From 55d7f8a4182c827f40ce43db2dce2d89d14b4da0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 11:39:21 -0700 Subject: [PATCH] fix(agents): pin agent openclaw invocations to gateway CLI (#122765) --- src/agents/agent-tools.ts | 3 +- src/agents/lazy-exec-tool.ts | 3 +- src/gateway/server-kernel.ts | 2 + src/gateway/server-startup-bootstrap.ts | 4 + src/infra/openclaw-cli-invocation.test.ts | 20 ++++- src/infra/openclaw-cli-invocation.ts | 38 ++++++++- src/infra/openclaw-cli-shim.test.ts | 94 +++++++++++++++++++++++ src/infra/openclaw-cli-shim.ts | 88 +++++++++++++++++++++ src/tui/tui-exec-argv.ts | 34 -------- src/tui/tui-launch.ts | 8 +- src/tui/tui.ts | 3 +- 11 files changed, 254 insertions(+), 43 deletions(-) create mode 100644 src/infra/openclaw-cli-shim.test.ts create mode 100644 src/infra/openclaw-cli-shim.ts delete mode 100644 src/tui/tui-exec-argv.ts diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 83797a9ffcdd..0df14d585467 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -17,6 +17,7 @@ import type { GroupToolPolicyConfig } from "../config/types.tools.js"; import type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js"; import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing.js"; import { applyExecPolicyLayer } from "../infra/exec-policy.js"; +import { mergeGatewayAgentCliPath } from "../infra/openclaw-cli-shim.js"; import { logWarn } from "../logger.js"; import type { PluginHookChannelContext, @@ -574,7 +575,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) reviewer: options?.exec?.reviewer ?? execConfig.reviewer, trigger: options?.trigger, node: options?.exec?.node ?? execConfig.node, - pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend, + pathPrepend: mergeGatewayAgentCliPath(options?.exec?.pathPrepend ?? execConfig.pathPrepend), safeBins: options?.exec?.safeBins ?? execConfig.safeBins, strictInlineEval: options?.exec?.strictInlineEval ?? execConfig.strictInlineEval, commandHighlighting: options?.exec?.commandHighlighting ?? execConfig.commandHighlighting, diff --git a/src/agents/lazy-exec-tool.ts b/src/agents/lazy-exec-tool.ts index 9752b0eaaf31..3dbc1b941192 100644 --- a/src/agents/lazy-exec-tool.ts +++ b/src/agents/lazy-exec-tool.ts @@ -2,6 +2,7 @@ import { resolveExecCommandHighlighting } from "../config/exec-command-highlight import type { OpenClawConfig } from "../config/types.openclaw.js"; import { applyExecPolicyLayer } from "../infra/exec-policy.js"; import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js"; +import { mergeGatewayAgentCliPath } from "../infra/openclaw-cli-shim.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { resolveAgentConfig } from "./agent-scope.js"; import { describeExecTool } from "./bash-tools.descriptions.js"; @@ -74,7 +75,7 @@ export function resolveExecToolConfig(params: { cfg?: OpenClawConfig; agentId?: security: layeredPolicy.security, ask: layeredPolicy.ask, node: agentExec?.node ?? globalExec?.node, - pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend, + pathPrepend: mergeGatewayAgentCliPath(agentExec?.pathPrepend ?? globalExec?.pathPrepend), safeBins: agentExec?.safeBins ?? globalExec?.safeBins, strictInlineEval: agentExec?.strictInlineEval ?? globalExec?.strictInlineEval, commandHighlighting: resolveExecCommandHighlighting({ diff --git a/src/gateway/server-kernel.ts b/src/gateway/server-kernel.ts index 8c180b940de7..3e0d48bafeef 100644 --- a/src/gateway/server-kernel.ts +++ b/src/gateway/server-kernel.ts @@ -1,4 +1,5 @@ import { isNixMode } from "../config/paths.js"; +import { clearGatewayAgentCliShim } from "../infra/openclaw-cli-shim.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; @@ -162,6 +163,7 @@ export async function createGatewayKernel(port = 18789, opts: GatewayServerOptio if (lifecycleRuntime) { await lifecycleRuntime.closeOnStartupFailure(); } else { + clearGatewayAgentCliShim(); clearSecretsRuntimeSnapshotState(); clearPluginMetadataLifecycleCaches(); } diff --git a/src/gateway/server-startup-bootstrap.ts b/src/gateway/server-startup-bootstrap.ts index 29bcf941601f..4de6165d628c 100644 --- a/src/gateway/server-startup-bootstrap.ts +++ b/src/gateway/server-startup-bootstrap.ts @@ -30,6 +30,7 @@ import { setDiagnosticsEnabledForProcess, } from "../infra/diagnostic-events.js"; import { isVitestRuntimeEnv, logAcceptedEnvOption } from "../infra/env.js"; +import { prepareGatewayAgentCliShim } from "../infra/openclaw-cli-shim.js"; import { readGatewayRestartHandoffSync } from "../infra/restart-handoff.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; @@ -152,6 +153,9 @@ export async function prepareGatewayServerBootstrap(input: { ]); } const startupTrace = createGatewayStartupTrace(log); + if (!minimalTestGateway) { + await startupTrace.measure("runtime.agent-cli", () => prepareGatewayAgentCliShim()); + } const startupConfigModulePromise = import("./server-startup-config.js"); const loadStartupPluginsModule = createLazyPromise(() => import("./server-startup-plugins.js"), { cacheRejections: true, diff --git a/src/infra/openclaw-cli-invocation.test.ts b/src/infra/openclaw-cli-invocation.test.ts index d1099653b08f..c73431545e3d 100644 --- a/src/infra/openclaw-cli-invocation.test.ts +++ b/src/infra/openclaw-cli-invocation.test.ts @@ -4,7 +4,10 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { withTempDir } from "../test-utils/temp-dir.js"; -import { resolveCurrentOpenClawCliInvocation } from "./openclaw-cli-invocation.js"; +import { + filterOpenClawChildExecArgv, + resolveCurrentOpenClawCliInvocation, +} from "./openclaw-cli-invocation.js"; const requireFromHere = createRequire(import.meta.url); const repoRoot = process.cwd(); @@ -13,6 +16,21 @@ const trustedTsxLoader = requireFromHere.resolve("tsx", { paths: [repoRoot] }); const commandArgs = ["sessions", "export-trajectory"]; describe("resolveCurrentOpenClawCliInvocation", () => { + it("keeps child runtime flags without inheriting debugger ownership", () => { + expect( + filterOpenClawChildExecArgv([ + "--import", + "/loader.mjs", + "--inspect", + "127.0.0.1:9231", + "--inspect-brk=0", + "--inspect-port", + "9230", + "--trace-warnings", + ]), + ).toEqual(["--import", "/loader.mjs", "--trace-warnings"]); + }); + it("uses the source entry for a Node-hosted checkout harness", () => { expect( resolveCurrentOpenClawCliInvocation(commandArgs, { diff --git a/src/infra/openclaw-cli-invocation.ts b/src/infra/openclaw-cli-invocation.ts index f22c3bc37b2c..40bef86b0369 100644 --- a/src/infra/openclaw-cli-invocation.ts +++ b/src/infra/openclaw-cli-invocation.ts @@ -15,12 +15,46 @@ const OPENCLAW_PACKAGE_ENTRY_PATHS = new Set([ path.join("src", "entry.ts"), ]); -type OpenClawCliInvocation = Readonly<{ +export type OpenClawCliInvocation = Readonly<{ command: string; args: string[]; cwd: string; }>; +/** Keep child CLI launches on the parent's loader/runtime flags without inheriting its debugger. */ +export function filterOpenClawChildExecArgv(execArgv: readonly string[]): string[] { + const filtered: string[] = []; + for (let index = 0; index < execArgv.length; index += 1) { + const arg = execArgv[index] ?? ""; + if ( + arg === "--inspect" || + arg.startsWith("--inspect=") || + arg === "--inspect-brk" || + arg.startsWith("--inspect-brk=") || + arg === "--inspect-wait" || + arg.startsWith("--inspect-wait=") + ) { + const next = execArgv[index + 1]; + if (!arg.includes("=") && typeof next === "string" && !next.startsWith("-")) { + index += 1; + } + continue; + } + if (arg === "--inspect-port") { + const next = execArgv[index + 1]; + if (typeof next === "string" && !next.startsWith("-")) { + index += 1; + } + continue; + } + if (arg.startsWith("--inspect-port=")) { + continue; + } + filtered.push(arg); + } + return filtered; +} + function resolveTrustedTsxLoader(packageRoot: string): string | null { try { return requireFromHere.resolve("tsx", { paths: [packageRoot] }); @@ -53,7 +87,7 @@ export function resolveCurrentOpenClawCliInvocation( } = {}, ): OpenClawCliInvocation { const execPath = options.execPath ?? process.execPath; - const execArgv = options.execArgv ?? process.execArgv; + const execArgv = filterOpenClawChildExecArgv(options.execArgv ?? process.execArgv); const entry = (options.argv1 ?? process.argv[1])?.trim(); const cwd = options.cwd ?? tryProcessCwd(); const entryPackageRoot = entry ? resolveOpenClawPackageRootSync({ argv1: entry }) : null; diff --git a/src/infra/openclaw-cli-shim.test.ts b/src/infra/openclaw-cli-shim.test.ts new file mode 100644 index 000000000000..3db14c90c1c4 --- /dev/null +++ b/src/infra/openclaw-cli-shim.test.ts @@ -0,0 +1,94 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createExecTool } from "../agents/bash-tools.js"; +import { resolveExecToolConfig } from "../agents/lazy-exec-tool.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { captureEnv } from "../test-utils/env.js"; +import { withTempDir } from "../test-utils/temp-dir.js"; +import { clearGatewayAgentCliShim, prepareGatewayAgentCliShim } from "./openclaw-cli-shim.js"; + +const envSnapshot = captureEnv(["OPENCLAW_EXEC_SHELL_SNAPSHOT", "OPENCLAW_PROFILE", "PATH"]); + +afterEach(() => { + clearGatewayAgentCliShim(); + envSnapshot.restore(); +}); + +function readExecText(result: Awaited["execute"]>>) { + return result.content.find((entry) => entry.type === "text")?.text?.trim() ?? ""; +} + +describe.skipIf(process.platform === "win32")("Gateway agent CLI shim", () => { + it.each([ + { profile: "work", expectedArgs: ["--profile", "work", "probe"] }, + { profile: undefined, expectedArgs: ["probe"] }, + ])("pins the running CLI before configured PATH entries (profile=$profile)", async (testCase) => { + await withTempDir("openclaw-agent-cli-shim-", async (root) => { + const entryPath = path.join(root, "gateway-entry.mjs"); + const staleBinDir = path.join(root, "stale-bin"); + const staleCliPath = path.join(staleBinDir, "openclaw"); + const stateDir = path.join(root, "state"); + await fs.mkdir(staleBinDir, { recursive: true }); + await fs.writeFile( + entryPath, + 'console.log(JSON.stringify({ source: "gateway", args: process.argv.slice(2), pathHead: process.env.PATH?.split(":")[0] }));\n', + ); + await fs.writeFile(staleCliPath, "#!/bin/sh\nprintf '%s\\n' '{\"source\":\"stale\"}'\n", { + mode: 0o700, + }); + + const shim = await prepareGatewayAgentCliShim({ + env: testCase.profile ? { OPENCLAW_PROFILE: testCase.profile } : {}, + invocation: { command: process.execPath, args: [entryPath], cwd: root }, + stateDir, + }); + const config = { + tools: { exec: { pathPrepend: [staleBinDir] } }, + } satisfies OpenClawConfig; + const execConfig = resolveExecToolConfig({ cfg: config }); + expect(execConfig.pathPrepend?.slice(0, 2)).toEqual([shim.binDir, staleBinDir]); + + process.env.OPENCLAW_EXEC_SHELL_SNAPSHOT = "0"; + process.env.PATH = `${staleBinDir}${path.delimiter}${process.env.PATH ?? ""}`; + delete process.env.OPENCLAW_PROFILE; + const tool = createExecTool({ + ...execConfig, + host: "gateway", + security: "full", + ask: "off", + cwd: root, + notifyOnExit: false, + }); + const result = await tool.execute("gateway-cli-version-probe", { + command: "openclaw probe", + yieldMs: 120_000, + }); + expect(JSON.parse(readExecText(result))).toEqual({ + source: "gateway", + args: testCase.expectedArgs, + pathHead: shim.binDir, + }); + }); + }); +}); + +it("renders a Windows PATH launcher for the running CLI", async () => { + await withTempDir("openclaw-agent-cli-shim-win-", async (root) => { + const result = await prepareGatewayAgentCliShim({ + env: { OPENCLAW_PROFILE: "work" }, + invocation: { + command: "C:\\Program Files\\nodejs\\node.exe", + args: ["C:\\OpenClaw\\dist\\index.js"], + cwd: "C:\\OpenClaw", + }, + platform: "win32", + stateDir: root, + }); + + expect(path.basename(result.executablePath)).toBe("openclaw.cmd"); + expect(await fs.readFile(result.executablePath, "utf8")).toBe( + '@echo off\r\n"C:\\Program Files\\nodejs\\node.exe" C:\\OpenClaw\\dist\\index.js --profile work %*\r\n', + ); + }); +}); diff --git a/src/infra/openclaw-cli-shim.ts b/src/infra/openclaw-cli-shim.ts new file mode 100644 index 000000000000..33c5a4bf7aab --- /dev/null +++ b/src/infra/openclaw-cli-shim.ts @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { normalizeProfileName } from "../cli/profile-utils.js"; +import { resolveStateDir } from "../config/paths.js"; +import { quoteCmdScriptArg } from "../daemon/cmd-argv.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +import { writeTextAtomic } from "./json-files.js"; +import { + resolveCurrentOpenClawCliInvocation, + type OpenClawCliInvocation, +} from "./openclaw-cli-invocation.js"; + +const AGENT_CLI_BIN_DIR = path.join("tmp", "agent-cli"); +const GATEWAY_AGENT_CLI_STATE_KEY = Symbol.for("openclaw.gatewayAgentCliShim"); +const gatewayAgentCliState = resolveGlobalSingleton( + GATEWAY_AGENT_CLI_STATE_KEY, + () => ({ binDir: undefined as string | undefined }), + (state) => { + state.binDir = undefined; + }, +); + +function quotePosixArgument(value: string): string { + return /^[A-Za-z0-9_@%+=:,./-]+$/u.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; +} + +function renderPosixShim(invocation: OpenClawCliInvocation, profile: string | null): string { + const args = [...invocation.args, ...(profile ? ["--profile", profile] : [])]; + return `#!/bin/sh +set -eu +exec ${[invocation.command, ...args].map(quotePosixArgument).join(" ")} "$@" +`; +} + +function renderWindowsShim(invocation: OpenClawCliInvocation, profile: string | null): string { + const args = [...invocation.args, ...(profile ? ["--profile", profile] : [])]; + return `@echo off\r\n${[invocation.command, ...args].map(quoteCmdScriptArg).join(" ")} %*\r\n`; +} + +/** + * Materialize the exact running Gateway CLI as an agent-visible PATH command. + * The generated launcher is a runtime tool contract, not persisted product state. + */ +export async function prepareGatewayAgentCliShim( + options: { + env?: NodeJS.ProcessEnv; + invocation?: OpenClawCliInvocation; + platform?: NodeJS.Platform; + stateDir?: string; + } = {}, +): Promise<{ binDir: string; executablePath: string }> { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const invocation = options.invocation ?? resolveCurrentOpenClawCliInvocation([]); + const profile = normalizeProfileName(env.OPENCLAW_PROFILE); + const binDir = path.join(options.stateDir ?? resolveStateDir(env), AGENT_CLI_BIN_DIR); + const executablePath = path.join(binDir, platform === "win32" ? "openclaw.cmd" : "openclaw"); + const content = + platform === "win32" + ? renderWindowsShim(invocation, profile) + : renderPosixShim(invocation, profile); + + await fs.mkdir(binDir, { recursive: true, mode: 0o700 }); + await fs.chmod(binDir, 0o700).catch(() => undefined); + await writeTextAtomic(executablePath, content, { + mode: 0o700, + dirMode: 0o700, + durable: false, + tempPrefix: "openclaw-agent-cli", + }); + gatewayAgentCliState.binDir = binDir; + return { binDir, executablePath }; +} + +/** Clear a prepared launcher after startup failure; normal Gateway close resets it globally. */ +export function clearGatewayAgentCliShim(): void { + gatewayAgentCliState.binDir = undefined; +} + +/** Prepend the prepared Gateway CLI ahead of operator-configured exec PATH entries. */ +export function mergeGatewayAgentCliPath(configured?: string[]): string[] | undefined { + const merged = normalizeUniqueStringEntries([ + ...(gatewayAgentCliState.binDir ? [gatewayAgentCliState.binDir] : []), + ...(configured ?? []), + ]); + return merged.length > 0 ? merged : undefined; +} diff --git a/src/tui/tui-exec-argv.ts b/src/tui/tui-exec-argv.ts deleted file mode 100644 index de34d0e835b1..000000000000 --- a/src/tui/tui-exec-argv.ts +++ /dev/null @@ -1,34 +0,0 @@ -export function filterTuiExecArgv(execArgv: readonly string[]): string[] { - const filtered: string[] = []; - for (let index = 0; index < execArgv.length; index += 1) { - const arg = execArgv[index] ?? ""; - // Strip inspector flags so TUI-owned children cannot contend with or pause beneath - // the parent debugger. - if ( - arg === "--inspect" || - arg.startsWith("--inspect=") || - arg === "--inspect-brk" || - arg.startsWith("--inspect-brk=") || - arg === "--inspect-wait" || - arg.startsWith("--inspect-wait=") - ) { - const next = execArgv[index + 1]; - if (!arg.includes("=") && typeof next === "string" && !next.startsWith("-")) { - index += 1; - } - continue; - } - if (arg === "--inspect-port") { - const next = execArgv[index + 1]; - if (typeof next === "string" && !next.startsWith("-")) { - index += 1; - } - continue; - } - if (arg.startsWith("--inspect-port=")) { - continue; - } - filtered.push(arg); - } - return filtered; -} diff --git a/src/tui/tui-launch.ts b/src/tui/tui-launch.ts index c4398c88d1ad..1e59f72177aa 100644 --- a/src/tui/tui-launch.ts +++ b/src/tui/tui-launch.ts @@ -2,8 +2,8 @@ import { spawn } from "node:child_process"; import path from "node:path"; import { formatErrorMessage } from "../infra/errors.js"; +import { filterOpenClawChildExecArgv } from "../infra/openclaw-cli-invocation.js"; import { attachChildProcessBridge } from "../process/child-process-bridge.js"; -import { filterTuiExecArgv } from "./tui-exec-argv.js"; import type { TuiOptions } from "./tui.js"; function appendOption(args: string[], flag: string, value: string | number | undefined): void { @@ -22,7 +22,11 @@ function buildCurrentCliEntryArgs(): string[] { } function buildTuiCliArgs(opts: TuiOptions): string[] { - const args = [...filterTuiExecArgv(process.execArgv), ...buildCurrentCliEntryArgs(), "tui"]; + const args = [ + ...filterOpenClawChildExecArgv(process.execArgv), + ...buildCurrentCliEntryArgs(), + "tui", + ]; if (opts.local) { args.push("--local"); } diff --git a/src/tui/tui.ts b/src/tui/tui.ts index c143db76dce8..f3d10382a025 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -46,7 +46,6 @@ import { sanitizeAutocompleteProvider } from "./tui-autocomplete.js"; import type { TuiBackend } from "./tui-backend.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; -import { filterTuiExecArgv } from "./tui-exec-argv.js"; import { formatTuiErrorMessage, formatTuiFooter, @@ -170,7 +169,7 @@ export function resolveTuiLocalAuthCliInvocation(params: { return resolveCurrentOpenClawCliInvocation( ["models", "auth", "login", ...(provider ? ["--provider", provider] : [])], { - execArgv: filterTuiExecArgv(params.execArgv ?? process.execArgv), + execArgv: params.execArgv ?? process.execArgv, }, ); }