From 416a3148e9781dfdd1efd15367a4facdf45e46ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 03:46:18 +0100 Subject: [PATCH] refactor: split cli backend live helpers --- src/cli/command-catalog.ts | 4 - src/cli/command-path-policy.ts | 7 + src/gateway/call.ts | 14 +- src/gateway/explicit-connection-policy.ts | 22 + .../gateway-cli-backend.live-helpers.ts | 531 +++++++++++++++++ src/gateway/gateway-cli-backend.live.test.ts | 550 ++---------------- src/gateway/mcp-http.runtime.ts | 2 +- src/gateway/mcp-http.test.ts | 2 +- src/gateway/tool-resolution.ts | 12 +- src/gateway/tools-invoke-http.ts | 2 +- 10 files changed, 630 insertions(+), 516 deletions(-) create mode 100644 src/gateway/explicit-connection-policy.ts create mode 100644 src/gateway/gateway-cli-backend.live-helpers.ts diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 52c5c8e6e73..4244d1ec3e3 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -56,10 +56,6 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ policy: { routeConfigGuard: "always" }, route: { id: "gateway-status" }, }, - { - commandPath: ["cron"], - policy: { bypassConfigGuard: true }, - }, { commandPath: ["sessions"], exact: true, diff --git a/src/cli/command-path-policy.ts b/src/cli/command-path-policy.ts index 13b4b913d0f..cc73091bc2a 100644 --- a/src/cli/command-path-policy.ts +++ b/src/cli/command-path-policy.ts @@ -1,3 +1,4 @@ +import { isGatewayConfigBypassCommandPath } from "../gateway/explicit-connection-policy.js"; import { cliCommandCatalog, type CliCommandPathPolicy } from "./command-catalog.js"; import { matchesCommandPath } from "./command-path-matches.js"; @@ -23,5 +24,11 @@ export function resolveCliCommandPathPolicy(commandPath: string[]): CliCommandPa ...entry.policy, }; } + if (isGatewayConfigBypassCommandPath(commandPath)) { + resolvedPolicy = { + ...resolvedPolicy, + bypassConfigGuard: true, + }; + } return resolvedPolicy; } diff --git a/src/gateway/call.ts b/src/gateway/call.ts index b493cef6e83..f5edb155a8f 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -32,11 +32,13 @@ import { GatewaySecretRefUnavailableError, resolveGatewayCredentialsFromConfig, trimToUndefined, + type ExplicitGatewayAuth, type GatewayCredentialMode, type GatewayCredentialPrecedence, type GatewayRemoteCredentialFallback, type GatewayRemoteCredentialPrecedence, } from "./credentials.js"; +import { canSkipGatewayConfigLoad } from "./explicit-connection-policy.js"; import { CLI_DEFAULT_OPERATOR_SCOPES, resolveLeastPrivilegeOperatorScopesForMethod, @@ -200,10 +202,7 @@ function resolveDeviceIdentityForGatewayCall(): ReturnType< } } -export type ExplicitGatewayAuth = { - token?: string; - password?: string; -}; +export type { ExplicitGatewayAuth } from "./credentials.js"; export function resolveExplicitGatewayAuth(opts?: ExplicitGatewayAuth): ExplicitGatewayAuth { const token = @@ -296,8 +295,11 @@ function resolveGatewayCallContext(opts: CallGatewayBaseOptions): ResolvedGatewa : trimToUndefined(process.env.OPENCLAW_GATEWAY_URL); const urlOverride = cliUrlOverride ?? envUrlOverride; const urlOverrideSource = cliUrlOverride ? "cli" : envUrlOverride ? "env" : undefined; - const canSkipConfigLoad = - !opts.config && urlOverride && (explicitAuth.token || explicitAuth.password); + const canSkipConfigLoad = canSkipGatewayConfigLoad({ + config: opts.config, + urlOverride, + explicitAuth, + }); const config = opts.config ?? (canSkipConfigLoad ? ({} as OpenClawConfig) : loadGatewayConfig()); const configPath = opts.configPath ?? resolveGatewayConfigPath(process.env); const isRemoteMode = config.gateway?.mode === "remote"; diff --git a/src/gateway/explicit-connection-policy.ts b/src/gateway/explicit-connection-policy.ts new file mode 100644 index 00000000000..66552789447 --- /dev/null +++ b/src/gateway/explicit-connection-policy.ts @@ -0,0 +1,22 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { trimToUndefined, type ExplicitGatewayAuth } from "./credentials.js"; + +export function hasExplicitGatewayConnectionAuth(auth?: ExplicitGatewayAuth): boolean { + return Boolean(trimToUndefined(auth?.token) || trimToUndefined(auth?.password)); +} + +export function canSkipGatewayConfigLoad(params: { + config?: OpenClawConfig; + urlOverride?: string; + explicitAuth?: ExplicitGatewayAuth; +}): boolean { + return ( + !params.config && + Boolean(trimToUndefined(params.urlOverride)) && + hasExplicitGatewayConnectionAuth(params.explicitAuth) + ); +} + +export function isGatewayConfigBypassCommandPath(commandPath: readonly string[]): boolean { + return commandPath[0] === "cron"; +} diff --git a/src/gateway/gateway-cli-backend.live-helpers.ts b/src/gateway/gateway-cli-backend.live-helpers.ts new file mode 100644 index 00000000000..e8b8552e018 --- /dev/null +++ b/src/gateway/gateway-cli-backend.live-helpers.ts @@ -0,0 +1,531 @@ +import { execFile } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { expect } from "vitest"; +import { isTruthyEnvValue } from "../infra/env.js"; +import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js"; +import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { GatewayClient } from "./client.js"; +import { renderCatNoncePngBase64 } from "./live-image-probe.js"; +import { extractPayloadText } from "./test-helpers.agent-results.js"; + +const execFileAsync = promisify(execFile); +const CLI_GATEWAY_CONNECT_TIMEOUT_MS = 30_000; + +export const DEFAULT_CLAUDE_ARGS = [ + "-p", + "--output-format", + "stream-json", + "--include-partial-messages", + "--verbose", + "--setting-sources", + "user", + "--permission-mode", + "bypassPermissions", +]; + +export const DEFAULT_CODEX_ARGS = [ + "exec", + "--json", + "--color", + "never", + "--sandbox", + "read-only", + "--skip-git-repo-check", +]; + +export const DEFAULT_CLEAR_ENV = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_API_KEY_OLD", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_UNIX_SOCKET", + "CLAUDE_CONFIG_DIR", + "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_OAUTH_REFRESH_TOKEN", + "CLAUDE_CODE_OAUTH_SCOPES", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + "CLAUDE_CODE_PLUGIN_CACHE_DIR", + "CLAUDE_CODE_PLUGIN_SEED_DIR", + "CLAUDE_CODE_REMOTE", + "CLAUDE_CODE_USE_COWORK_PLUGINS", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_USE_VERTEX", +]; + +export type BootstrapWorkspaceContext = { + expectedInjectedFiles: string[]; + workspaceDir: string; + workspaceRootDir: string; +}; + +export type SystemPromptReport = { + injectedWorkspaceFiles?: Array<{ name?: string }>; +}; + +export type CronListCliResult = { + jobs?: Array<{ + id?: string; + name?: string; + sessionTarget?: string; + agentId?: string | null; + sessionKey?: string | null; + payload?: { kind?: string; text?: string; message?: string }; + }>; +}; + +type CronListJob = NonNullable[number]; + +export type CliBackendLiveEnvSnapshot = { + configPath?: string; + token?: string; + skipChannels?: string; + skipGmail?: string; + skipCron?: string; + skipCanvas?: string; + anthropicApiKey?: string; + anthropicApiKeyOld?: string; +}; + +export function randomImageProbeCode(len = 6): string { + // Chosen to avoid common OCR confusions in our 5x7 bitmap font. + // Notably: 0↔8, B↔8, 6↔9, 3↔B, D↔0. + const alphabet = "24567ACEF"; + const bytes = randomBytes(len); + let out = ""; + for (let i = 0; i < len; i += 1) { + out += alphabet[bytes[i] % alphabet.length]; + } + return out; +} + +export function parseJsonStringArray(name: string, raw?: string): string[] | undefined { + const trimmed = raw?.trim(); + if (!trimmed) { + return undefined; + } + const parsed = JSON.parse(trimmed); + if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) { + throw new Error(`${name} must be a JSON array of strings.`); + } + return parsed; +} + +export function parseImageMode(raw?: string): "list" | "repeat" | undefined { + const trimmed = raw?.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed === "list" || trimmed === "repeat") { + return trimmed; + } + throw new Error("OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE must be 'list' or 'repeat'."); +} + +export function shouldRunCliImageProbe(providerId: string): boolean { + const raw = process.env.OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE?.trim(); + if (raw) { + return isTruthyEnvValue(raw); + } + return providerId === "claude-cli"; +} + +export function matchesCliBackendReply(text: string, expected: string): boolean { + const normalized = text.trim(); + const target = expected.trim(); + return normalized === target || normalized === target.slice(0, -1); +} + +export function withMcpConfigOverrides(args: string[], mcpConfigPath: string): string[] { + const next = [...args]; + if (!next.includes("--strict-mcp-config")) { + next.push("--strict-mcp-config"); + } + if (!next.includes("--mcp-config")) { + next.push("--mcp-config", mcpConfigPath); + } + return next; +} + +export async function getFreeGatewayPort(): Promise { + return await getFreePortBlockWithPermissionFallback({ + offsets: [0, 1, 2, 4], + fallbackBase: 40_000, + }); +} + +export async function createBootstrapWorkspace( + tempDir: string, +): Promise { + const workspaceRootDir = path.join(tempDir, "workspace"); + const workspaceDir = path.join(workspaceRootDir, "dev"); + const expectedInjectedFiles = ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md"]; + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile( + path.join(workspaceDir, "AGENTS.md"), + [ + "# AGENTS.md", + "", + "Follow exact reply instructions from the user.", + "Do not add extra punctuation when the user asks for an exact response.", + ].join("\n"), + ); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), `SOUL-${randomUUID()}\n`); + await fs.writeFile(path.join(workspaceDir, "IDENTITY.md"), `IDENTITY-${randomUUID()}\n`); + await fs.writeFile(path.join(workspaceDir, "USER.md"), `USER-${randomUUID()}\n`); + return { expectedInjectedFiles, workspaceDir, workspaceRootDir }; +} + +export async function runOpenClawCliJson(args: string[], env: NodeJS.ProcessEnv): Promise { + const childEnv = { ...env }; + delete childEnv.VITEST; + delete childEnv.VITEST_MODE; + delete childEnv.VITEST_POOL_ID; + delete childEnv.VITEST_WORKER_ID; + const { stdout, stderr } = await execFileAsync(process.execPath, ["openclaw.mjs", ...args], { + cwd: process.cwd(), + env: childEnv, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error( + [ + `openclaw ${args.join(" ")} produced no JSON stdout`, + stderr.trim() ? `stderr: ${stderr.trim()}` : undefined, + ] + .filter(Boolean) + .join("\n"), + ); + } + try { + return JSON.parse(trimmed) as T; + } catch (error) { + throw new Error( + [ + `openclaw ${args.join(" ")} returned invalid JSON`, + `stdout: ${trimmed}`, + stderr.trim() ? `stderr: ${stderr.trim()}` : undefined, + error instanceof Error ? `cause: ${error.message}` : undefined, + ] + .filter(Boolean) + .join("\n"), + { cause: error }, + ); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function connectTestGatewayClient(params: { + url: string; + token: string; +}): Promise { + const startedAt = Date.now(); + let attempt = 0; + let lastError: Error | null = null; + + while (Date.now() - startedAt < CLI_GATEWAY_CONNECT_TIMEOUT_MS) { + attempt += 1; + const remainingMs = CLI_GATEWAY_CONNECT_TIMEOUT_MS - (Date.now() - startedAt); + if (remainingMs <= 0) { + break; + } + try { + return await connectClientOnce({ + ...params, + timeoutMs: Math.min(remainingMs, 35_000), + }); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (!isRetryableGatewayConnectError(lastError) || remainingMs <= 5_000) { + throw lastError; + } + await sleep(Math.min(1_000 * attempt, 5_000)); + } + } + + throw lastError ?? new Error("gateway connect timeout"); +} + +async function connectClientOnce(params: { + url: string; + token: string; + timeoutMs: number; +}): Promise { + return await new Promise((resolve, reject) => { + let done = false; + let client: GatewayClient | undefined; + const finish = (result: { client?: GatewayClient; error?: Error }) => { + if (done) { + return; + } + done = true; + clearTimeout(connectTimeout); + if (result.error) { + if (client) { + void client.stopAndWait({ timeoutMs: 1_000 }).catch(() => {}); + } + reject(result.error); + return; + } + resolve(result.client as GatewayClient); + }; + + const failWithClose = (code: number, reason: string) => + finish({ error: new Error(`gateway closed during connect (${code}): ${reason}`) }); + + client = new GatewayClient({ + url: params.url, + token: params.token, + clientName: GATEWAY_CLIENT_NAMES.TEST, + clientVersion: "dev", + mode: "test", + requestTimeoutMs: params.timeoutMs, + connectChallengeTimeoutMs: params.timeoutMs, + onHelloOk: () => finish({ client }), + onConnectError: (error) => finish({ error }), + onClose: failWithClose, + }); + + const connectTimeout = setTimeout( + () => finish({ error: new Error("gateway connect timeout") }), + params.timeoutMs, + ); + connectTimeout.unref(); + client.start(); + }); +} + +function isRetryableGatewayConnectError(error: Error): boolean { + const message = error.message.toLowerCase(); + return ( + message.includes("gateway closed during connect (1000)") || + message.includes("gateway connect timeout") || + message.includes("gateway connect challenge timeout") || + message.includes("gateway request timeout for connect") || + message.includes("gateway client stopped") + ); +} + +export function snapshotCliBackendLiveEnv(): CliBackendLiveEnvSnapshot { + return { + configPath: process.env.OPENCLAW_CONFIG_PATH, + token: process.env.OPENCLAW_GATEWAY_TOKEN, + skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, + skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, + skipCron: process.env.OPENCLAW_SKIP_CRON, + skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, + anthropicApiKey: process.env.ANTHROPIC_API_KEY, + anthropicApiKeyOld: process.env.ANTHROPIC_API_KEY_OLD, + }; +} + +export function applyCliBackendLiveEnv(preservedEnv: ReadonlySet): void { + process.env.OPENCLAW_SKIP_CHANNELS = "1"; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; + process.env.OPENCLAW_SKIP_CRON = "1"; + process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; + if (!preservedEnv.has("ANTHROPIC_API_KEY")) { + delete process.env.ANTHROPIC_API_KEY; + } + if (!preservedEnv.has("ANTHROPIC_API_KEY_OLD")) { + delete process.env.ANTHROPIC_API_KEY_OLD; + } +} + +export function restoreCliBackendLiveEnv(snapshot: CliBackendLiveEnvSnapshot): void { + restoreEnvVar("OPENCLAW_CONFIG_PATH", snapshot.configPath); + restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", snapshot.token); + restoreEnvVar("OPENCLAW_SKIP_CHANNELS", snapshot.skipChannels); + restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", snapshot.skipGmail); + restoreEnvVar("OPENCLAW_SKIP_CRON", snapshot.skipCron); + restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", snapshot.skipCanvas); + restoreEnvVar("ANTHROPIC_API_KEY", snapshot.anthropicApiKey); + restoreEnvVar("ANTHROPIC_API_KEY_OLD", snapshot.anthropicApiKeyOld); +} + +function restoreEnvVar(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +export async function verifyCliBackendImageProbe(params: { + client: GatewayClient; + providerId: string; + sessionKey: string; + tempDir: string; + bootstrapWorkspace: BootstrapWorkspaceContext | null; +}): Promise { + const imageCode = randomImageProbeCode(); + const imageBase64 = renderCatNoncePngBase64(imageCode); + const runIdImage = randomUUID(); + const imageFilePath = path.join( + params.bootstrapWorkspace?.workspaceDir ?? params.tempDir, + `probe-${runIdImage}.png`, + ); + await fs.writeFile(imageFilePath, Buffer.from(imageBase64, "base64")); + + const imageProbe = await params.client.request( + "agent", + params.providerId === "claude-cli" + ? { + sessionKey: params.sessionKey, + idempotencyKey: `idem-${runIdImage}-image`, + message: + `Image path: ${imageFilePath}\n` + + "Best match: lobster, mouse, cat, horse. " + + "Reply with one lowercase word only.", + deliver: false, + } + : { + sessionKey: params.sessionKey, + idempotencyKey: `idem-${runIdImage}-image`, + message: + "Best match for the attached image: lobster, mouse, cat, horse. " + + "Reply with one lowercase word only.", + attachments: [ + { + mimeType: "image/png", + fileName: `probe-${runIdImage}.png`, + content: imageBase64, + }, + ], + deliver: false, + }, + { expectFinal: true }, + ); + if (imageProbe?.status !== "ok") { + throw new Error(`image probe failed: status=${String(imageProbe?.status)}`); + } + const imageText = extractPayloadText(imageProbe?.result).trim().toLowerCase(); + if (imageText !== "cat") { + throw new Error(`image probe expected 'cat', got: ${imageText}`); + } +} + +export async function verifyClaudeCliCronMcpProbe(params: { + client: GatewayClient; + sessionKey: string; + port: number; + token: string; + env: NodeJS.ProcessEnv; +}): Promise { + const cronProbeNonce = randomBytes(3).toString("hex").toUpperCase(); + const cronProbeName = `live-mcp-${cronProbeNonce.toLowerCase()}`; + const cronProbeMessage = `probe-${cronProbeNonce.toLowerCase()}`; + const cronProbeAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + const cronArgsJson = JSON.stringify({ + action: "add", + job: { + name: cronProbeName, + schedule: { kind: "at", at: cronProbeAt }, + payload: { kind: "agentTurn", message: cronProbeMessage }, + sessionTarget: "current", + enabled: true, + }, + }); + + let createdJob: CronListJob | undefined; + let lastCronText = ""; + + for (let attempt = 0; attempt < 2 && !createdJob; attempt += 1) { + const runIdMcp = randomUUID(); + const cronProbe = await params.client.request( + "agent", + { + sessionKey: params.sessionKey, + idempotencyKey: `idem-${runIdMcp}-mcp-${attempt}`, + message: + attempt === 0 + ? "Use the OpenClaw MCP tool named cron. " + + `Call it with JSON arguments ${cronArgsJson}. ` + + "Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " + + `After the cron job is created, reply exactly: ${cronProbeName}` + : "Return only a tool call for the OpenClaw MCP tool `cron`. " + + `Use these exact JSON arguments: ${cronArgsJson}. ` + + "No prose. I will verify externally with the OpenClaw cron CLI.", + deliver: false, + }, + { expectFinal: true }, + ); + if (cronProbe?.status !== "ok") { + throw new Error(`cron mcp probe failed: status=${String(cronProbe?.status)}`); + } + lastCronText = extractPayloadText(cronProbe?.result).trim(); + createdJob = await assertCronJobVisibleViaCli({ + port: params.port, + token: params.token, + env: params.env, + expectedName: cronProbeName, + expectedMessage: cronProbeMessage, + }); + if (!createdJob && attempt === 1) { + throw new Error( + `cron cli verify could not find job ${cronProbeName}: reply=${JSON.stringify(lastCronText)}`, + ); + } + } + + if (!createdJob) { + throw new Error(`cron cli verify did not create job ${cronProbeName}`); + } + expect(createdJob.name).toBe(cronProbeName); + expect(createdJob?.payload?.kind).toBe("agentTurn"); + expect(createdJob?.payload?.message).toBe(cronProbeMessage); + expect(createdJob?.agentId).toBe("dev"); + expect(createdJob?.sessionKey).toBe(params.sessionKey); + expect(createdJob?.sessionTarget).toBe(`session:${params.sessionKey}`); + if (createdJob?.id) { + await runOpenClawCliJson( + [ + "cron", + "rm", + createdJob.id, + "--json", + "--url", + `ws://127.0.0.1:${params.port}`, + "--token", + params.token, + ], + params.env, + ); + } +} + +export async function assertCronJobVisibleViaCli(params: { + port: number; + token: string; + env: NodeJS.ProcessEnv; + expectedName: string; + expectedMessage: string; +}): Promise { + const cronList = await runOpenClawCliJson( + [ + "cron", + "list", + "--all", + "--json", + "--url", + `ws://127.0.0.1:${params.port}`, + "--token", + params.token, + ], + params.env, + ); + return ( + cronList.jobs?.find((job) => job.name === params.expectedName) ?? + cronList.jobs?.find((job) => job.payload?.message === params.expectedMessage) + ); +} diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 2b304ba8f7c..1d26a254af6 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -1,22 +1,34 @@ -import { execFile } from "node:child_process"; import { randomBytes, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; import { parseModelRef } from "../agents/model-selection.js"; import { clearRuntimeConfigSnapshot, type OpenClawConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; -import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js"; -import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; -import { GatewayClient } from "./client.js"; -import { renderCatNoncePngBase64 } from "./live-image-probe.js"; +import { + applyCliBackendLiveEnv, + createBootstrapWorkspace, + DEFAULT_CLAUDE_ARGS, + DEFAULT_CLEAR_ENV, + DEFAULT_CODEX_ARGS, + getFreeGatewayPort, + matchesCliBackendReply, + parseImageMode, + parseJsonStringArray, + restoreCliBackendLiveEnv, + shouldRunCliImageProbe, + snapshotCliBackendLiveEnv, + type SystemPromptReport, + verifyClaudeCliCronMcpProbe, + verifyCliBackendImageProbe, + withMcpConfigOverrides, + connectTestGatewayClient, +} from "./gateway-cli-backend.live-helpers.js"; import { startGatewayServer } from "./server.js"; import { extractPayloadText } from "./test-helpers.agent-results.js"; -const execFileAsync = promisify(execFile); const LIVE = isLiveTestEnabled(); const CLI_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND); const CLI_RESUME = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE); @@ -24,285 +36,6 @@ const describeLive = LIVE && CLI_LIVE ? describe : describe.skip; const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-6"; const CLI_BACKEND_LIVE_TIMEOUT_MS = 420_000; -const CLI_GATEWAY_CONNECT_TIMEOUT_MS = 30_000; -const DEFAULT_CLAUDE_ARGS = [ - "-p", - "--output-format", - "stream-json", - "--include-partial-messages", - "--verbose", - "--setting-sources", - "user", - "--permission-mode", - "bypassPermissions", -]; -const DEFAULT_CODEX_ARGS = [ - "exec", - "--json", - "--color", - "never", - "--sandbox", - "read-only", - "--skip-git-repo-check", -]; -const DEFAULT_CLEAR_ENV = [ - "ANTHROPIC_API_KEY", - "ANTHROPIC_API_KEY_OLD", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_BASE_URL", - "ANTHROPIC_UNIX_SOCKET", - "CLAUDE_CONFIG_DIR", - "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", - "CLAUDE_CODE_ENTRYPOINT", - "CLAUDE_CODE_OAUTH_REFRESH_TOKEN", - "CLAUDE_CODE_OAUTH_SCOPES", - "CLAUDE_CODE_OAUTH_TOKEN", - "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", - "CLAUDE_CODE_PLUGIN_CACHE_DIR", - "CLAUDE_CODE_PLUGIN_SEED_DIR", - "CLAUDE_CODE_REMOTE", - "CLAUDE_CODE_USE_COWORK_PLUGINS", - "CLAUDE_CODE_USE_BEDROCK", - "CLAUDE_CODE_USE_FOUNDRY", - "CLAUDE_CODE_USE_VERTEX", -]; - -function randomImageProbeCode(len = 6): string { - // Chosen to avoid common OCR confusions in our 5x7 bitmap font. - // Notably: 0↔8, B↔8, 6↔9, 3↔B, D↔0. - // Must stay within the glyph set in `src/gateway/live-image-probe.ts`. - const alphabet = "24567ACEF"; - const bytes = randomBytes(len); - let out = ""; - for (let i = 0; i < len; i += 1) { - out += alphabet[bytes[i] % alphabet.length]; - } - return out; -} - -function parseJsonStringArray(name: string, raw?: string): string[] | undefined { - const trimmed = raw?.trim(); - if (!trimmed) { - return undefined; - } - const parsed = JSON.parse(trimmed); - if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) { - throw new Error(`${name} must be a JSON array of strings.`); - } - return parsed; -} - -function parseImageMode(raw?: string): "list" | "repeat" | undefined { - const trimmed = raw?.trim(); - if (!trimmed) { - return undefined; - } - if (trimmed === "list" || trimmed === "repeat") { - return trimmed; - } - throw new Error("OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE must be 'list' or 'repeat'."); -} - -function shouldRunCliImageProbe(providerId: string): boolean { - const raw = process.env.OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE?.trim(); - if (raw) { - return isTruthyEnvValue(raw); - } - return providerId === "claude-cli"; -} - -function matchesCliBackendReply(text: string, expected: string): boolean { - const normalized = text.trim(); - const target = expected.trim(); - return normalized === target || normalized === target.slice(0, -1); -} - -function withMcpConfigOverrides(args: string[], mcpConfigPath: string): string[] { - const next = [...args]; - if (!next.includes("--strict-mcp-config")) { - next.push("--strict-mcp-config"); - } - if (!next.includes("--mcp-config")) { - next.push("--mcp-config", mcpConfigPath); - } - return next; -} - -async function getFreeGatewayPort(): Promise { - return await getFreePortBlockWithPermissionFallback({ - offsets: [0, 1, 2, 4], - fallbackBase: 40_000, - }); -} - -type BootstrapWorkspaceContext = { - expectedInjectedFiles: string[]; - workspaceDir: string; - workspaceRootDir: string; -}; - -type SystemPromptReport = { - injectedWorkspaceFiles?: Array<{ name?: string }>; -}; - -type CronListCliResult = { - jobs?: Array<{ - id?: string; - name?: string; - sessionTarget?: string; - agentId?: string | null; - sessionKey?: string | null; - payload?: { kind?: string; text?: string; message?: string }; - }>; -}; - -async function createBootstrapWorkspace(tempDir: string): Promise { - const workspaceRootDir = path.join(tempDir, "workspace"); - const workspaceDir = path.join(workspaceRootDir, "dev"); - const expectedInjectedFiles = ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md"]; - await fs.mkdir(workspaceDir, { recursive: true }); - await fs.writeFile( - path.join(workspaceDir, "AGENTS.md"), - [ - "# AGENTS.md", - "", - "Follow exact reply instructions from the user.", - "Do not add extra punctuation when the user asks for an exact response.", - ].join("\n"), - ); - await fs.writeFile(path.join(workspaceDir, "SOUL.md"), `SOUL-${randomUUID()}\n`); - await fs.writeFile(path.join(workspaceDir, "IDENTITY.md"), `IDENTITY-${randomUUID()}\n`); - await fs.writeFile(path.join(workspaceDir, "USER.md"), `USER-${randomUUID()}\n`); - return { expectedInjectedFiles, workspaceDir, workspaceRootDir }; -} - -async function runOpenClawCliJson(args: string[], env: NodeJS.ProcessEnv): Promise { - const childEnv = { ...env }; - delete childEnv.VITEST; - delete childEnv.VITEST_MODE; - delete childEnv.VITEST_POOL_ID; - delete childEnv.VITEST_WORKER_ID; - const { stdout, stderr } = await execFileAsync(process.execPath, ["openclaw.mjs", ...args], { - cwd: process.cwd(), - env: childEnv, - timeout: 30_000, - maxBuffer: 1024 * 1024, - }); - const trimmed = stdout.trim(); - if (!trimmed) { - throw new Error( - [ - `openclaw ${args.join(" ")} produced no JSON stdout`, - stderr.trim() ? `stderr: ${stderr.trim()}` : undefined, - ] - .filter(Boolean) - .join("\n"), - ); - } - try { - return JSON.parse(trimmed) as T; - } catch (error) { - throw new Error( - [ - `openclaw ${args.join(" ")} returned invalid JSON`, - `stdout: ${trimmed}`, - stderr.trim() ? `stderr: ${stderr.trim()}` : undefined, - error instanceof Error ? `cause: ${error.message}` : undefined, - ] - .filter(Boolean) - .join("\n"), - { cause: error }, - ); - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function connectClient(params: { url: string; token: string }) { - const startedAt = Date.now(); - let attempt = 0; - let lastError: Error | null = null; - - while (Date.now() - startedAt < CLI_GATEWAY_CONNECT_TIMEOUT_MS) { - attempt += 1; - const remainingMs = CLI_GATEWAY_CONNECT_TIMEOUT_MS - (Date.now() - startedAt); - if (remainingMs <= 0) { - break; - } - try { - return await connectClientOnce({ - ...params, - timeoutMs: Math.min(remainingMs, 35_000), - }); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - if (!isRetryableGatewayConnectError(lastError) || remainingMs <= 5_000) { - throw lastError; - } - await sleep(Math.min(1_000 * attempt, 5_000)); - } - } - - throw lastError ?? new Error("gateway connect timeout"); -} - -async function connectClientOnce(params: { url: string; token: string; timeoutMs: number }) { - return await new Promise((resolve, reject) => { - let done = false; - let client: GatewayClient | undefined; - const finish = (result: { client?: GatewayClient; error?: Error }) => { - if (done) { - return; - } - done = true; - clearTimeout(connectTimeout); - if (result.error) { - if (client) { - void client.stopAndWait({ timeoutMs: 1_000 }).catch(() => {}); - } - reject(result.error); - return; - } - resolve(result.client as GatewayClient); - }; - - const failWithClose = (code: number, reason: string) => - finish({ error: new Error(`gateway closed during connect (${code}): ${reason}`) }); - - client = new GatewayClient({ - url: params.url, - token: params.token, - clientName: GATEWAY_CLIENT_NAMES.TEST, - clientVersion: "dev", - mode: "test", - requestTimeoutMs: params.timeoutMs, - connectChallengeTimeoutMs: params.timeoutMs, - onHelloOk: () => finish({ client }), - onConnectError: (error) => finish({ error }), - onClose: failWithClose, - }); - - const connectTimeout = setTimeout( - () => finish({ error: new Error("gateway connect timeout") }), - params.timeoutMs, - ); - connectTimeout.unref(); - client.start(); - }); -} - -function isRetryableGatewayConnectError(error: Error): boolean { - const message = error.message.toLowerCase(); - return ( - message.includes("gateway closed during connect (1000)") || - message.includes("gateway connect timeout") || - message.includes("gateway connect challenge timeout") || - message.includes("gateway request timeout for connect") || - message.includes("gateway client stopped") - ); -} describeLive("gateway live (cli backend)", () => { it( @@ -314,29 +47,10 @@ describeLive("gateway live (cli backend)", () => { process.env.OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV, ) ?? [], ); + const previousEnv = snapshotCliBackendLiveEnv(); clearRuntimeConfigSnapshot(); - const previous = { - configPath: process.env.OPENCLAW_CONFIG_PATH, - token: process.env.OPENCLAW_GATEWAY_TOKEN, - skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, - skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, - skipCron: process.env.OPENCLAW_SKIP_CRON, - skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, - anthropicApiKey: process.env.ANTHROPIC_API_KEY, - anthropicApiKeyOld: process.env.ANTHROPIC_API_KEY_OLD, - }; - - process.env.OPENCLAW_SKIP_CHANNELS = "1"; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; - process.env.OPENCLAW_SKIP_CRON = "1"; - process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; - if (!preservedEnv.has("ANTHROPIC_API_KEY")) { - delete process.env.ANTHROPIC_API_KEY; - } - if (!preservedEnv.has("ANTHROPIC_API_KEY_OLD")) { - delete process.env.ANTHROPIC_API_KEY_OLD; - } + applyCliBackendLiveEnv(preservedEnv); const token = `test-${randomUUID()}`; process.env.OPENCLAW_GATEWAY_TOKEN = token; @@ -349,10 +63,10 @@ describeLive("gateway live (cli backend)", () => { `OPENCLAW_LIVE_CLI_BACKEND_MODEL must resolve to a CLI backend model. Got: ${rawModel}`, ); } + const providerId = parsed.provider; const modelKey = `${providerId}/${parsed.model}`; const enableCliImageProbe = shouldRunCliImageProbe(providerId); - const providerDefaults = providerId === "claude-cli" ? { @@ -374,6 +88,7 @@ describeLive("gateway live (cli backend)", () => { `OPENCLAW_LIVE_CLI_BACKEND_COMMAND is required for provider "${providerId}".`, ); } + const baseCliArgs = parseJsonStringArray( "OPENCLAW_LIVE_CLI_BACKEND_ARGS", @@ -382,6 +97,7 @@ describeLive("gateway live (cli backend)", () => { if (!baseCliArgs || baseCliArgs.length === 0) { throw new Error(`OPENCLAW_LIVE_CLI_BACKEND_ARGS is required for provider "${providerId}".`); } + const cliClearEnv = parseJsonStringArray( "OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV", @@ -398,7 +114,6 @@ describeLive("gateway live (cli backend)", () => { const cliImageMode = parseImageMode(process.env.OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE) ?? providerDefaults?.imageMode; - if (cliImageMode && !cliImageArg) { throw new Error( "OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE requires OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG.", @@ -438,9 +153,7 @@ describeLive("gateway live (cli backend)", () => { ...cfg.agents?.defaults, ...(bootstrapWorkspace ? { workspace: bootstrapWorkspace.workspaceRootDir } : {}), model: { primary: modelKey }, - models: { - [modelKey]: {}, - }, + models: { [modelKey]: {} }, cliBackends: { ...existingBackends, [providerId]: { @@ -465,26 +178,23 @@ describeLive("gateway live (cli backend)", () => { auth: { mode: "token", token }, controlUiEnabled: false, }); - - const client = await connectClient({ + const client = await connectTestGatewayClient({ url: `ws://127.0.0.1:${port}`, token, }); try { const sessionKey = "agent:dev:live-cli-backend"; - const runId = randomUUID(); const nonce = randomBytes(3).toString("hex").toUpperCase(); - const message = - providerId === "codex-cli" - ? `Please include the token CLI-BACKEND-${nonce} in your reply.` - : `Reply with exactly: CLI backend OK ${nonce}.`; const payload = await client.request( "agent", { sessionKey, - idempotencyKey: `idem-${runId}`, - message, + idempotencyKey: `idem-${randomUUID()}`, + message: + providerId === "codex-cli" + ? `Please include the token CLI-BACKEND-${nonce} in your reply.` + : `Reply with exactly: CLI backend OK ${nonce}.`, deliver: false, }, { expectFinal: true }, @@ -492,6 +202,7 @@ describeLive("gateway live (cli backend)", () => { if (payload?.status !== "ok") { throw new Error(`agent status=${String(payload?.status)}`); } + const text = extractPayloadText(payload?.result); if (providerId === "codex-cli") { expect(text).toContain(`CLI-BACKEND-${nonce}`); @@ -508,18 +219,16 @@ describeLive("gateway live (cli backend)", () => { } if (CLI_RESUME) { - const runIdResume = randomUUID(); const resumeNonce = randomBytes(3).toString("hex").toUpperCase(); - const resumeMessage = - providerId === "codex-cli" - ? `Please include the token CLI-RESUME-${resumeNonce} in your reply.` - : `Reply with exactly: CLI backend RESUME OK ${resumeNonce}.`; const resumePayload = await client.request( "agent", { sessionKey, - idempotencyKey: `idem-${runIdResume}`, - message: resumeMessage, + idempotencyKey: `idem-${randomUUID()}`, + message: + providerId === "codex-cli" + ? `Please include the token CLI-RESUME-${resumeNonce} in your reply.` + : `Reply with exactly: CLI backend RESUME OK ${resumeNonce}.`, deliver: false, }, { expectFinal: true }, @@ -538,187 +247,30 @@ describeLive("gateway live (cli backend)", () => { } if (enableCliImageProbe) { - const imageCode = randomImageProbeCode(); - const imageBase64 = renderCatNoncePngBase64(imageCode); - const runIdImage = randomUUID(); - const imageFilePath = path.join( - bootstrapWorkspace?.workspaceDir ?? tempDir, - `probe-${runIdImage}.png`, - ); - await fs.writeFile(imageFilePath, Buffer.from(imageBase64, "base64")); - - const imageProbe = await client.request( - "agent", - providerId === "claude-cli" - ? { - sessionKey, - idempotencyKey: `idem-${runIdImage}-image`, - message: - `Image path: ${imageFilePath}\n` + - "Best match: lobster, mouse, cat, horse. " + - "Reply with one lowercase word only.", - deliver: false, - } - : { - sessionKey, - idempotencyKey: `idem-${runIdImage}-image`, - message: - "Best match for the attached image: lobster, mouse, cat, horse. " + - "Reply with one lowercase word only.", - attachments: [ - { - mimeType: "image/png", - fileName: `probe-${runIdImage}.png`, - content: imageBase64, - }, - ], - deliver: false, - }, - { expectFinal: true }, - ); - if (imageProbe?.status !== "ok") { - throw new Error(`image probe failed: status=${String(imageProbe?.status)}`); - } - const imageText = extractPayloadText(imageProbe?.result).trim().toLowerCase(); - if (imageText !== "cat") { - throw new Error(`image probe expected 'cat', got: ${imageText}`); - } + await verifyCliBackendImageProbe({ + client, + providerId, + sessionKey, + tempDir, + bootstrapWorkspace, + }); } if (providerId === "claude-cli") { - const cronProbeNonce = randomBytes(3).toString("hex").toUpperCase(); - const cronProbeName = `live-mcp-${cronProbeNonce.toLowerCase()}`; - const cronProbeMessage = `probe-${cronProbeNonce.toLowerCase()}`; - const cronProbeAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); - const cronArgsJson = JSON.stringify({ - action: "add", - job: { - name: cronProbeName, - schedule: { kind: "at", at: cronProbeAt }, - payload: { kind: "agentTurn", message: cronProbeMessage }, - sessionTarget: "current", - enabled: true, - }, + await verifyClaudeCliCronMcpProbe({ + client, + sessionKey, + port, + token, + env: process.env, }); - let createdJob: CronListCliResult["jobs"] extends Array ? T | undefined : never; - let lastCronText = ""; - - for (let attempt = 0; attempt < 2 && !createdJob; attempt += 1) { - const runIdMcp = randomUUID(); - const cronProbe = await client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runIdMcp}-mcp-${attempt}`, - message: - attempt === 0 - ? "Use the OpenClaw MCP tool named cron. " + - `Call it with JSON arguments ${cronArgsJson}. ` + - "Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " + - `After the cron job is created, reply exactly: ${cronProbeName}` - : "Return only a tool call for the OpenClaw MCP tool `cron`. " + - `Use these exact JSON arguments: ${cronArgsJson}. ` + - "No prose. I will verify externally with the OpenClaw cron CLI.", - deliver: false, - }, - { expectFinal: true }, - ); - if (cronProbe?.status !== "ok") { - throw new Error(`cron mcp probe failed: status=${String(cronProbe?.status)}`); - } - lastCronText = extractPayloadText(cronProbe?.result).trim(); - const cronList = await runOpenClawCliJson( - [ - "cron", - "list", - "--all", - "--json", - "--url", - `ws://127.0.0.1:${port}`, - "--token", - token, - ], - process.env, - ); - createdJob = - cronList.jobs?.find((job) => job.name === cronProbeName) ?? - cronList.jobs?.find((job) => job.payload?.message === cronProbeMessage); - if (!createdJob && attempt === 1) { - throw new Error( - `cron cli verify could not find job ${cronProbeName}: reply=${JSON.stringify(lastCronText)} list=${JSON.stringify(cronList)}`, - ); - } - } - if (!createdJob) { - throw new Error(`cron cli verify did not create job ${cronProbeName}`); - } - expect(createdJob.name).toBe(cronProbeName); - expect(createdJob?.payload?.kind).toBe("agentTurn"); - expect(createdJob?.payload?.message).toBe(cronProbeMessage); - expect(createdJob?.agentId).toBe("dev"); - expect(createdJob?.sessionKey).toBe(sessionKey); - expect(createdJob?.sessionTarget).toBe(`session:${sessionKey}`); - if (createdJob?.id) { - await runOpenClawCliJson( - [ - "cron", - "rm", - createdJob.id, - "--json", - "--url", - `ws://127.0.0.1:${port}`, - "--token", - token, - ], - process.env, - ); - } } } finally { clearRuntimeConfigSnapshot(); await client.stopAndWait(); await server.close(); await fs.rm(tempDir, { recursive: true, force: true }); - if (previous.configPath === undefined) { - delete process.env.OPENCLAW_CONFIG_PATH; - } else { - process.env.OPENCLAW_CONFIG_PATH = previous.configPath; - } - if (previous.token === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = previous.token; - } - if (previous.skipChannels === undefined) { - delete process.env.OPENCLAW_SKIP_CHANNELS; - } else { - process.env.OPENCLAW_SKIP_CHANNELS = previous.skipChannels; - } - if (previous.skipGmail === undefined) { - delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER; - } else { - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = previous.skipGmail; - } - if (previous.skipCron === undefined) { - delete process.env.OPENCLAW_SKIP_CRON; - } else { - process.env.OPENCLAW_SKIP_CRON = previous.skipCron; - } - if (previous.skipCanvas === undefined) { - delete process.env.OPENCLAW_SKIP_CANVAS_HOST; - } else { - process.env.OPENCLAW_SKIP_CANVAS_HOST = previous.skipCanvas; - } - if (previous.anthropicApiKey === undefined) { - delete process.env.ANTHROPIC_API_KEY; - } else { - process.env.ANTHROPIC_API_KEY = previous.anthropicApiKey; - } - if (previous.anthropicApiKeyOld === undefined) { - delete process.env.ANTHROPIC_API_KEY_OLD; - } else { - process.env.ANTHROPIC_API_KEY_OLD = previous.anthropicApiKeyOld; - } + restoreCliBackendLiveEnv(previousEnv); } }, CLI_BACKEND_LIVE_TIMEOUT_MS, diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index cdbd58e5357..725d6afbe4f 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -45,7 +45,7 @@ export class McpLoopbackToolCache { sessionKey: params.sessionKey, messageProvider: params.messageProvider, accountId: params.accountId, - applyDefaultGatewayHttpDeny: false, + surface: "loopback", excludeToolNames: NATIVE_TOOL_EXCLUDE, }); const nextEntry: CachedScopedTools = { diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 765d295d5e6..231b34385ff 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -103,7 +103,7 @@ describe("mcp loopback server", () => { sessionKey: "agent:main:telegram:group:chat123", accountId: "work", messageProvider: "telegram", - applyDefaultGatewayHttpDeny: false, + surface: "loopback", }), ); }); diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 2b65e46e0ca..27b27034c3a 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -21,6 +21,8 @@ import { getPluginToolMeta } from "../plugins/tools.js"; import { isSubagentSessionKey } from "../routing/session-key.js"; import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js"; +export type GatewayScopedToolSurface = "http" | "loopback"; + export function resolveGatewayScopedTools(params: { cfg: ReturnType; sessionKey: string; @@ -30,7 +32,7 @@ export function resolveGatewayScopedTools(params: { agentThreadId?: string; allowGatewaySubagentBinding?: boolean; allowMediaInvokeCommands?: boolean; - applyDefaultGatewayHttpDeny?: boolean; + surface?: GatewayScopedToolSurface; excludeToolNames?: Iterable; disablePluginTools?: boolean; }) { @@ -112,10 +114,12 @@ export function resolveGatewayScopedTools(params: { ], }); + const surface = params.surface ?? "http"; const gatewayToolsCfg = params.cfg.gateway?.tools; - const defaultGatewayDeny = params.applyDefaultGatewayHttpDeny - ? DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) => !gatewayToolsCfg?.allow?.includes(name)) - : []; + const defaultGatewayDeny = + surface === "http" + ? DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) => !gatewayToolsCfg?.allow?.includes(name)) + : []; const gatewayDenySet = new Set([ ...defaultGatewayDeny, ...(Array.isArray(gatewayToolsCfg?.deny) ? gatewayToolsCfg.deny : []), diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 8741dad429d..56a2e0b202d 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -237,7 +237,7 @@ export async function handleToolsInvokeHttpRequest( agentThreadId, allowGatewaySubagentBinding: true, allowMediaInvokeCommands: true, - applyDefaultGatewayHttpDeny: true, + surface: "http", disablePluginTools: isKnownCoreToolId(toolName), }); // Owner semantics intentionally follow the same shared-secret HTTP contract