From 3d952aa35d680d108f513546ab80a40aab3afca4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 09:46:46 +0100 Subject: [PATCH] fix(agents): preserve claude cli backend defaults --- extensions/anthropic/cli-backend-api.ts | 1 + src/agents/cli-backends.test.ts | 6 ++ src/agents/cli-backends.ts | 26 +++++- src/agents/cli-runner.spawn.test.ts | 108 ++++++++++++++++++++++++ src/agents/cli-runner.test-support.ts | 7 ++ src/agents/cli-runner/execute.ts | 11 ++- 6 files changed, 155 insertions(+), 4 deletions(-) diff --git a/extensions/anthropic/cli-backend-api.ts b/extensions/anthropic/cli-backend-api.ts index 85587f32a49..ce54b5480f4 100644 --- a/extensions/anthropic/cli-backend-api.ts +++ b/extensions/anthropic/cli-backend-api.ts @@ -1,3 +1,4 @@ +export { buildAnthropicCliBackend } from "./cli-backend.js"; export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider, diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 2bfa7260305..69f9540030b 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -352,6 +352,12 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { "--permission-mode", "bypassPermissions", ]); + expect(resolved?.config.systemPromptArg).toBe("--append-system-prompt"); + expect(resolved?.config.systemPromptWhen).toBe("first"); + expect(resolved?.config.sessionArg).toBe("--session-id"); + expect(resolved?.config.sessionMode).toBe("always"); + expect(resolved?.config.input).toBe("stdin"); + expect(resolved?.config.output).toBe("jsonl"); }); }); diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index dd2ce70447c..e543cd20292 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -1,5 +1,6 @@ import { CLAUDE_CLI_BACKEND_ID, + buildAnthropicCliBackend, normalizeClaudeBackendConfig, } from "../../extensions/anthropic/cli-backend-api.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -18,6 +19,7 @@ export { normalizeClaudeBackendConfig }; type FallbackCliBackendPolicy = { bundleMcp: boolean; + baseConfig?: CliBackendConfig; normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig; }; @@ -27,6 +29,7 @@ const FALLBACK_CLI_BACKEND_POLICIES: Record = // plugin registry is not initialized yet (for example direct runner tests // or narrow non-gateway entrypoints). bundleMcp: true, + baseConfig: buildAnthropicCliBackend().config, normalizeConfig: normalizeClaudeBackendConfig, }, }; @@ -134,11 +137,28 @@ export function resolveCliBackendConfig( } if (!override) { - return null; + if (!fallbackPolicy?.baseConfig) { + return null; + } + const baseConfig = fallbackPolicy.normalizeConfig + ? fallbackPolicy.normalizeConfig(fallbackPolicy.baseConfig) + : fallbackPolicy.baseConfig; + const command = baseConfig.command?.trim(); + if (!command) { + return null; + } + return { + id: normalized, + config: { ...baseConfig, command }, + bundleMcp: fallbackPolicy.bundleMcp, + }; } - const config = fallbackPolicy?.normalizeConfig - ? fallbackPolicy.normalizeConfig(override) + const mergedFallback = fallbackPolicy?.baseConfig + ? mergeBackendConfig(fallbackPolicy.baseConfig, override) : override; + const config = fallbackPolicy?.normalizeConfig + ? fallbackPolicy.normalizeConfig(mergedFallback) + : mergedFallback; const command = config.command?.trim(); if (!command) { return null; diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index c2c753b60fa..786cbed1bf0 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -1,21 +1,29 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { + makeBootstrapWarn as realMakeBootstrapWarn, + resolveBootstrapContextForRun as realResolveBootstrapContextForRun, +} from "./bootstrap-files.js"; import { createManagedRun, mockSuccessfulCliRun, + restoreCliRunnerPrepareTestDeps, runCliAgentWithBackendConfig, setupCliRunnerTestModule, SMALL_PNG_BASE64, stubBootstrapContext, supervisorSpawnMock, } from "./cli-runner.test-support.js"; +import { setCliRunnerPrepareTestDeps } from "./cli-runner/prepare.js"; beforeEach(() => { resetAgentEventsForTest(); + restoreCliRunnerPrepareTestDeps(); }); describe("runCliAgent spawn path", () => { @@ -308,6 +316,28 @@ describe("runCliAgent spawn path", () => { expect(input.env?.SAFE_CLEAR).toBeUndefined(); }); + it("keeps explicit backend env overrides even when clearEnv drops inherited values", async () => { + const runCliAgent = await setupCliRunnerTestModule(); + process.env.SAFE_OVERRIDE = "from-base"; + mockSuccessfulCliRun(); + await runCliAgentWithBackendConfig({ + runCliAgent, + backend: { + command: "codex", + env: { + SAFE_OVERRIDE: "from-override", + }, + clearEnv: ["SAFE_OVERRIDE"], + }, + runId: "run-clear-env-override", + }); + + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { + env?: Record; + }; + expect(input.env?.SAFE_OVERRIDE).toBe("from-override"); + }); + it("prepends bootstrap warnings to the CLI prompt body", async () => { const runCliAgent = await setupCliRunnerTestModule(); supervisorSpawnMock.mockResolvedValueOnce( @@ -365,6 +395,84 @@ describe("runCliAgent spawn path", () => { expect(promptCarrier).toContain("hi"); }); + it("loads workspace bootstrap files into the Claude CLI system prompt", async () => { + const runCliAgent = await setupCliRunnerTestModule(); + const workspaceDir = await fs.mkdtemp( + path.join(os.tmpdir(), "openclaw-cli-bootstrap-context-"), + ); + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 50, + stdout: "ok", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }), + ); + + await fs.writeFile( + path.join(workspaceDir, "AGENTS.md"), + [ + "# AGENTS.md", + "", + "Read SOUL.md and IDENTITY.md before replying.", + "Use the injected workspace bootstrap files as standing instructions.", + ].join("\n"), + "utf-8", + ); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "SOUL-SECRET\n", "utf-8"); + await fs.writeFile(path.join(workspaceDir, "IDENTITY.md"), "IDENTITY-SECRET\n", "utf-8"); + await fs.writeFile(path.join(workspaceDir, "USER.md"), "USER-SECRET\n", "utf-8"); + + setCliRunnerPrepareTestDeps({ + makeBootstrapWarn: realMakeBootstrapWarn, + resolveBootstrapContextForRun: realResolveBootstrapContextForRun, + }); + + try { + await runCliAgent({ + sessionId: "s1", + sessionFile: "/tmp/session.jsonl", + workspaceDir, + prompt: "BOOTSTRAP_CAPTURE_CHECK", + provider: "claude-cli", + model: "sonnet", + timeoutMs: 1_000, + runId: "run-bootstrap-context", + }); + + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { + argv?: string[]; + input?: string; + }; + const allArgs = (input.argv ?? []).join("\n"); + const agentsPath = path.join(workspaceDir, "AGENTS.md"); + const soulPath = path.join(workspaceDir, "SOUL.md"); + const identityPath = path.join(workspaceDir, "IDENTITY.md"); + const userPath = path.join(workspaceDir, "USER.md"); + expect(input.input).toContain("BOOTSTRAP_CAPTURE_CHECK"); + expect(allArgs).toContain("--append-system-prompt"); + expect(allArgs).toContain("# Project Context"); + expect(allArgs).toContain(`## ${agentsPath}`); + expect(allArgs).toContain("Read SOUL.md and IDENTITY.md before replying."); + expect(allArgs).toContain(`## ${soulPath}`); + expect(allArgs).toContain("SOUL-SECRET"); + expect(allArgs).toContain( + "If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.", + ); + expect(allArgs).toContain(`## ${identityPath}`); + expect(allArgs).toContain("IDENTITY-SECRET"); + expect(allArgs).toContain(`## ${userPath}`); + expect(allArgs).toContain("USER-SECRET"); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + restoreCliRunnerPrepareTestDeps(); + } + }); + it("hydrates prompt media refs into CLI image args", async () => { const runCliAgent = await setupCliRunnerTestModule(); supervisorSpawnMock.mockResolvedValueOnce( diff --git a/src/agents/cli-runner.test-support.ts b/src/agents/cli-runner.test-support.ts index f017bf0187f..649e5ce0c62 100644 --- a/src/agents/cli-runner.test-support.ts +++ b/src/agents/cli-runner.test-support.ts @@ -191,6 +191,13 @@ export function stubBootstrapContext(params: { hoisted.resolveBootstrapContextForRunMock.mockResolvedValueOnce(params); } +export function restoreCliRunnerPrepareTestDeps() { + setCliRunnerPrepareTestDeps({ + makeBootstrapWarn: () => () => {}, + resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, + }); +} + export async function runCliAgentWithBackendConfig(params: { runCliAgent: typeof import("./cli-runner.js").runCliAgent; backend: TestCliBackendConfig; diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index b23df5f711a..5dfaba5b0fc 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -171,12 +171,21 @@ export async function executePreparedCliRun( const env = (() => { const next = sanitizeHostExecEnv({ baseEnv: process.env, - overrides: backend.env, blockPathOverrides: true, }); for (const key of backend.clearEnv ?? []) { delete next[key]; } + if (backend.env && Object.keys(backend.env).length > 0) { + Object.assign( + next, + sanitizeHostExecEnv({ + baseEnv: {}, + overrides: backend.env, + blockPathOverrides: true, + }), + ); + } Object.assign(next, context.preparedBackend.env); return next; })();