import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { __testing as replyRunTesting, createReplyOperation, replyRunRegistry, } from "../auto-reply/reply/reply-run-registry.js"; import { onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js"; import type { getProcessSupervisor } from "../process/supervisor/index.js"; import { makeBootstrapWarn as realMakeBootstrapWarn, resolveBootstrapContextForRun as realResolveBootstrapContextForRun, } from "./bootstrap-files.js"; import { buildRunClaudeCliAgentParams } from "./cli-runner.js"; import { createManagedRun, mockSuccessfulCliRun, restoreCliRunnerPrepareTestDeps, supervisorSpawnMock, } from "./cli-runner.test-support.js"; import { buildClaudeLiveArgs, resetClaudeLiveSessionsForTest, runClaudeLiveSessionTurn, } from "./cli-runner/claude-live-session.js"; import { buildCliEnvAuthLog, executePreparedCliRun } from "./cli-runner/execute.js"; import { buildSystemPrompt } from "./cli-runner/helpers.js"; import { setCliRunnerPrepareTestDeps } from "./cli-runner/prepare.js"; import type { PreparedCliRunContext } from "./cli-runner/types.js"; import { createClaudeApiErrorFixture } from "./test-helpers/claude-api-error-fixture.js"; type ProcessSupervisor = ReturnType; type SupervisorSpawnFn = ProcessSupervisor["spawn"]; beforeEach(() => { resetAgentEventsForTest(); resetClaudeLiveSessionsForTest(); replyRunTesting.resetReplyRunRegistry(); restoreCliRunnerPrepareTestDeps(); supervisorSpawnMock.mockClear(); }); afterEach(() => { resetClaudeLiveSessionsForTest(); replyRunTesting.resetReplyRunRegistry(); }); function buildPreparedCliRunContext(params: { provider: "claude-cli" | "codex-cli"; model: string; runId: string; prompt?: string; sessionId?: string; sessionKey?: string; backend?: Partial; config?: PreparedCliRunContext["params"]["config"]; mcpConfigHash?: string; skillsSnapshot?: PreparedCliRunContext["params"]["skillsSnapshot"]; workspaceDir?: string; }): PreparedCliRunContext { const workspaceDir = params.workspaceDir ?? "/tmp"; const baseBackend = params.provider === "claude-cli" ? { command: "claude", args: ["-p", "--output-format", "stream-json"], output: "jsonl" as const, input: "stdin" as const, modelArg: "--model", sessionArg: "--session-id", sessionMode: "always" as const, systemPromptFileArg: "--append-system-prompt-file", systemPromptWhen: "first" as const, serialize: true, } : { command: "codex", args: ["exec", "--json"], resumeArgs: ["exec", "resume", "{sessionId}", "--skip-git-repo-check"], output: "text" as const, input: "arg" as const, modelArg: "--model", sessionMode: "existing" as const, systemPromptFileConfigArg: "-c", systemPromptFileConfigKey: "model_instructions_file", systemPromptWhen: "first" as const, serialize: true, }; const backend = { ...baseBackend, ...params.backend }; return { params: { sessionId: params.sessionId ?? "s1", sessionKey: params.sessionKey, sessionFile: "/tmp/session.jsonl", workspaceDir, config: params.config, prompt: params.prompt ?? "hi", provider: params.provider, model: params.model, timeoutMs: 1_000, runId: params.runId, skillsSnapshot: params.skillsSnapshot, }, started: Date.now(), workspaceDir, backendResolved: { id: params.provider, config: backend, bundleMcp: params.provider === "claude-cli", pluginId: params.provider === "claude-cli" ? "anthropic" : "openai", }, preparedBackend: { backend, env: {}, ...(params.mcpConfigHash ? { mcpConfigHash: params.mcpConfigHash } : {}), }, reusableCliSession: {}, modelId: params.model, normalizedModel: params.model, systemPrompt: "You are a helpful assistant.", systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], bootstrapPromptWarningLines: [], authEpochVersion: 2, }; } describe("runCliAgent spawn path", () => { it("does not inject hardcoded 'Tools are disabled' text into CLI arguments", async () => { supervisorSpawnMock.mockResolvedValueOnce( createManagedRun({ reason: "exit", exitCode: 0, exitSignal: null, durationMs: 50, stdout: "ok", stderr: "", timedOut: false, noOutputTimedOut: false, }), ); const backendConfig = { command: "claude", args: ["-p", "--output-format", "stream-json"], output: "jsonl" as const, input: "stdin" as const, modelArg: "--model", sessionArg: "--session-id", systemPromptArg: "--append-system-prompt", systemPromptWhen: "first" as const, serialize: true, }; const context: PreparedCliRunContext = { params: { sessionId: "s1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", prompt: "Run: node script.mjs", provider: "claude-cli", model: "sonnet", timeoutMs: 1_000, runId: "run-no-tools-disabled", extraSystemPrompt: "You are a helpful assistant.", }, started: Date.now(), workspaceDir: "/tmp", backendResolved: { id: "claude-cli", config: backendConfig, bundleMcp: true, pluginId: "anthropic", }, preparedBackend: { backend: backendConfig, env: {}, }, reusableCliSession: {}, modelId: "sonnet", normalizedModel: "sonnet", systemPrompt: "You are a helpful assistant.", systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], bootstrapPromptWarningLines: [], authEpochVersion: 2, }; await executePreparedCliRun(context); const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[] }; const allArgs = (input.argv ?? []).join("\n"); expect(allArgs).not.toContain("Tools are disabled in this session"); expect(allArgs).toContain("You are a helpful assistant."); }); it("includes the OpenClaw skills prompt in CLI system prompts", () => { const systemPrompt = buildSystemPrompt({ workspaceDir: "/tmp", modelDisplay: "claude-cli/sonnet", tools: [], skillsPrompt: [ "", " ", " weather", " Use weather tools.", " /tmp/skills/weather/SKILL.md", " ", "", ].join("\n"), }); expect(systemPrompt).toContain("## Skills (mandatory)"); expect(systemPrompt).toContain("weather"); expect(systemPrompt).toContain("/tmp/skills/weather/SKILL.md"); }); it("pipes Claude prompts over stdin instead of argv", async () => { supervisorSpawnMock.mockResolvedValueOnce( createManagedRun({ reason: "exit", exitCode: 0, exitSignal: null, durationMs: 50, stdout: "ok", stderr: "", timedOut: false, noOutputTimedOut: false, }), ); await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-stdin-claude", prompt: "Explain this diff", }), ); const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[]; input?: string; }; expect(input.input).toContain("Explain this diff"); expect(input.argv).not.toContain("Explain this diff"); }); it("passes Claude system prompts through a file instead of argv", async () => { let systemPromptPath = ""; supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { argv?: string[] }; const systemPromptArgIndex = input.argv?.indexOf("--append-system-prompt-file") ?? -1; expect(systemPromptArgIndex).toBeGreaterThanOrEqual(0); systemPromptPath = input.argv?.[systemPromptArgIndex + 1] ?? ""; expect(systemPromptPath).toContain("openclaw-cli-system-prompt-"); await expect(fs.readFile(systemPromptPath, "utf-8")).resolves.toBe( "You are a helpful assistant.", ); expect(input.argv).not.toContain("You are a helpful assistant."); return createManagedRun({ reason: "exit", exitCode: 0, exitSignal: null, durationMs: 50, stdout: "ok", stderr: "", timedOut: false, noOutputTimedOut: false, }); }); await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-claude-system-prompt-file", }), ); await expect(fs.access(systemPromptPath)).rejects.toMatchObject({ code: "ENOENT" }); }); it("passes --session-id for new Claude sessions", async () => { mockSuccessfulCliRun(); await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-claude-session-id", }), ); const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[]; input?: string; mode?: string; }; expect(input.mode).toBe("child"); expect(input.argv).toContain("claude"); const sessionArgIndex = input.argv?.indexOf("--session-id") ?? -1; expect(sessionArgIndex).toBeGreaterThanOrEqual(0); expect(input.argv?.[sessionArgIndex + 1]?.trim()).toBeTruthy(); expect(input.input).toContain("hi"); expect(input.argv).not.toContain("hi"); }); it("passes OpenClaw skills to Claude as a session plugin", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-skills-")); const skillDir = path.join(workspaceDir, "skills", "weather"); await fs.mkdir(skillDir, { recursive: true }); await fs.writeFile( path.join(skillDir, "SKILL.md"), [ "---", "name: weather", "description: Use weather tools for forecasts.", "---", "", "Read forecast data before replying.", ].join("\n"), "utf-8", ); let pluginDir = ""; supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { argv?: string[] }; const pluginArgIndex = input.argv?.indexOf("--plugin-dir") ?? -1; expect(pluginArgIndex).toBeGreaterThanOrEqual(0); pluginDir = input.argv?.[pluginArgIndex + 1] ?? ""; const manifest = JSON.parse( await fs.readFile(path.join(pluginDir, ".claude-plugin", "plugin.json"), "utf-8"), ) as { name?: string; skills?: string }; expect(manifest).toMatchObject({ name: "openclaw-skills", skills: "./skills", }); await expect( fs.readFile(path.join(pluginDir, "skills", "weather", "SKILL.md"), "utf-8"), ).resolves.toContain("Read forecast data before replying."); return createManagedRun({ reason: "exit", exitCode: 0, exitSignal: null, durationMs: 50, stdout: "ok", stderr: "", timedOut: false, noOutputTimedOut: false, }); }); try { await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-claude-skills-plugin", workspaceDir, skillsSnapshot: { prompt: "", skills: [{ name: "weather" }], resolvedSkills: [ { name: "weather", description: "Use weather tools for forecasts.", filePath: path.join(skillDir, "SKILL.md"), baseDir: skillDir, source: "test", sourceInfo: { path: skillDir, source: "test", scope: "project", origin: "top-level", baseDir: skillDir, }, disableModelInvocation: false, }, ], }, }), ); await expect(fs.access(pluginDir)).rejects.toThrow(); } finally { await fs.rm(workspaceDir, { recursive: true, force: true }); } }); it("injects skill env overrides into CLI child env and restores host env", async () => { const previousEnvValue = process.env.CLI_SKILL_API_KEY; delete process.env.CLI_SKILL_API_KEY; supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { env?: Record }; expect(input.env?.CLI_SKILL_API_KEY).toBe("skill-secret"); return createManagedRun({ reason: "exit", exitCode: 0, exitSignal: null, durationMs: 50, stdout: "ok", stderr: "", timedOut: false, noOutputTimedOut: false, }); }); try { await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-claude-skill-env", config: { skills: { entries: { envskill: { apiKey: "skill-secret" }, // pragma: allowlist secret }, }, }, skillsSnapshot: { prompt: "", skills: [{ name: "envskill", primaryEnv: "CLI_SKILL_API_KEY" }], }, }), ); expect(process.env.CLI_SKILL_API_KEY).toBeUndefined(); } finally { if (previousEnvValue === undefined) { delete process.env.CLI_SKILL_API_KEY; } else { process.env.CLI_SKILL_API_KEY = previousEnvValue; } } }); it("ignores legacy claudeSessionId on the compat wrapper", () => { const params = buildRunClaudeCliAgentParams({ sessionId: "openclaw-session", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", prompt: "hi", model: "opus", timeoutMs: 1_000, runId: "run-claude-legacy-wrapper", claudeSessionId: "c9d7b831-1c31-4d22-80b9-1e50ca207d4b", }); expect(params.provider).toBe("claude-cli"); expect(params.prompt).toBe("hi"); expect(params).not.toHaveProperty("cliSessionId"); expect(JSON.stringify(params)).not.toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b"); }); it("forwards senderIsOwner through the compat wrapper", () => { const params = buildRunClaudeCliAgentParams({ sessionId: "openclaw-session", sessionKey: "agent:main:matrix:room:123", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", prompt: "hi", model: "opus", timeoutMs: 1_000, runId: "run-claude-owner-wrapper", senderIsOwner: false, }); expect(params.senderIsOwner).toBe(false); }); it("forwards channel context through the compat wrapper", () => { const params = buildRunClaudeCliAgentParams({ sessionId: "openclaw-session", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", prompt: "hi", timeoutMs: 1_000, runId: "run-claude-channel-wrapper", messageChannel: "telegram", messageProvider: "acp", }); expect(params.messageChannel).toBe("telegram"); expect(params.messageProvider).toBe("acp"); }); it("forwards static extra system prompt through the compat wrapper", () => { const params = buildRunClaudeCliAgentParams({ sessionId: "openclaw-session", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", prompt: "hi", timeoutMs: 1_000, runId: "run-claude-static-prompt-wrapper", extraSystemPrompt: "dynamic\n\nstatic", extraSystemPromptStatic: "static", }); expect(params.extraSystemPrompt).toBe("dynamic\n\nstatic"); expect(params.extraSystemPromptStatic).toBe("static"); }); it("forwards cron jobId through the compat wrapper", () => { const params = buildRunClaudeCliAgentParams({ sessionId: "openclaw-session", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", prompt: "hi", timeoutMs: 1_000, runId: "run-claude-jobid-wrapper", trigger: "cron", jobId: "cron-job-123", }); expect(params.trigger).toBe("cron"); expect(params.jobId).toBe("cron-job-123"); }); it("runs CLI through supervisor and returns payload", async () => { supervisorSpawnMock.mockResolvedValueOnce( createManagedRun({ reason: "exit", exitCode: 0, exitSignal: null, durationMs: 50, stdout: "ok", stderr: "", timedOut: false, noOutputTimedOut: false, }), ); const context = buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", runId: "run-1", }); context.reusableCliSession = { sessionId: "thread-123" }; const result = await executePreparedCliRun(context, "thread-123"); expect(result.text).toBe("ok"); const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[]; mode?: string; timeoutMs?: number; noOutputTimeoutMs?: number; replaceExistingScope?: boolean; scopeKey?: string; }; expect(input.mode).toBe("child"); expect(input.argv).toEqual([ "codex", "exec", "resume", "thread-123", "--skip-git-repo-check", "--model", "gpt-5.4", "hi", ]); expect(input.timeoutMs).toBe(1_000); expect(input.noOutputTimeoutMs).toBeGreaterThanOrEqual(1_000); expect(input.replaceExistingScope).toBe(true); expect(input.scopeKey).toContain("thread-123"); }); it("passes Codex system prompts through model_instructions_file", async () => { let promptFileText = ""; supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { argv?: string[] }; const configArgIndex = input.argv?.indexOf("-c") ?? -1; expect(configArgIndex).toBeGreaterThanOrEqual(0); const configArg = input.argv?.[configArgIndex + 1] ?? ""; const match = /^model_instructions_file="(.+)"$/.exec(configArg); expect(match?.[1]).toBeTruthy(); promptFileText = await fs.readFile(match?.[1] ?? "", "utf-8"); return createManagedRun({ reason: "exit", exitCode: 0, exitSignal: null, durationMs: 50, stdout: "ok", stderr: "", timedOut: false, noOutputTimedOut: false, }); }); await executePreparedCliRun( buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", runId: "run-codex-system-prompt-file", }), ); expect(promptFileText).toBe("You are a helpful assistant."); }); it("cancels the managed CLI run when the abort signal fires", async () => { const abortController = new AbortController(); let resolveWait!: (value: { reason: | "manual-cancel" | "overall-timeout" | "no-output-timeout" | "spawn-error" | "signal" | "exit"; exitCode: number | null; exitSignal: NodeJS.Signals | number | null; durationMs: number; stdout: string; stderr: string; timedOut: boolean; noOutputTimedOut: boolean; }) => void; const cancel = vi.fn((reason?: string) => { resolveWait({ reason: reason === "manual-cancel" ? "manual-cancel" : "signal", exitCode: null, exitSignal: null, durationMs: 50, stdout: "", stderr: "", timedOut: false, noOutputTimedOut: false, }); }); supervisorSpawnMock.mockResolvedValueOnce({ runId: "run-supervisor", pid: 1234, startedAtMs: Date.now(), stdin: undefined, wait: vi.fn( async () => await new Promise((resolve) => { resolveWait = resolve; }), ), cancel, }); const context = buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", runId: "run-abort", }); context.params.abortSignal = abortController.signal; const runPromise = executePreparedCliRun(context); await vi.waitFor(() => { expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); }); abortController.abort(); await expect(runPromise).rejects.toMatchObject({ name: "AbortError" }); expect(cancel).toHaveBeenCalledWith("manual-cancel"); }); it("streams Claude text deltas from stream-json stdout", async () => { const agentEvents: Array<{ stream: string; text?: string; delta?: string }> = []; const stop = onAgentEvent((evt) => { agentEvents.push({ stream: evt.stream, text: typeof evt.data.text === "string" ? evt.data.text : undefined, delta: typeof evt.data.delta === "string" ? evt.data.delta : undefined, }); }); supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; input.onStdout?.( [ JSON.stringify({ type: "init", session_id: "session-123" }), JSON.stringify({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } }, }), ].join("\n") + "\n", ); input.onStdout?.( JSON.stringify({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: " world" } }, }) + "\n", ); return createManagedRun({ reason: "exit", exitCode: 0, exitSignal: null, durationMs: 50, stdout: [ JSON.stringify({ type: "init", session_id: "session-123" }), JSON.stringify({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } }, }), JSON.stringify({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: " world" } }, }), JSON.stringify({ type: "result", session_id: "session-123", result: "Hello world", }), ].join("\n"), stderr: "", timedOut: false, noOutputTimedOut: false, }); }); try { const result = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-claude-stream-json", }), ); expect(result.text).toBe("Hello world"); expect(agentEvents).toEqual([ { stream: "assistant", text: "Hello", delta: "Hello" }, { stream: "assistant", text: "Hello world", delta: " world" }, ]); } finally { stop(); } }); it("reuses a Claude live session process across turns", async () => { const agentEvents: unknown[] = []; const stop = onAgentEvent((evt) => { if (evt.stream === "assistant") { agentEvents.push(evt.data); } }); const writes: string[] = []; let stdoutListener: ((chunk: string) => void) | undefined; const stdin = { write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { writes.push(data); const prompt = (JSON.parse(data) as { message: { content: string } }).message.content; const text = prompt === "first" ? "one" : "two"; stdoutListener?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: "live-session-1" }), JSON.stringify({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text }, }, }), JSON.stringify({ type: "result", session_id: "live-session-1", result: text, }), ].join("\n") + "\n", ); cb?.(); }), end: vi.fn(), }; supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; return { runId: "live-run", pid: 2345, startedAtMs: Date.now(), stdin, wait: vi.fn(() => new Promise(() => {})), cancel: vi.fn(), }; }); try { const first = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-1", prompt: "first", backend: { args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-one.json"], liveSession: "claude-stdio", }, mcpConfigHash: "same-mcp-config", }), ); const second = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-2", prompt: "second", backend: { args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-two.json"], liveSession: "claude-stdio", }, mcpConfigHash: "same-mcp-config", }), ); const spawnInput = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[]; stdinMode?: string; }; expect(first.text).toBe("one"); expect(second.text).toBe("two"); expect(supervisorSpawnMock).toHaveBeenCalledOnce(); expect(spawnInput.stdinMode).toBe("pipe-open"); expect(spawnInput.argv).toContain("--input-format"); expect(spawnInput.argv).toContain("--output-format"); expect(spawnInput.argv).toContain("stream-json"); expect(spawnInput.argv).toContain("--replay-user-messages"); expect(spawnInput.argv).not.toContain("--session-id"); expect(spawnInput.argv).toContain("/tmp/mcp-one.json"); expect( writes.map( (entry) => (JSON.parse(entry) as { message: { content: string } }).message.content, ), ).toEqual(["first", "second"]); expect(agentEvents).toEqual([ { text: "one", delta: "one" }, { text: "two", delta: "two" }, ]); } finally { stop(); } }); it("accepts Claude live stream-json lines larger than 256 KiB", async () => { const largeText = "x".repeat(270 * 1024); let stdoutListener: ((chunk: string) => void) | undefined; const stdin = { write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { stdoutListener?.( JSON.stringify({ type: "result", session_id: "live-session-large", result: largeText, }) + "\n", ); cb?.(); }), end: vi.fn(), }; supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; return { runId: "live-run-large", pid: 2345, startedAtMs: Date.now(), stdin, wait: vi.fn(() => new Promise(() => {})), cancel: vi.fn(), }; }); const result = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-large-line", backend: { liveSession: "claude-stdio", }, }), ); expect(result.text).toHaveLength(largeText.length); expect(result.text).toBe(largeText); }); it("reports Claude live session reply backends as streaming until the turn finishes", async () => { let stdoutListener: ((chunk: string) => void) | undefined; let markWriteReady: (() => void) | undefined; const writeReady = new Promise((resolve) => { markWriteReady = resolve; }); const stdin = { write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { markWriteReady?.(); cb?.(); }), end: vi.fn(), }; supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; return { runId: "live-run", pid: 2345, startedAtMs: Date.now(), stdin, wait: vi.fn(() => new Promise(() => {})), cancel: vi.fn(), }; }); const operation = createReplyOperation({ sessionKey: "agent:main:main", sessionId: "live-session-reply", resetTriggered: false, }); operation.setPhase("running"); const context = buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-reply-streaming", sessionId: "live-session-reply", sessionKey: "agent:main:main", prompt: "hello", backend: { liveSession: "claude-stdio", }, }); const run = executePreparedCliRun({ ...context, params: { ...context.params, replyOperation: operation, }, }); await writeReady; expect(replyRunRegistry.isStreaming("agent:main:main")).toBe(true); stdoutListener?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: "live-session-reply" }), JSON.stringify({ type: "result", session_id: "live-session-reply", result: "done", }), ].join("\n") + "\n", ); await expect(run).resolves.toMatchObject({ text: "done" }); expect(replyRunRegistry.isStreaming("agent:main:main")).toBe(false); operation.complete(); }); it("reuses a Claude live session when resumed turns omit the system prompt arg", async () => { let stdoutListener: ((chunk: string) => void) | undefined; let turn = 0; const stdin = { write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { turn += 1; stdoutListener?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: "live-system" }), JSON.stringify({ type: "result", session_id: "live-system", result: turn === 1 ? "one" : "two", }), ].join("\n") + "\n", ); cb?.(); }), end: vi.fn(), }; supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; return { runId: "live-run", pid: 2345, startedAtMs: Date.now(), stdin, wait: vi.fn(() => new Promise(() => {})), cancel: vi.fn(), }; }); const backend = { resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], liveSession: "claude-stdio" as const, }; const first = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-system-1", prompt: "first", backend, }), ); const second = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-system-2", prompt: "second", backend, }), "live-system", ); expect(first.text).toBe("one"); expect(second.text).toBe("two"); expect(supervisorSpawnMock).toHaveBeenCalledOnce(); }); it("serializes concurrent Claude live session creation for the same key", async () => { let stdoutListener: ((chunk: string) => void) | undefined; let releaseSpawn: (() => void) | undefined; let turn = 0; const spawnReady = new Promise((resolve) => { releaseSpawn = resolve; }); const stdin = { write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { turn += 1; stdoutListener?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: "live-concurrent" }), JSON.stringify({ type: "result", session_id: "live-concurrent", result: turn === 1 ? "one" : "two", }), ].join("\n") + "\n", ); cb?.(); }), end: vi.fn(), }; supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; await spawnReady; return { runId: "live-run", pid: 2345, startedAtMs: Date.now(), stdin, wait: vi.fn(() => new Promise(() => {})), cancel: vi.fn(), }; }); const backend = { liveSession: "claude-stdio" as const, }; const first = executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-concurrent-1", prompt: "first", backend, }), ); const second = executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-concurrent-2", prompt: "second", backend, }), ); await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledOnce()); releaseSpawn?.(); const results = await Promise.all([first, second]); expect(results.map((result) => result.text).toSorted()).toEqual(["one", "two"]); expect(stdin.write).toHaveBeenCalledTimes(2); expect(supervisorSpawnMock).toHaveBeenCalledOnce(); }); it("counts pending Claude live session creates against the session cap", async () => { let releaseSpawn: (() => void) | undefined; const spawnReady = new Promise((resolve) => { releaseSpawn = resolve; }); supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; const spawnIndex = supervisorSpawnMock.mock.calls.length; await spawnReady; const stdin = { write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { input.onStdout?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: `live-cap-${spawnIndex}`, }), JSON.stringify({ type: "result", session_id: `live-cap-${spawnIndex}`, result: `ok-${spawnIndex}`, }), ].join("\n") + "\n", ); cb?.(); }), end: vi.fn(), }; return { runId: `live-run-${spawnIndex}`, pid: 2300 + spawnIndex, startedAtMs: Date.now(), stdin, wait: vi.fn(() => new Promise(() => {})), cancel: vi.fn(), }; }); const backend = { liveSession: "claude-stdio" as const, }; const runs = Array.from({ length: 17 }, (_, index) => (() => { const context = buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: `run-live-cap-${index}`, prompt: `prompt ${index}`, sessionId: `session-${index}`, backend, }); return runClaudeLiveSessionTurn({ context, args: context.preparedBackend.backend.args ?? [], env: {}, prompt: `prompt ${index}`, useResume: false, noOutputTimeoutMs: 1_000, getProcessSupervisor: () => ({ spawn: (params: Parameters[0]) => supervisorSpawnMock(params) as ReturnType, cancel: vi.fn(), cancelScope: vi.fn(), reconcileOrphans: vi.fn(), getRecord: vi.fn(), }), onAssistantDelta: () => {}, cleanup: async () => {}, }); })(), ); await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledTimes(16)); const rejectedRun = runs[16]; expect(rejectedRun).toBeDefined(); await expect(rejectedRun).rejects.toThrow("Too many Claude CLI live sessions are active."); releaseSpawn?.(); await expect(Promise.all(runs.slice(0, 16))).resolves.toHaveLength(16); expect(supervisorSpawnMock).toHaveBeenCalledTimes(16); }); it("preserves Claude resume args when building live session argv", () => { const backend: PreparedCliRunContext["preparedBackend"]["backend"] = { command: "claude", args: ["-p", "--output-format", "stream-json"], output: "jsonl", input: "stdin", sessionArg: "--session-id", systemPromptArg: "--append-system-prompt", systemPromptFileArg: "--append-system-prompt-file", }; const args = buildClaudeLiveArgs({ args: [ "-p", "--output-format", "stream-json", "--resume", "claude-session", "--session-id", "openclaw-session", "--append-system-prompt", "old prompt", "--append-system-prompt-file", "/tmp/system-prompt.md", ], backend, systemPrompt: "current prompt", useResume: true, }); expect(args).toContain("--resume"); expect(args).toContain("claude-session"); expect(args).not.toContain("--session-id"); expect(args).not.toContain("openclaw-session"); expect(args).not.toContain("--append-system-prompt-file"); expect(args).not.toContain("/tmp/system-prompt.md"); expect(args).not.toContain("--append-system-prompt"); expect(args).not.toContain("old prompt"); expect(args).not.toContain("current prompt"); }); it("adds Claude stream-json output format when building live session argv", () => { const backend: PreparedCliRunContext["preparedBackend"]["backend"] = { command: "claude", args: ["-p"], output: "jsonl", input: "stdin", sessionArg: "--session-id", systemPromptArg: "--append-system-prompt", systemPromptFileArg: "--append-system-prompt-file", }; const args = buildClaudeLiveArgs({ args: ["-p"], backend, systemPrompt: "current prompt", useResume: false, }); expect(args).toEqual( expect.arrayContaining([ "--input-format", "stream-json", "--output-format", "stream-json", "--permission-prompt-tool", "stdio", ]), ); }); it("restarts Claude live sessions for env changes and fresh retries", async () => { const cancels: Array> = []; const turnResults = ["first-ok", "resume-ok", "env-ok", "fresh-ok"]; let turnIndex = 0; supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { const spawnIndex = supervisorSpawnMock.mock.calls.length; const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; const cancel = vi.fn(); cancels.push(cancel); return { runId: `live-run-${spawnIndex}`, pid: 2345 + spawnIndex, startedAtMs: Date.now(), stdin: { write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { const result = turnResults[turnIndex] ?? "ok"; turnIndex += 1; input.onStdout?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: "live-session" }), JSON.stringify({ type: "result", session_id: "live-session", result, }), ].join("\n") + "\n", ); cb?.(); }), end: vi.fn(), }, wait: vi.fn(() => new Promise(() => {})), cancel, }; }); const runTurn = async (runId: string, args: string[], env: Record) => { const context = buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId, backend: { liveSession: "claude-stdio", resumeArgs: ["-p", "--output-format", "stream-json", "--resume", "{sessionId}"], }, }); const result = await runClaudeLiveSessionTurn({ context, args, env, prompt: "hi", useResume: args.some((entry) => entry.startsWith("--resume")), noOutputTimeoutMs: 1_000, getProcessSupervisor: () => ({ spawn: (params: Parameters[0]) => supervisorSpawnMock(params) as ReturnType, cancel: vi.fn(), cancelScope: vi.fn(), reconcileOrphans: vi.fn(), getRecord: vi.fn(), }), onAssistantDelta: () => {}, cleanup: async () => {}, }); return result.output.text; }; const freshArgs = ["-p", "--output-format", "stream-json"]; const resumeArgs = ["-p", "--output-format", "stream-json", "--resume", "live-session"]; await expect( runTurn("run-live-fresh", freshArgs, { ANTHROPIC_BASE_URL: "https://one.example" }), ).resolves.toBe("first-ok"); await expect( runTurn("run-live-resume", resumeArgs, { ANTHROPIC_BASE_URL: "https://one.example" }), ).resolves.toBe("resume-ok"); expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); expect(cancels[0]).not.toHaveBeenCalled(); await expect( runTurn("run-live-env-change", resumeArgs, { ANTHROPIC_BASE_URL: "https://two.example" }), ).resolves.toBe("env-ok"); expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); await expect( runTurn("run-live-fresh-retry", freshArgs, { ANTHROPIC_BASE_URL: "https://two.example", }), ).resolves.toBe("fresh-ok"); expect(supervisorSpawnMock).toHaveBeenCalledTimes(3); expect(cancels[1]).toHaveBeenCalledWith("manual-cancel"); expect(cancels[2]).not.toHaveBeenCalled(); }); it("ignores non-JSON stdout lines from Claude live sessions", async () => { let stdoutListener: ((chunk: string) => void) | undefined; const stdin = { write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { stdoutListener?.( [ "Claude CLI warning", JSON.stringify({ type: "system", subtype: "init", session_id: "live-mixed" }), JSON.stringify({ type: "result", session_id: "live-mixed", result: "mixed-ok", }), ].join("\n") + "\n", ); cb?.(); }), end: vi.fn(), }; supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; return { runId: "live-run", pid: 2345, startedAtMs: Date.now(), stdin, wait: vi.fn(() => new Promise(() => {})), cancel: vi.fn(), }; }); const result = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-mixed", backend: { liveSession: "claude-stdio", }, }), ); expect(result.text).toBe("mixed-ok"); }); it("fails Claude live turns on is_error results", async () => { let stdoutListener: ((chunk: string) => void) | undefined; const stdin = { write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { stdoutListener?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: "live-error" }), JSON.stringify({ type: "result", session_id: "live-error", is_error: true, result: "Credit balance is too low", }), ].join("\n") + "\n", ); cb?.(); }), end: vi.fn(), }; supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; return { runId: "live-run", pid: 2345, startedAtMs: Date.now(), stdin, wait: vi.fn(() => new Promise(() => {})), cancel: vi.fn(), }; }); await expect( executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-error", backend: { liveSession: "claude-stdio", }, }), ), ).rejects.toMatchObject({ name: "FailoverError", message: "Credit balance is too low", }); }); it("fails when Claude exits before a live turn starts", async () => { supervisorSpawnMock.mockImplementationOnce(async () => ({ runId: "live-run", pid: 2345, startedAtMs: Date.now(), stdin: { write: vi.fn(), end: vi.fn(), }, wait: vi.fn(async () => ({ reason: "exit", exitCode: 1, exitSignal: null, durationMs: 1, stdout: "", stderr: "startup failed", timedOut: false, noOutputTimedOut: false, })), cancel: vi.fn(), })); await expect( executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-startup-exit", backend: { liveSession: "claude-stdio", }, }), ), ).rejects.toThrow("Claude CLI live session closed before handling the turn"); }); it("restarts the Claude live process after request abort", async () => { const abortController = new AbortController(); let stdoutListener: ((chunk: string) => void) | undefined; const cancels: Array> = []; supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; const spawnIndex = supervisorSpawnMock.mock.calls.length; const cancel = vi.fn(); cancels.push(cancel); const stdin = { write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { if (spawnIndex === 2) { stdoutListener?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: "live-abort-2" }), JSON.stringify({ type: "result", session_id: "live-abort-2", result: "second-ok", }), ].join("\n") + "\n", ); } cb?.(); }), end: vi.fn(), }; return { runId: `live-run-${spawnIndex}`, pid: 2345 + spawnIndex, startedAtMs: Date.now(), stdin, wait: vi.fn( () => new Promise((resolve) => { if (spawnIndex === 1) { cancel.mockImplementationOnce(() => { resolve({ reason: "manual-cancel", exitCode: null, exitSignal: null, durationMs: 50, stdout: "", stderr: "", timedOut: false, noOutputTimedOut: false, }); }); } }), ), cancel, }; }); const firstContext = buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-abort-1", backend: { liveSession: "claude-stdio", }, }); firstContext.params.abortSignal = abortController.signal; const first = executePreparedCliRun(firstContext); await vi.waitFor(() => { expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); }); abortController.abort(); await expect(first).rejects.toMatchObject({ name: "AbortError" }); expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); stdoutListener?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: "live-abort" }), JSON.stringify({ type: "result", session_id: "live-abort", result: "discarded", }), ].join("\n") + "\n", ); const second = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-abort-2", backend: { liveSession: "claude-stdio", }, }), ); expect(second.text).toBe("second-ok"); expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); }); it("restarts Claude live sessions when selected skills change", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-skills-")); const weatherDir = path.join(workspaceDir, "skills", "weather"); const gitDir = path.join(workspaceDir, "skills", "git"); await fs.mkdir(weatherDir, { recursive: true }); await fs.mkdir(gitDir, { recursive: true }); await fs.writeFile(path.join(weatherDir, "SKILL.md"), "weather instructions\n", "utf-8"); await fs.writeFile(path.join(gitDir, "SKILL.md"), "git instructions\n", "utf-8"); const cancels: Array> = []; supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { const spawnIndex = supervisorSpawnMock.mock.calls.length; const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; const cancel = vi.fn(); cancels.push(cancel); const stdin = { write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { const text = spawnIndex === 1 ? "weather-ok" : "git-ok"; input.onStdout?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: `live-${spawnIndex}` }), JSON.stringify({ type: "result", session_id: `live-${spawnIndex}`, result: text, }), ].join("\n") + "\n", ); cb?.(); }), end: vi.fn(), }; return { runId: `live-run-${spawnIndex}`, pid: 2345 + spawnIndex, startedAtMs: Date.now(), stdin, wait: vi.fn(() => new Promise(() => {})), cancel, }; }); try { const first = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-skills-1", prompt: "first", workspaceDir, backend: { liveSession: "claude-stdio", }, skillsSnapshot: { prompt: "weather", skills: [{ name: "weather" }], resolvedSkills: [ { name: "weather", description: "Weather instructions.", filePath: path.join(weatherDir, "SKILL.md"), baseDir: weatherDir, source: "test", sourceInfo: { path: weatherDir, source: "test", scope: "project", origin: "top-level", baseDir: weatherDir, }, disableModelInvocation: false, }, ], }, }), ); const second = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-skills-2", prompt: "second", workspaceDir, backend: { liveSession: "claude-stdio", }, skillsSnapshot: { prompt: "git", skills: [{ name: "git" }], resolvedSkills: [ { name: "git", description: "Git instructions.", filePath: path.join(gitDir, "SKILL.md"), baseDir: gitDir, source: "test", sourceInfo: { path: gitDir, source: "test", scope: "project", origin: "top-level", baseDir: gitDir, }, disableModelInvocation: false, }, ], }, }), ); expect(first.text).toBe("weather-ok"); expect(second.text).toBe("git-ok"); expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); expect(cancels[1]).not.toHaveBeenCalled(); } finally { await fs.rm(workspaceDir, { recursive: true, force: true }); } }); it("closes idle Claude live sessions after ten minutes", async () => { vi.useFakeTimers(); const writes: string[] = []; let stdoutListener: ((chunk: string) => void) | undefined; const cancel = vi.fn(); const stdin = { write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { writes.push(data); stdoutListener?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: "live-session-idle" }), JSON.stringify({ type: "result", session_id: "live-session-idle", result: "idle-ok", }), ].join("\n") + "\n", ); cb?.(); }), end: vi.fn(), }; supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; return { runId: "live-run", pid: 2345, startedAtMs: Date.now(), stdin, wait: vi.fn(() => new Promise(() => {})), cancel, }; }); try { const result = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-idle", prompt: "idle", backend: { liveSession: "claude-stdio", }, }), ); expect(result.text).toBe("idle-ok"); expect(cancel).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(10 * 60 * 1_000 - 1); expect(cancel).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(cancel).toHaveBeenCalledWith("manual-cancel"); expect( writes.map( (entry) => (JSON.parse(entry) as { message: { content: string } }).message.content, ), ).toEqual(["idle"]); } finally { vi.useRealTimers(); } }); it("does not surface stale stderr after a later Claude live exit", async () => { let stdoutListener: ((chunk: string) => void) | undefined; let stderrListener: ((chunk: string) => void) | undefined; let resolveExit!: (value: { reason: "exit"; exitCode: number; exitSignal: null; durationMs: number; stdout: string; stderr: string; timedOut: false; noOutputTimedOut: false; }) => void; const wait = new Promise<{ reason: "exit"; exitCode: number; exitSignal: null; durationMs: number; stdout: string; stderr: string; timedOut: false; noOutputTimedOut: false; }>((resolve) => { resolveExit = resolve; }); let writeCount = 0; const stdin = { write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { writeCount += 1; if (writeCount === 1) { stderrListener?.("stale stderr from first turn"); stdoutListener?.( [ JSON.stringify({ type: "system", subtype: "init", session_id: "live-stderr" }), JSON.stringify({ type: "result", session_id: "live-stderr", result: "first-ok", }), ].join("\n") + "\n", ); cb?.(); return; } cb?.(); resolveExit({ reason: "exit", exitCode: 1, exitSignal: null, durationMs: 50, stdout: "", stderr: "", timedOut: false, noOutputTimedOut: false, }); }), end: vi.fn(), }; supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void; onStderr?: (chunk: string) => void; }; stdoutListener = input.onStdout; stderrListener = input.onStderr; return { runId: "live-run", pid: 2345, startedAtMs: Date.now(), stdin, wait: vi.fn(() => wait), cancel: vi.fn(), }; }); const first = await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-stderr-1", prompt: "first", backend: { liveSession: "claude-stdio", }, }), ); const second = executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-live-stderr-2", prompt: "second", backend: { liveSession: "claude-stdio", }, }), ); expect(first.text).toBe("first-ok"); await expect(second).rejects.toMatchObject({ name: "FailoverError", message: "Claude CLI failed.", }); }); it("surfaces nested Claude stream-json API errors instead of raw event output", async () => { const { message, jsonl } = createClaudeApiErrorFixture(); supervisorSpawnMock.mockResolvedValueOnce( createManagedRun({ reason: "exit", exitCode: 1, exitSignal: null, durationMs: 50, stdout: jsonl, stderr: "", timedOut: false, noOutputTimedOut: false, }), ); const run = executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "sonnet", runId: "run-claude-api-error", }), ); await expect(run).rejects.toMatchObject({ name: "FailoverError", message, reason: "billing", status: 402, }); }); it("sanitizes dangerous backend env overrides before spawn", async () => { mockSuccessfulCliRun(); await executePreparedCliRun( buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", runId: "run-env-sanitized", backend: { env: { NODE_OPTIONS: "--require ./malicious.js", LD_PRELOAD: "/tmp/pwn.so", PATH: "/tmp/evil", HOME: "/tmp/evil-home", SAFE_KEY: "ok", }, }, }), "thread-123", ); const input = supervisorSpawnMock.mock.calls[0]?.[0] as { env?: Record; }; expect(input.env?.SAFE_KEY).toBe("ok"); expect(input.env?.PATH).toBe(process.env.PATH); expect(input.env?.HOME).toBe(process.env.HOME); expect(input.env?.NODE_OPTIONS).toBeUndefined(); expect(input.env?.LD_PRELOAD).toBeUndefined(); }); it("applies clearEnv after sanitizing backend env overrides", async () => { process.env.SAFE_CLEAR = "from-base"; mockSuccessfulCliRun(); await executePreparedCliRun( buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", runId: "run-clear-env", backend: { env: { SAFE_KEEP: "keep-me", }, clearEnv: ["SAFE_CLEAR"], }, }), "thread-123", ); const input = supervisorSpawnMock.mock.calls[0]?.[0] as { env?: Record; }; expect(input.env?.SAFE_KEEP).toBe("keep-me"); expect(input.env?.SAFE_CLEAR).toBeUndefined(); }); it("can preserve selected clearEnv keys for live CLI backend probes", async () => { try { process.env.OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV = '["SAFE_CLEAR"]'; process.env.SAFE_CLEAR = "from-base"; mockSuccessfulCliRun(); await executePreparedCliRun( buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", runId: "run-clear-env-preserve", backend: { clearEnv: ["SAFE_CLEAR", "SAFE_DROP"], }, }), "thread-123", ); const input = supervisorSpawnMock.mock.calls[0]?.[0] as { env?: Record; }; expect(input.env?.SAFE_CLEAR).toBe("from-base"); expect(input.env?.SAFE_DROP).toBeUndefined(); } finally { delete process.env.OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV; delete process.env.SAFE_CLEAR; } }); it("keeps explicit backend env overrides even when clearEnv drops inherited values", async () => { process.env.SAFE_OVERRIDE = "from-base"; mockSuccessfulCliRun(); await executePreparedCliRun( buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", runId: "run-clear-env-override", backend: { env: { SAFE_OVERRIDE: "from-override", }, clearEnv: ["SAFE_OVERRIDE"], }, }), "thread-123", ); const input = supervisorSpawnMock.mock.calls[0]?.[0] as { env?: Record; }; expect(input.env?.SAFE_OVERRIDE).toBe("from-override"); }); it("clears claude-cli provider-routing, auth, telemetry, and host-managed env", async () => { vi.stubEnv("ANTHROPIC_BASE_URL", "https://proxy.example.com/v1"); vi.stubEnv("ANTHROPIC_API_TOKEN", "env-api-token"); vi.stubEnv("ANTHROPIC_CUSTOM_HEADERS", "x-test-header: env"); vi.stubEnv("ANTHROPIC_OAUTH_TOKEN", "env-oauth-token"); vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", "1"); vi.stubEnv("ANTHROPIC_AUTH_TOKEN", "env-auth-token"); vi.stubEnv("CLAUDE_CODE_OAUTH_TOKEN", "env-oauth-token"); vi.stubEnv("CLAUDE_CODE_REMOTE", "1"); vi.stubEnv("ANTHROPIC_UNIX_SOCKET", "/tmp/anthropic.sock"); vi.stubEnv("OTEL_LOGS_EXPORTER", "none"); vi.stubEnv("OTEL_METRICS_EXPORTER", "none"); vi.stubEnv("OTEL_TRACES_EXPORTER", "none"); vi.stubEnv("OTEL_EXPORTER_OTLP_PROTOCOL", "none"); vi.stubEnv("OTEL_SDK_DISABLED", "true"); vi.stubEnv("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1"); mockSuccessfulCliRun(); await executePreparedCliRun( buildPreparedCliRunContext({ provider: "claude-cli", model: "claude-sonnet-4-6", runId: "run-claude-env-hardened", backend: { env: { SAFE_KEEP: "ok", ANTHROPIC_BASE_URL: "https://override.example.com/v1", CLAUDE_CODE_OAUTH_TOKEN: "override-oauth-token", CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1", }, clearEnv: [ "ANTHROPIC_BASE_URL", "ANTHROPIC_API_TOKEN", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_OAUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_REMOTE", "ANTHROPIC_UNIX_SOCKET", "OTEL_LOGS_EXPORTER", "OTEL_METRICS_EXPORTER", "OTEL_TRACES_EXPORTER", "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_SDK_DISABLED", ], }, }), ); const input = supervisorSpawnMock.mock.calls[0]?.[0] as { env?: Record; }; expect(input.env?.SAFE_KEEP).toBe("ok"); expect(input.env?.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined(); expect(input.env?.ANTHROPIC_BASE_URL).toBe("https://override.example.com/v1"); expect(input.env?.ANTHROPIC_API_TOKEN).toBeUndefined(); expect(input.env?.ANTHROPIC_CUSTOM_HEADERS).toBeUndefined(); expect(input.env?.ANTHROPIC_OAUTH_TOKEN).toBeUndefined(); expect(input.env?.CLAUDE_CODE_USE_BEDROCK).toBeUndefined(); expect(input.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); expect(input.env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("override-oauth-token"); expect(input.env?.CLAUDE_CODE_REMOTE).toBeUndefined(); expect(input.env?.ANTHROPIC_UNIX_SOCKET).toBeUndefined(); expect(input.env?.OTEL_LOGS_EXPORTER).toBeUndefined(); expect(input.env?.OTEL_METRICS_EXPORTER).toBeUndefined(); expect(input.env?.OTEL_TRACES_EXPORTER).toBeUndefined(); expect(input.env?.OTEL_EXPORTER_OTLP_PROTOCOL).toBeUndefined(); expect(input.env?.OTEL_SDK_DISABLED).toBeUndefined(); }); it("formats CLI auth env diagnostics as key names without secret values", () => { vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-host"); vi.stubEnv("ANTHROPIC_API_TOKEN", "token-host"); vi.stubEnv("OPENAI_API_KEY", "sk-openai-host"); const log = buildCliEnvAuthLog({ ANTHROPIC_API_TOKEN: "token-child", CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1", OPENAI_API_KEY: "sk-openai-child", }); expect(log).toMatch(/host=.*ANTHROPIC_API_KEY/); expect(log).toMatch(/host=.*ANTHROPIC_API_TOKEN/); expect(log).toMatch(/host=.*OPENAI_API_KEY/); expect(log).toMatch(/child=.*ANTHROPIC_API_TOKEN/); expect(log).toMatch(/child=.*CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST/); expect(log).toMatch(/child=.*OPENAI_API_KEY/); expect(log).toMatch(/cleared=.*ANTHROPIC_API_KEY/); expect(log).not.toContain("sk-ant-host"); expect(log).not.toContain("token-child"); expect(log).not.toContain("sk-openai-child"); }); it("prepends bootstrap warnings to the CLI prompt body", async () => { supervisorSpawnMock.mockResolvedValueOnce( createManagedRun({ reason: "exit", exitCode: 0, exitSignal: null, durationMs: 50, stdout: "ok", stderr: "", timedOut: false, noOutputTimedOut: false, }), ); const context = buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", runId: "run-warning", }); context.reusableCliSession = { sessionId: "thread-123" }; context.bootstrapPromptWarningLines = [ "[Bootstrap truncation warning]", "- AGENTS.md: 200 raw -> 20 injected", ]; await executePreparedCliRun(context, "thread-123"); const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[]; input?: string; }; const promptCarrier = [input.input ?? "", ...(input.argv ?? [])].join("\n"); expect(promptCarrier).toContain("[Bootstrap truncation warning]"); expect(promptCarrier).toContain("- AGENTS.md: 200 raw -> 20 injected"); expect(promptCarrier).toContain("hi"); }); it("loads workspace bootstrap files into the Claude CLI system prompt", async () => { const workspaceDir = await fs.mkdtemp( path.join(os.tmpdir(), "openclaw-cli-bootstrap-context-"), ); 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 { const { contextFiles } = await realResolveBootstrapContextForRun({ workspaceDir, }); const allArgs = buildSystemPrompt({ workspaceDir, modelDisplay: "claude-cli/sonnet", contextFiles, tools: [], }); 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(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(); } }); });