Download src/commands/agent-exec.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 40.5 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/commands/agent-exec.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/commands/agent-exec.test.ts
-
curl -L -o agent-exec.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/commands/agent-exec.test.ts
40.5 kB
| import { execFile } from "node:child_process"; | |
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { DatabaseSync } from "node:sqlite"; | |
| import { Readable } from "node:stream"; | |
| import { promisify } from "node:util"; | |
| import { expectDefined } from "@openclaw/normalization-core"; | |
| import { afterEach, describe, expect, it, vi } from "vitest"; | |
| import { cleanupTempDirs, useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; | |
| import { prepareAgentCommandExecutionIdentity } from "../agents/agent-command-execution-identity.js"; | |
| import { AgentRunTerminalOutcomeError } from "../agents/agent-run-terminal-error.js"; | |
| import type { AgentCommandOpts } from "../agents/command/types.js"; | |
| import { createAgentHarnessHostCapabilities } from "../agents/harness/host-capability.js"; | |
| import { createAgentHarnessToolSurfaceRuntimeCore } from "../agents/harness/tool-surface-bridge.js"; | |
| import { createStubTool } from "../agents/test-helpers/agent-tool-stubs.js"; | |
| import { enqueueExecutionIdentityContextAtAdmission } from "../audit/execution-identity-admission.js"; | |
| import { | |
| clearRuntimeConfigSnapshot, | |
| getRuntimeConfigSnapshot, | |
| setRuntimeConfigSnapshot, | |
| } from "../config/io.js"; | |
| import type { OpenClawConfig } from "../config/types.openclaw.js"; | |
| import type { RuntimeEnv } from "../runtime.js"; | |
| import { | |
| buildExecRunConfig, | |
| resolveAgentExecPrompt, | |
| resolveExecBaseConfig, | |
| } from "./agent-exec-input.js"; | |
| import { classifyAgentExecResult } from "./agent-exec-result.js"; | |
| import { agentExecCommand } from "./agent-exec.js"; | |
| const tempDirs = useAutoCleanupTempDirTracker(afterEach); | |
| const externalTempDirs: string[] = []; | |
| const execFileAsync = promisify(execFile); | |
| function createRuntime() { | |
| const log = vi.fn(); | |
| const error = vi.fn(); | |
| const runtime: RuntimeEnv = { | |
| log, | |
| error, | |
| exit: vi.fn(), | |
| }; | |
| return { runtime, log, error }; | |
| } | |
| function successResult(text = "done") { | |
| return { | |
| payloads: [{ text }], | |
| meta: { | |
| durationMs: 25, | |
| finalAssistantVisibleText: text, | |
| agentMeta: { | |
| sessionId: "session-result", | |
| provider: "openai", | |
| model: "gpt-5.6-sol", | |
| usage: { input: 10, output: 2, total: 12 }, | |
| }, | |
| }, | |
| }; | |
| } | |
| afterEach(() => { | |
| vi.restoreAllMocks(); | |
| cleanupTempDirs(externalTempDirs); | |
| }); | |
| describe("agent exec prompt sources", () => { | |
| it("accepts a positional prompt", async () => { | |
| await expect(resolveAgentExecPrompt("fix it", undefined)).resolves.toBe("fix it"); | |
| }); | |
| it("reads a UTF-8 prompt file", async () => { | |
| const root = tempDirs.make("openclaw-agent-exec-prompt-"); | |
| const promptPath = path.join(root, "prompt.md"); | |
| await fs.writeFile(promptPath, "\uFEFFline one\nline two", "utf8"); | |
| await expect(resolveAgentExecPrompt(undefined, promptPath)).resolves.toBe("line one\nline two"); | |
| }); | |
| it("reads --message-file - from stdin", async () => { | |
| const stdin = Readable.from([Buffer.from("from stdin", "utf8")]); | |
| await expect(resolveAgentExecPrompt(undefined, "-", stdin)).resolves.toBe("from stdin"); | |
| }); | |
| }); | |
| describe("agent exec strict result classification", () => { | |
| it("classifies a successful embedded result", () => { | |
| expect(classifyAgentExecResult(successResult())).toMatchObject({ | |
| ok: true, | |
| status: "ok", | |
| final: "done", | |
| }); | |
| }); | |
| it("classifies model error payloads as failure", () => { | |
| const envelope = classifyAgentExecResult({ | |
| payloads: [{ text: "provider rejected request", isError: true }], | |
| meta: { durationMs: 10 }, | |
| }); | |
| expect(envelope).toMatchObject({ | |
| ok: false, | |
| status: "error", | |
| error: { kind: "error_payload", message: "provider rejected request" }, | |
| }); | |
| }); | |
| it("classifies textless error payloads as failure", () => { | |
| const envelope = classifyAgentExecResult({ | |
| payloads: [{ isError: true }], | |
| meta: { durationMs: 10 }, | |
| }); | |
| expect(envelope).toMatchObject({ | |
| ok: false, | |
| status: "error", | |
| error: { kind: "error_payload", message: "Agent run failed" }, | |
| }); | |
| }); | |
| it("classifies terminal timeouts separately", () => { | |
| const envelope = classifyAgentExecResult({ | |
| payloads: [{ text: "timed out", isError: true }], | |
| meta: { durationMs: 600_000, aborted: true, stopReason: "timeout" }, | |
| }); | |
| expect(envelope).toMatchObject({ | |
| ok: false, | |
| status: "timeout", | |
| error: { kind: "timeout" }, | |
| }); | |
| }); | |
| it("classifies exhausted explicit fallbacks as failure", () => { | |
| const envelope = classifyAgentExecResult(successResult("last candidate output"), true); | |
| expect(envelope).toMatchObject({ | |
| ok: false, | |
| status: "error", | |
| error: { kind: "fallback_exhausted" }, | |
| }); | |
| }); | |
| it("classifies projected production error payloads as failure", () => { | |
| const envelope = classifyAgentExecResult( | |
| successResult("projected error text"), | |
| false, | |
| "projected error text", | |
| ); | |
| expect(envelope).toMatchObject({ | |
| ok: false, | |
| status: "error", | |
| final: "", | |
| payloads: [{ text: "projected error text", isError: true }], | |
| error: { kind: "error_payload", message: "projected error text" }, | |
| }); | |
| }); | |
| it("does not restore metadata text for a projected textless error", () => { | |
| const result = successResult("metadata error text"); | |
| result.payloads = []; | |
| const envelope = classifyAgentExecResult(result, false, true); | |
| expect(envelope).toMatchObject({ ok: false, status: "error", final: "", payloads: [] }); | |
| }); | |
| it("projects payloads onto the stable documented fields", () => { | |
| const envelope = classifyAgentExecResult({ | |
| payloads: [ | |
| { | |
| text: "done", | |
| mediaUrl: null, | |
| audioAsVoice: true, | |
| presentation: { blocks: [] }, | |
| channelData: { private: true }, | |
| }, | |
| ], | |
| meta: { durationMs: 10 }, | |
| }); | |
| expect(envelope.payloads).toEqual([{ text: "done", mediaUrl: null }]); | |
| }); | |
| it("projects the embedded outer tool summary", () => { | |
| const envelope = classifyAgentExecResult({ | |
| payloads: [{ text: "done" }], | |
| meta: { | |
| durationMs: 10, | |
| toolSummary: { | |
| calls: 2, | |
| tools: ["read", "write"], | |
| failures: 1, | |
| totalToolTimeMs: 25, | |
| }, | |
| }, | |
| }); | |
| expect(envelope.toolSummary).toEqual({ | |
| calls: 2, | |
| tools: ["read", "write"], | |
| failures: 1, | |
| totalToolTimeMs: 25, | |
| }); | |
| }); | |
| }); | |
| describe("agent exec command composition", () => { | |
| it("writes plain final text to stdout when diagnostics are routed to stderr", async () => { | |
| const source = ` | |
| import { agentExecCommand } from "./src/commands/agent-exec.ts"; | |
| import { enableConsoleCapture, routeLogsToStderr } from "./src/logging.ts"; | |
| import { defaultRuntime } from "./src/runtime.ts"; | |
| routeLogsToStderr(); | |
| enableConsoleCapture(); | |
| const result = await agentExecCommand("inspect", {}, defaultRuntime, { | |
| runAgent: async () => ({ | |
| payloads: [{ text: "india" }], | |
| meta: { | |
| durationMs: 1, | |
| agentMeta: { | |
| sessionId: "session-result", | |
| provider: "openai", | |
| model: "gpt-5.6-sol", | |
| }, | |
| }, | |
| }), | |
| }); | |
| process.exitCode = result.exitCode; | |
| `; | |
| const { stdout, stderr } = await execFileAsync( | |
| process.execPath, | |
| ["--import", "tsx", "--input-type=module", "--eval", source], | |
| { | |
| cwd: path.resolve(import.meta.dirname, "../.."), | |
| encoding: "utf8", | |
| env: { ...process.env, OPENCLAW_TEST_RUNTIME_LOG: "1" }, | |
| }, | |
| ); | |
| expect(stdout).toBe("india\n"); | |
| expect(stderr).not.toContain("india"); | |
| }); | |
| it("treats invalid timeout syntax as an ordinary usage error", async () => { | |
| const { runtime } = createRuntime(); | |
| const result = await agentExecCommand("inspect", { timeout: "nope", json: true }, runtime, { | |
| runAgent: vi.fn(async () => successResult()), | |
| }); | |
| expect(result).toMatchObject({ | |
| exitCode: 1, | |
| envelope: { status: "error", error: { kind: "exception" } }, | |
| }); | |
| }); | |
| it("maps structured thrown timeouts to exit code 2", async () => { | |
| const { runtime } = createRuntime(); | |
| const timeout = Object.assign(new Error("deadline elapsed"), { name: "TimeoutError" }); | |
| const runAgent = vi.fn(async () => { | |
| throw timeout; | |
| }); | |
| const result = await agentExecCommand("inspect", { timeout: "1", json: true }, runtime, { | |
| runAgent, | |
| }); | |
| expect(runAgent).toHaveBeenCalledWith( | |
| expect.objectContaining({ timeout: "1" }), | |
| expect.any(Object), | |
| ); | |
| expect(result).toMatchObject({ | |
| exitCode: 2, | |
| envelope: { status: "timeout", error: { kind: "timeout" } }, | |
| }); | |
| }); | |
| it("maps embedded terminal-outcome timeouts to exit code 2", async () => { | |
| const { runtime } = createRuntime(); | |
| const timeout = new AgentRunTerminalOutcomeError( | |
| new Error("attempt aborted before prompt submission"), | |
| { | |
| reason: "hard_timeout", | |
| status: "timeout", | |
| timeoutPhase: "provider", | |
| providerStarted: true, | |
| }, | |
| ); | |
| const result = await agentExecCommand("inspect", { json: true }, runtime, { | |
| runAgent: vi.fn(async () => { | |
| throw timeout; | |
| }), | |
| }); | |
| expect(result).toMatchObject({ | |
| exitCode: 2, | |
| envelope: { | |
| status: "timeout", | |
| error: { | |
| kind: "timeout", | |
| message: "attempt aborted before prompt submission", | |
| }, | |
| }, | |
| }); | |
| }); | |
| it("creates and removes ephemeral state for a configless run", async () => { | |
| const { runtime } = createRuntime(); | |
| let observedStateDir = ""; | |
| let observedConfigPath: string | undefined; | |
| let observedConfig: unknown; | |
| const result = await agentExecCommand("inspect", { authEnvOnly: true }, runtime, { | |
| runAgent: vi.fn(async () => { | |
| observedStateDir = process.env.OPENCLAW_STATE_DIR ?? ""; | |
| observedConfigPath = process.env.OPENCLAW_CONFIG_PATH; | |
| // The published snapshot is what the run reads; exec writes no config file. | |
| observedConfig = getRuntimeConfigSnapshot(); | |
| await expect(fs.stat(observedStateDir)).resolves.toBeDefined(); | |
| return successResult(); | |
| }), | |
| }); | |
| expect(result.exitCode).toBe(0); | |
| expect(observedConfigPath).toBeUndefined(); | |
| await expect(fs.readdir(observedStateDir).catch(() => [])).resolves.not.toContain( | |
| "openclaw.json", | |
| ); | |
| expect(observedConfig).toMatchObject({ | |
| agents: { defaults: { skipBootstrap: true, sandbox: { mode: "off" } } }, | |
| tools: { | |
| profile: "coding", | |
| fs: { workspaceOnly: true }, | |
| exec: { mode: "full" }, | |
| }, | |
| }); | |
| await expect(fs.stat(observedStateDir)).rejects.toMatchObject({ code: "ENOENT" }); | |
| }); | |
| it.each(["current", "revoked", "replaced"])( | |
| "keeps source authority through embedded admission without signal cancellation (%s)", | |
| async (outcome) => { | |
| const { runtime } = createRuntime(); | |
| const controller = new AbortController(); | |
| const claim = { current: true }; | |
| let owner = claim; | |
| let effectCount = 0; | |
| let stateDir = ""; | |
| const result = await agentExecCommand("inspect", { authEnvOnly: true }, runtime, { | |
| abortSignal: controller.signal, | |
| assertSourceCurrent: () => { | |
| if (owner !== claim || !claim.current) { | |
| throw new Error("repair owner closed"); | |
| } | |
| }, | |
| runAgent: async (invocation) => { | |
| stateDir = process.env.OPENCLAW_STATE_DIR!; | |
| const admission = prepareAgentCommandExecutionIdentity({ | |
| opts: invocation as AgentCommandOpts, | |
| prepared: { | |
| cfg: {}, | |
| runId: `exec-source-${outcome}`, | |
| sessionAgentId: "main", | |
| sessionId: "source-session", | |
| }, | |
| ingress: { kind: "local-cli", boundary: "test", state: "present" }, | |
| lifecycleGeneration: "test-generation", | |
| }); | |
| try { | |
| const admitted = await admission.admit("embedded"); | |
| const host = createAgentHarnessHostCapabilities({ | |
| pluginId: "test", | |
| attempt: { | |
| admittedRunContext: admitted, | |
| runId: `exec-source-${outcome}`, | |
| abortSignal: controller.signal, | |
| }, | |
| }); | |
| try { | |
| const [tool] = host.capabilities.bindToolSurface([ | |
| { | |
| ...createStubTool("source_effect"), | |
| execute: async () => { | |
| effectCount += 1; | |
| return { content: [], details: {} }; | |
| }, | |
| }, | |
| ]); | |
| await Promise.resolve(); | |
| if (outcome === "revoked") { | |
| claim.current = false; | |
| } | |
| if (outcome === "replaced") { | |
| owner = { current: true }; | |
| } | |
| await tool!.execute!("source-call", {}); | |
| return successResult(); | |
| } finally { | |
| host.close(); | |
| } | |
| } finally { | |
| admission.close(); | |
| } | |
| }, | |
| }); | |
| expect(controller.signal.aborted).toBe(false); | |
| expect(effectCount).toBe(outcome === "current" ? 1 : 0); | |
| expect(result.exitCode).toBe(outcome === "current" ? 0 : 1); | |
| await expect(fs.stat(stateDir)).rejects.toMatchObject({ code: "ENOENT" }); | |
| }, | |
| ); | |
| it("cancels a failure-owned turn and removes its temporary state", async () => { | |
| const { runtime } = createRuntime(); | |
| const controller = new AbortController(); | |
| let stateDir = ""; | |
| const result = await agentExecCommand("inspect", { authEnvOnly: true }, runtime, { | |
| abortSignal: controller.signal, | |
| runAgent: async (invocation) => { | |
| stateDir = process.env.OPENCLAW_STATE_DIR!; | |
| const signal = invocation.abortSignal as AbortSignal; | |
| expect(signal.aborted).toBe(false); | |
| controller.abort(new Error("operator stopped the Gateway")); | |
| expect(signal.reason).toBe(controller.signal.reason); | |
| signal.throwIfAborted(); | |
| return successResult(); | |
| }, | |
| }); | |
| expect(result.exitCode).toBe(1); | |
| expect(result.envelope.error?.message).toContain("operator stopped the Gateway"); | |
| await expect(fs.stat(stateDir)).rejects.toMatchObject({ code: "ENOENT" }); | |
| }); | |
| it("flushes opted-in identity evidence through its owned direct-local writer", async () => { | |
| const root = tempDirs.make("openclaw-agent-exec-audit-"); | |
| const admittedAt = Date.now(); | |
| setRuntimeConfigSnapshot({ logging: { audit: { executionIdentity: true } } }); | |
| try { | |
| const { runtime } = createRuntime(); | |
| const result = await agentExecCommand("inspect", { stateDir: root }, runtime, { | |
| runAgent: vi.fn(async () => { | |
| expect( | |
| enqueueExecutionIdentityContextAtAdmission( | |
| { | |
| runId: "agent-exec-run", | |
| agentId: "main", | |
| ingress: { | |
| kind: "local-cli", | |
| boundary: "agent-command.local", | |
| state: "present", | |
| }, | |
| runtime: { kind: "embedded" }, | |
| }, | |
| { | |
| enabled: true, | |
| contextId: "agent-exec-context", | |
| executionId: "agent-exec-execution", | |
| now: admittedAt, | |
| runtimeInstanceId: "agent-exec-runtime", | |
| }, | |
| ), | |
| ).toMatchObject({ accepted: true }); | |
| return successResult(); | |
| }), | |
| }); | |
| expect(result.exitCode).toBe(0); | |
| const database = new DatabaseSync(path.join(root, "state", "openclaw.sqlite"), { | |
| readOnly: true, | |
| }); | |
| try { | |
| const row = database | |
| .prepare("SELECT context_json FROM execution_identity_contexts WHERE execution_id = ?") | |
| .get("agent-exec-execution") as { context_json: string }; | |
| expect(JSON.parse(row.context_json)).toMatchObject({ | |
| contextId: "agent-exec-context", | |
| executionId: "agent-exec-execution", | |
| runId: "agent-exec-run", | |
| ingress: { kind: "local-cli", state: "present" }, | |
| }); | |
| } finally { | |
| database.close(); | |
| } | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it("discovers operator-installed plugins while run state stays ephemeral", async () => { | |
| const operatorStateDir = tempDirs.make("openclaw-agent-exec-plugin-owner-"); | |
| const pluginDir = path.join(operatorStateDir, "extensions", "exec-provider"); | |
| await fs.mkdir(pluginDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(pluginDir, "openclaw.plugin.json"), | |
| JSON.stringify({ | |
| id: "exec-provider", | |
| configSchema: { type: "object", additionalProperties: false }, | |
| providers: ["exec-provider"], | |
| }), | |
| "utf8", | |
| ); | |
| await fs.writeFile( | |
| path.join(pluginDir, "package.json"), | |
| JSON.stringify({ | |
| name: "exec-provider", | |
| version: "1.0.0", | |
| type: "module", | |
| openclaw: { extensions: ["./index.js"] }, | |
| }), | |
| "utf8", | |
| ); | |
| await fs.writeFile(path.join(pluginDir, "index.js"), "export default {}\n", "utf8"); | |
| const previousStateDir = process.env.OPENCLAW_STATE_DIR; | |
| process.env.OPENCLAW_STATE_DIR = operatorStateDir; | |
| const { runtime } = createRuntime(); | |
| let runtimeStateDir = ""; | |
| let discoveredRoot = ""; | |
| try { | |
| await agentExecCommand("inspect", {}, runtime, { | |
| runAgent: vi.fn(async () => { | |
| runtimeStateDir = process.env.OPENCLAW_STATE_DIR ?? ""; | |
| const { resolvePluginMetadataSnapshot } = | |
| await import("../plugins/plugin-metadata-snapshot.js"); | |
| const snapshot = resolvePluginMetadataSnapshot({ | |
| allowCurrent: false, | |
| config: { plugins: { entries: { "exec-provider": { enabled: true } } } }, | |
| env: process.env, | |
| preferPersisted: false, | |
| }); | |
| discoveredRoot = snapshot.byPluginId.get("exec-provider")?.rootDir ?? ""; | |
| return successResult(); | |
| }), | |
| }); | |
| } finally { | |
| if (previousStateDir === undefined) { | |
| delete process.env.OPENCLAW_STATE_DIR; | |
| } else { | |
| process.env.OPENCLAW_STATE_DIR = previousStateDir; | |
| } | |
| } | |
| expect(runtimeStateDir).not.toBe(operatorStateDir); | |
| await expect(fs.realpath(discoveredRoot)).resolves.toBe(await fs.realpath(pluginDir)); | |
| await expect(fs.stat(runtimeStateDir)).rejects.toMatchObject({ code: "ENOENT" }); | |
| }); | |
| it("keeps operator-installed plugins hidden under --isolated", async () => { | |
| const operatorStateDir = tempDirs.make("openclaw-agent-exec-plugin-isolated-"); | |
| const previousStateDir = process.env.OPENCLAW_STATE_DIR; | |
| process.env.OPENCLAW_STATE_DIR = operatorStateDir; | |
| const { runtime } = createRuntime(); | |
| let resolvedExtensionsDir = ""; | |
| try { | |
| await agentExecCommand("inspect", { isolated: true }, runtime, { | |
| runAgent: vi.fn(async () => { | |
| const { resolveDefaultPluginExtensionsDir } = await import("../plugins/install-paths.js"); | |
| resolvedExtensionsDir = resolveDefaultPluginExtensionsDir(); | |
| return successResult(); | |
| }), | |
| }); | |
| } finally { | |
| if (previousStateDir === undefined) { | |
| delete process.env.OPENCLAW_STATE_DIR; | |
| } else { | |
| process.env.OPENCLAW_STATE_DIR = previousStateDir; | |
| } | |
| } | |
| expect(resolvedExtensionsDir).not.toBe(path.join(operatorStateDir, "extensions")); | |
| expect(path.basename(path.dirname(resolvedExtensionsDir))).toMatch(/^openclaw-agent-exec-/u); | |
| }); | |
| it("keeps --state-dir scoped to run state instead of plugin installs", async () => { | |
| const operatorStateDir = tempDirs.make("openclaw-agent-exec-plugin-operator-"); | |
| const retainedRunStateDir = tempDirs.make("openclaw-agent-exec-retained-state-"); | |
| const previousStateDir = process.env.OPENCLAW_STATE_DIR; | |
| process.env.OPENCLAW_STATE_DIR = operatorStateDir; | |
| const { runtime } = createRuntime(); | |
| let resolvedExtensionsDir = ""; | |
| try { | |
| await agentExecCommand("inspect", { stateDir: retainedRunStateDir }, runtime, { | |
| runAgent: vi.fn(async () => { | |
| expect(process.env.OPENCLAW_STATE_DIR).toBe(retainedRunStateDir); | |
| const { resolveDefaultPluginExtensionsDir } = await import("../plugins/install-paths.js"); | |
| resolvedExtensionsDir = resolveDefaultPluginExtensionsDir(); | |
| return successResult(); | |
| }), | |
| }); | |
| } finally { | |
| if (previousStateDir === undefined) { | |
| delete process.env.OPENCLAW_STATE_DIR; | |
| } else { | |
| process.env.OPENCLAW_STATE_DIR = previousStateDir; | |
| } | |
| } | |
| expect(resolvedExtensionsDir).toBe(path.join(operatorStateDir, "extensions")); | |
| await expect(fs.stat(retainedRunStateDir)).resolves.toBeDefined(); | |
| }); | |
| it.each([ | |
| { mode: "direct", configured: true, capability: "preferred", enabled: false }, | |
| { mode: "code", configured: false, capability: "capable", enabled: true }, | |
| { mode: "auto", configured: false, capability: "preferred", enabled: true }, | |
| { mode: "auto", configured: true, capability: "capable", enabled: false }, | |
| ] as const)( | |
| "honors --code-mode $mode over model settings ($capability)", | |
| async ({ mode, configured, capability, enabled }) => { | |
| const { runtime } = createRuntime(); | |
| const codeMode = { enabled: configured, maxOutputBytes: 4096 }; | |
| setRuntimeConfigSnapshot({ | |
| agents: { | |
| defaults: { | |
| systemAgent: { agentId: "main" }, | |
| models: { "test/model-a": { codeMode: configured } }, | |
| }, | |
| entries: { | |
| main: { models: { "test/model-a": { codeMode: configured } } }, | |
| }, | |
| }, | |
| tools: { codeMode, toolSearch: false }, | |
| }); | |
| let visibleTools: string[] | undefined; | |
| try { | |
| const result = await agentExecCommand( | |
| "inspect", | |
| { codeMode: mode, model: "test/model-a", localModelLean: true }, | |
| runtime, | |
| { | |
| runAgent: vi.fn(async (invocation) => { | |
| const config = expectDefined(getRuntimeConfigSnapshot(), "isolated run config"); | |
| expect(config.tools?.codeMode).toEqual(codeMode); | |
| expect(config.agents?.defaults?.experimental?.localModelLean).toBe(true); | |
| const surface = createAgentHarnessToolSurfaceRuntimeCore({ | |
| config, | |
| agentId: "main", | |
| modelProvider: "test", | |
| modelId: "model-a", | |
| model: { compat: { codeMode: capability } }, | |
| codeModeOverride: invocation.codeModeOverride as boolean | "auto" | undefined, | |
| modelToolsEnabled: true, | |
| executeTool: async () => ({ content: [], details: {} }), | |
| }); | |
| try { | |
| visibleTools = surface | |
| .compactTools([createStubTool("read")]) | |
| .tools.map((tool) => tool.name); | |
| } finally { | |
| surface.cleanup(); | |
| } | |
| return successResult(); | |
| }), | |
| }, | |
| ); | |
| expect(result.exitCode).toBe(0); | |
| expect(visibleTools).toEqual(enabled ? ["exec", "wait"] : ["read"]); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }, | |
| ); | |
| it("rejects invalid programmatic Code Mode values", async () => { | |
| const { runtime } = createRuntime(); | |
| const result = await agentExecCommand("inspect", { codeMode: "invalid" as never }, runtime, { | |
| runAgent: vi.fn(async () => successResult()), | |
| }); | |
| expect(result).toMatchObject({ | |
| exitCode: 1, | |
| envelope: { | |
| status: "error", | |
| error: { kind: "exception", message: "--code-mode must be one of direct, auto, code." }, | |
| }, | |
| }); | |
| }); | |
| it.each([ | |
| { kind: "exception", status: "error", exitCode: 1, thrown: true }, | |
| { kind: "timeout", status: "timeout", exitCode: 2, thrown: true }, | |
| { kind: "context_overflow", status: "error", exitCode: 1, thrown: false }, | |
| ] as const)("preserves $kind when temporary-state cleanup also fails", async (failure) => { | |
| const { runtime, log, error } = createRuntime(); | |
| let observedStateDir = ""; | |
| vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("cleanup denied")); | |
| const result = await agentExecCommand("inspect", { json: true }, runtime, { | |
| runAgent: async () => { | |
| observedStateDir = process.env.OPENCLAW_STATE_DIR ?? ""; | |
| if (failure.thrown) { | |
| throw Object.assign(new Error("original run failure"), { | |
| name: failure.kind === "timeout" ? "TimeoutError" : "Error", | |
| }); | |
| } | |
| return { | |
| ...successResult("partial answer"), | |
| meta: { durationMs: 25, error: { kind: failure.kind, message: "original run failure" } }, | |
| }; | |
| }, | |
| }); | |
| externalTempDirs.push(observedStateDir); | |
| expect(result).toMatchObject({ | |
| exitCode: failure.exitCode, | |
| envelope: { | |
| status: failure.status, | |
| final: failure.thrown ? "" : "partial answer", | |
| payloads: failure.thrown ? [] : [{ text: "partial answer" }], | |
| error: { kind: failure.kind, message: "original run failure" }, | |
| }, | |
| }); | |
| expect(log).toHaveBeenCalledTimes(1); | |
| expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual(result.envelope); | |
| expect(error).toHaveBeenCalledWith("original run failure"); | |
| expect(error).toHaveBeenCalledWith("Agent exec cleanup failed: cleanup denied"); | |
| }); | |
| it("classifies cleanup failures before emitting the JSON envelope", async () => { | |
| const { runtime, log } = createRuntime(); | |
| let observedStateDir = ""; | |
| vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("cleanup denied")); | |
| const result = await agentExecCommand("inspect", { json: true }, runtime, { | |
| runAgent: vi.fn(async () => { | |
| observedStateDir = process.env.OPENCLAW_STATE_DIR ?? ""; | |
| return successResult(); | |
| }), | |
| }); | |
| externalTempDirs.push(observedStateDir); | |
| expect(result).toMatchObject({ | |
| exitCode: 1, | |
| envelope: { | |
| ok: false, | |
| status: "error", | |
| error: { kind: "exception", message: "Agent exec cleanup failed: cleanup denied" }, | |
| }, | |
| }); | |
| expect(log).toHaveBeenCalledTimes(1); | |
| expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ | |
| ok: false, | |
| status: "error", | |
| error: { message: "Agent exec cleanup failed: cleanup denied" }, | |
| }); | |
| }); | |
| it("threads --cwd and --timeout to the agent", async () => { | |
| const root = tempDirs.make("openclaw-agent-exec-cwd-"); | |
| const { runtime } = createRuntime(); | |
| const runAgent = vi.fn(async () => successResult()); | |
| await agentExecCommand("inspect", { cwd: root, timeout: "7" }, runtime, { runAgent }); | |
| expect(runAgent).toHaveBeenCalledWith( | |
| expect.objectContaining({ workspaceDir: root, cwd: root, timeout: "7" }), | |
| expect.any(Object), | |
| ); | |
| }); | |
| it("emits the small stable JSON envelope", async () => { | |
| const { runtime, log } = createRuntime(); | |
| const result = await agentExecCommand("inspect", { json: true }, runtime, { | |
| runAgent: vi.fn(async () => successResult("final answer")), | |
| }); | |
| expect(result.exitCode).toBe(0); | |
| expect(log).toHaveBeenCalledTimes(1); | |
| expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ | |
| ok: true, | |
| status: "ok", | |
| final: "final answer", | |
| payloads: [{ text: "final answer" }], | |
| usage: { input: 10, output: 2, total: 12 }, | |
| model: "gpt-5.6-sol", | |
| provider: "openai", | |
| sessionId: "session-result", | |
| }); | |
| }); | |
| it("honors ordered fallbacks with an explicit primary model", async () => { | |
| const { runtime } = createRuntime(); | |
| const runAgent = vi.fn(async () => successResult()); | |
| await agentExecCommand( | |
| "inspect", | |
| { | |
| model: "openai/gpt-5.6-sol", | |
| fallback: ["anthropic/claude-sonnet-4-6", "google/gemini-3.1-pro-preview"], | |
| }, | |
| runtime, | |
| { runAgent }, | |
| ); | |
| expect(runAgent).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| model: "openai/gpt-5.6-sol", | |
| modelFallbacksOverride: ["anthropic/claude-sonnet-4-6", "google/gemini-3.1-pro-preview"], | |
| }), | |
| expect.any(Object), | |
| ); | |
| }); | |
| it("undoes environment mutations made by loading the config", async () => { | |
| const seedDir = tempDirs.make("openclaw-agent-exec-envseed-"); | |
| const seedPath = path.join(seedDir, "openclaw.json"); | |
| await fs.writeFile( | |
| seedPath, | |
| JSON.stringify({ env: { vars: { OPENCLAW_EXEC_ENV_PROBE: "from-config" } } }), | |
| "utf8", | |
| ); | |
| const { runtime } = createRuntime(); | |
| let observedDuringRun: string | undefined; | |
| await agentExecCommand("inspect", { config: seedPath }, runtime, { | |
| runAgent: vi.fn(async () => { | |
| observedDuringRun = process.env.OPENCLAW_EXEC_ENV_PROBE; | |
| return successResult(); | |
| }), | |
| }); | |
| expect(observedDuringRun).toBe("from-config"); | |
| // Config-applied values must not outlive the command, or a later isolated | |
| // run in the same process would inherit them. | |
| expect(process.env.OPENCLAW_EXEC_ENV_PROBE).toBeUndefined(); | |
| }); | |
| it("leaves no runtime config snapshot behind when the caller had none", async () => { | |
| clearRuntimeConfigSnapshot(); | |
| const { runtime } = createRuntime(); | |
| await agentExecCommand("inspect", {}, runtime, { | |
| runAgent: vi.fn(async () => successResult()), | |
| }); | |
| // Resolving the ambient config pins a snapshot of its own, so "previous" has | |
| // to be read before that happens or cleanup reinstalls exec's own load. | |
| expect(getRuntimeConfigSnapshot() ?? undefined).toBeUndefined(); | |
| }); | |
| it("restores a caller's runtime config snapshot after the run", async () => { | |
| const callerSnapshot = { | |
| models: { providers: { caller: { baseUrl: "https://caller.invalid", models: [] } } }, | |
| }; | |
| setRuntimeConfigSnapshot(callerSnapshot); | |
| const { runtime } = createRuntime(); | |
| let observedDuringRun: string | undefined; | |
| try { | |
| await agentExecCommand("inspect", {}, runtime, { | |
| runAgent: vi.fn(async () => { | |
| observedDuringRun = getRuntimeConfigSnapshot()?.tools?.profile; | |
| return successResult(); | |
| }), | |
| }); | |
| // The run sees exec's composed config... | |
| expect(observedDuringRun).toBe("coding"); | |
| // ...and the caller gets its own back afterwards. | |
| expect(getRuntimeConfigSnapshot()?.models?.providers?.caller?.baseUrl).toBe( | |
| "https://caller.invalid", | |
| ); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it("publishes no config env values when the config load fails", async () => { | |
| const seedDir = tempDirs.make("openclaw-agent-exec-badenv-"); | |
| const seedPath = path.join(seedDir, "openclaw.json"); | |
| // The loader owns this: it applies `env.vars` only after validation passes, | |
| // and restores them from its own catch. Pinned here because the observable | |
| // contract matters regardless of which layer enforces it. | |
| await fs.writeFile( | |
| seedPath, | |
| JSON.stringify({ | |
| env: { vars: { OPENCLAW_EXEC_FAILED_PROBE: "from-rejected-config" } }, | |
| agents: { defaults: { sandbox: { mode: "not-a-real-mode" } } }, | |
| }), | |
| "utf8", | |
| ); | |
| const { runtime } = createRuntime(); | |
| const result = await agentExecCommand("inspect", { config: seedPath }, runtime, { | |
| runAgent: vi.fn(async () => successResult()), | |
| }); | |
| expect(result.exitCode).not.toBe(0); | |
| expect(process.env.OPENCLAW_EXEC_FAILED_PROBE).toBeUndefined(); | |
| }); | |
| it("leaves an explicit state directory untouched", async () => { | |
| const stateDir = tempDirs.make("openclaw-agent-exec-state-"); | |
| const marker = path.join(stateDir, "keep.txt"); | |
| await fs.writeFile(marker, "keep", "utf8"); | |
| const { runtime } = createRuntime(); | |
| await agentExecCommand("inspect", { stateDir }, runtime, { | |
| runAgent: vi.fn(async () => { | |
| expect(process.env.OPENCLAW_STATE_DIR).toBe(stateDir); | |
| return successResult(); | |
| }), | |
| }); | |
| await expect(fs.readFile(marker, "utf8")).resolves.toBe("keep"); | |
| // The run config inherits the ambient config, so a retained state dir must | |
| // never receive a serialized copy of it. | |
| await expect(fs.readdir(stateDir)).resolves.toEqual(["keep.txt"]); | |
| }); | |
| }); | |
| describe("agent exec run config layering", () => { | |
| it("keeps the run scoped to the invocation folder over any config", () => { | |
| const config = buildExecRunConfig({ | |
| base: { agents: { defaults: { workspace: "/elsewhere", skipBootstrap: false } } }, | |
| cwd: "/run/here", | |
| }); | |
| expect(config.agents?.defaults?.workspace).toBe("/run/here"); | |
| expect(config.agents?.defaults?.skipBootstrap).toBe(true); | |
| expect(config.skills?.load?.watch).toBe(false); | |
| }); | |
| it("never downgrades a configured sandbox or shell env to the exec defaults", () => { | |
| const config = buildExecRunConfig({ | |
| base: { | |
| env: { shellEnv: { enabled: true } }, | |
| agents: { defaults: { sandbox: { mode: "all" } } }, | |
| tools: { profile: "full" }, | |
| }, | |
| cwd: "/run/here", | |
| }); | |
| expect(config.agents?.defaults?.sandbox?.mode).toBe("all"); | |
| expect(config.env?.shellEnv?.enabled).toBe(true); | |
| expect(config.tools?.profile).toBe("full"); | |
| }); | |
| it("applies coding one-shot defaults when the config leaves them unset", () => { | |
| const config = buildExecRunConfig({ base: {}, cwd: "/run/here" }); | |
| expect(config.agents?.defaults?.sandbox?.mode).toBe("off"); | |
| expect(config.env?.shellEnv?.enabled).toBe(false); | |
| expect(config.tools?.profile).toBe("coding"); | |
| expect(config.tools?.fs?.workspaceOnly).toBe(true); | |
| }); | |
| it("leaves exec host routing to the configured sandbox", () => { | |
| const sandboxed = buildExecRunConfig({ | |
| base: { agents: { defaults: { sandbox: { mode: "all" } } } }, | |
| cwd: "/run/here", | |
| }); | |
| expect(sandboxed.agents?.defaults?.sandbox?.mode).toBe("all"); | |
| expect(sandboxed.tools?.exec?.host).toBeUndefined(); | |
| expect(buildExecRunConfig({ base: {}, cwd: "/run/here" }).tools?.exec?.host).toBeUndefined(); | |
| }); | |
| it("carries config-owned provider and harness surfaces into the run", () => { | |
| const config = buildExecRunConfig({ | |
| base: { | |
| models: { providers: { custom: { baseUrl: "https://example.invalid", models: [] } } }, | |
| tools: { codeMode: { enabled: true } }, | |
| }, | |
| cwd: "/run/here", | |
| }); | |
| expect(config.models?.providers?.custom?.baseUrl).toBe("https://example.invalid"); | |
| expect(config.tools?.codeMode).toMatchObject({ enabled: true }); | |
| }); | |
| it("pins per-agent workspaces to the invocation folder", () => { | |
| const config = buildExecRunConfig({ | |
| base: { agents: { entries: { ops: { workspace: "/elsewhere" } } } }, | |
| cwd: "/run/here", | |
| }); | |
| expect(config.agents?.entries?.ops?.workspace).toBe("/run/here"); | |
| }); | |
| it("drops inherited agent directories so run state stays in the state dir", () => { | |
| const config = buildExecRunConfig({ | |
| base: { | |
| agents: { | |
| entries: { ops: { agentDir: "/persistent/agents/ops", model: "openai/gpt-5.6-sol" } }, | |
| }, | |
| }, | |
| cwd: "/run/here", | |
| }); | |
| expect(config.agents?.entries?.ops?.agentDir).toBeUndefined(); | |
| // Only the directory is dropped; the rest of the entry is still inherited. | |
| expect(config.agents?.entries?.ops?.model).toBe("openai/gpt-5.6-sol"); | |
| }); | |
| it("drops an inherited session store so the invocation state dir owns the agent database", () => { | |
| const config = buildExecRunConfig({ | |
| base: { | |
| session: { | |
| store: "/persistent/agents/{agentId}/sessions/sessions.json", | |
| mainKey: "primary", | |
| }, | |
| }, | |
| cwd: "/run/here", | |
| }); | |
| expect(config.session?.store).toBeUndefined(); | |
| expect(config.session?.mainKey).toBe("primary"); | |
| }); | |
| it("drops an inherited harness cwd so --cwd wins", () => { | |
| const config = buildExecRunConfig({ | |
| base: { | |
| agents: { | |
| entries: { | |
| ops: { runtime: { type: "acp", acp: { agent: "codex", cwd: "/other/repo" } } }, | |
| }, | |
| }, | |
| }, | |
| cwd: "/run/here", | |
| }); | |
| const runtime = config.agents?.entries?.ops?.runtime; | |
| expect(runtime?.type === "acp" ? runtime.acp?.cwd : "unset").toBeUndefined(); | |
| // The rest of the harness selection survives. | |
| expect(runtime?.type === "acp" ? runtime.acp?.agent : undefined).toBe("codex"); | |
| }); | |
| it("keeps Code Mode limits while enabling the lean local-model flag", () => { | |
| const config = buildExecRunConfig({ | |
| base: { tools: { codeMode: { enabled: true, maxOutputBytes: 4096 } } }, | |
| cwd: "/run/here", | |
| opts: { localModelLean: true }, | |
| }); | |
| expect(config.tools?.codeMode).toEqual({ enabled: true, maxOutputBytes: 4096 }); | |
| expect(config.agents?.defaults?.experimental?.localModelLean).toBe(true); | |
| }); | |
| }); | |
| describe("agent exec base config resolution", () => { | |
| const seedConfig = { | |
| models: { | |
| providers: { | |
| custom: { | |
| apiKey: "sk-config", | |
| baseUrl: "https://example.invalid", | |
| headers: { Authorization: "Bearer header-secret" }, | |
| request: { auth: { mode: "authorization-bearer", token: "request-secret" } }, | |
| models: [], | |
| }, | |
| }, | |
| }, | |
| } satisfies OpenClawConfig; | |
| async function writeSeed(body: string): Promise<string> { | |
| const dir = tempDirs.make("openclaw-agent-exec-seed-"); | |
| const seedPath = path.join(dir, "openclaw.json"); | |
| await fs.writeFile(seedPath, body, "utf8"); | |
| return seedPath; | |
| } | |
| it("rejects a missing or invalid pinned config instead of falling back", async () => { | |
| const missing = path.join(tempDirs.make("openclaw-agent-exec-seed-"), "absent.json"); | |
| await expect(resolveExecBaseConfig({ config: missing })).rejects.toThrow( | |
| "--config file not found", | |
| ); | |
| const broken = await writeSeed("{ this is not a config"); | |
| await expect(resolveExecBaseConfig({ config: broken })).rejects.toThrow(); | |
| }); | |
| it("reads the pinned file even when a runtime snapshot is already published", async () => { | |
| const seedPath = await writeSeed( | |
| JSON.stringify({ | |
| models: { providers: { custom: { baseUrl: "https://from-file.invalid", models: [] } } }, | |
| }), | |
| ); | |
| setRuntimeConfigSnapshot({ | |
| models: { providers: { custom: { baseUrl: "https://from-snapshot.invalid", models: [] } } }, | |
| }); | |
| try { | |
| const resolved = await resolveExecBaseConfig({ config: seedPath }); | |
| expect(resolved.models?.providers?.custom?.baseUrl).toBe("https://from-file.invalid"); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it("reads --config through the JSON5-aware loader", async () => { | |
| const seedPath = await writeSeed( | |
| `{\n // pinned run config\n models: { providers: { custom: { baseUrl: "https://example.invalid", models: [] } } },\n}\n`, | |
| ); | |
| const resolved = await resolveExecBaseConfig({ config: seedPath }); | |
| expect(resolved.models?.providers?.custom?.baseUrl).toBe("https://example.invalid"); | |
| }); | |
| it("rejects --config paired with a mode that reads no config", async () => { | |
| const seedPath = await writeSeed(JSON.stringify(seedConfig)); | |
| await expect(resolveExecBaseConfig({ config: seedPath, isolated: true })).rejects.toThrow( | |
| "--config cannot be combined with --isolated", | |
| ); | |
| await expect(resolveExecBaseConfig({ config: seedPath, authEnvOnly: true })).rejects.toThrow( | |
| "--config cannot be combined with --auth-env-only", | |
| ); | |
| }); | |
| it("loads no authored config under --auth-env-only", async () => { | |
| const seedPath = await writeSeed(JSON.stringify(seedConfig)); | |
| // A config can supply provider credentials through several surfaces, so | |
| // env-only means no authored config; only the canonical missing-config migration applies. | |
| await expect(resolveExecBaseConfig({ authEnvOnly: true })).resolves.toEqual({ | |
| agents: { entries: { main: {} } }, | |
| }); | |
| // Proves the assertion above is not vacuous. | |
| const inherited = await resolveExecBaseConfig({ config: seedPath }); | |
| expect(inherited.models?.providers?.custom?.apiKey).toBe("sk-config"); | |
| }); | |
| }); | |