From d6f9affe79bcd858b05a9e92655771af6afa8d40 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 15:38:27 -0400 Subject: [PATCH] feat(cli): run agent exec against the ambient config, composed in memory (#116038) * feat(cli): run agent exec against the ambient config, composed in memory Exec previously ignored the operator's config entirely, so a one-shot turn could not reach configured providers, credentials, or agentRuntime harness selection. It now layers config the way other folder-scoped coding CLIs do. The composed config is published as this process's runtime snapshot rather than serialized to a temp file and re-read through OPENCLAW_CONFIG_PATH. The snapshot is the only in-process config cache, so the file only ever fed it -- while writing env-substituted provider keys to disk where the run's own exec tool could read them. * fix(cli): resolve exec stored credentials from the configured agent dir * chore(scripts): allow agent exec the file-scoped config loader at its process boundary * test(cli): cover the exec credential default and pinned-config flags --- docs/cli/agent.md | 18 +- scripts/lib/config-boundary-guard.mjs | 5 + src/cli/program/register.agent-turn.ts | 7 +- src/cli/program/register.agent.test.ts | 25 +- src/commands/agent-exec.test.ts | 370 +++++++++++++++++++++++-- src/commands/agent-exec.ts | 244 +++++++++++++--- src/config/io.ts | 1 + 7 files changed, 608 insertions(+), 62 deletions(-) diff --git a/docs/cli/agent.md b/docs/cli/agent.md index 5cd408229b25..b526d1e0a8f2 100644 --- a/docs/cli/agent.md +++ b/docs/cli/agent.md @@ -24,11 +24,17 @@ openclaw agent exec --message-file task.md --cwd ./repo cat task.md | openclaw agent exec --message-file - --json ``` -By default, the command creates and later removes a temporary state directory. Its implicit config skips workspace bootstrap files, disables the agent sandbox, selects the `coding` tool profile, restricts filesystem tools to `--cwd`, and enables full Gateway-host execution policy for the embedded local tool runtime. `--cwd` defaults to the process working directory and is passed as both the agent workspace and tool working directory. +By default, the command creates and later removes a temporary state directory, and it runs against your ordinary OpenClaw config, so configured providers, credentials, and `agentRuntime` harness selection apply exactly as they do elsewhere. `--cwd` defaults to the process working directory and is passed as both the agent workspace and tool working directory. -Use `--state-dir ` to retain sessions and other run state. The directory must already exist and is never created or deleted by the command. The command still uses its isolated implicit policy config; it does not read the ordinary OpenClaw config from that directory. +Config is layered in three parts, entirely in memory: exec composes the run config and publishes it as this process's runtime config rather than writing a copy to disk. Exec defaults apply only where your config leaves a setting unset: workspace bootstrap files are skipped, the agent sandbox is off, the `coding` tool profile is selected, filesystem tools are restricted to `--cwd`, and exec runs under the full execution policy a headless turn needs. Anything your config sets wins over those defaults, so a configured sandbox, shell env, or tool profile is never downgraded, and exec host routing stays with the sandbox when your config enables one. The invocation itself always wins last: the run is scoped to `--cwd` and never bootstraps. -`--auth-env-only` is enabled by default. In this mode, the run can use provider keys already present in the process environment, but it does not load OpenClaw auth profiles or external Codex, Claude, or other CLI credential stores. Provider auth variables remain available to model authentication but are omitted from agent-launched host commands. Use `--no-auth-env-only` only when the run intentionally relies on those stored credentials. +Use `--state-dir ` to retain sessions and other run state. The directory must already exist and is never created or deleted by the command. + +The state directory is also where installed plugins live, so the default ephemeral one cannot discover plugins you installed with `openclaw plugins install`. If your config selects a provider, channel, or harness from a non-bundled plugin, point the run at your real state directory with `--state-dir ~/.openclaw`. + +For reproducible runs, pin the config instead of inheriting it. `--config ` runs against exactly that config file, read through the normal loader so JSON5 syntax and `$include` resolve relative to it; a missing or invalid file fails the run rather than falling back to defaults, as does an ambient config that exists but cannot be parsed. `--isolated` ignores the ambient config entirely and uses only the exec defaults above. Both are the right choice for CI, where inheriting operator state would make runs machine-dependent. + +Stored credentials are used by default, so a folder-scoped run reaches the same logins as the rest of the CLI. Pass `--auth-env-only` to restrict the run to provider keys already present in the process environment. That mode loads no config at all, and pairing it with `--config` is rejected rather than silently ignored, because a config supplies provider credentials through several surfaces at once: [inline keys and secret headers](/reference/secretref-credential-surface), an `env` block, and login-shell import. It also skips OpenClaw auth profiles and external Codex, Claude, or other CLI credential stores. Provider auth variables remain available to model authentication but are omitted from agent-launched host commands. Select a primary and ordered fallback chain with repeatable flags: @@ -107,13 +113,15 @@ This is evaluation-only evidence, not a CI or release gate. Results do not chang - `--message-file `: read a UTF-8 prompt from a file; `-` reads stdin - `--cwd `: set both the agent workspace and tool working directory - `--state-dir `: use an existing state directory without deleting it +- `--config `: run against this config file instead of the ambient config (JSON5 and `$include` supported) +- `--isolated`: ignore the ambient config and use only exec defaults - `--model `: explicit primary model - `--code-mode `: select `direct`, `auto`, or forced `code` tool mode - `--local-model-lean`: use the reduced local-model tool surface - `--thinking `: one-run thinking level - `--fallback `: ordered fallback model; repeatable and requires `--model` -- `--auth-env-only`: ignore stored and external CLI credentials (default) -- `--no-auth-env-only`: allow stored and external CLI credentials +- `--auth-env-only`: use only environment provider keys; skips stored credentials, external CLI credentials, and config entirely +- `--no-auth-env-only`: allow stored and external CLI credentials (default) - `--timeout `: deadline in seconds (default `600`; `0` disables it) - `--json`: emit the stable JSON envelope diff --git a/scripts/lib/config-boundary-guard.mjs b/scripts/lib/config-boundary-guard.mjs index fed99e8ed35d..73a6de7612a0 100644 --- a/scripts/lib/config-boundary-guard.mjs +++ b/scripts/lib/config-boundary-guard.mjs @@ -35,6 +35,11 @@ const AMBIENT_RUNTIME_LOAD_CONFIG_COMPAT_FILES = new Set([ const PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES = new Set([ "src/cli/banner-config-lite.ts", "src/cli/daemon-cli/status.gather.ts", + // `agent exec --config ` must load one specific file. `getRuntimeConfig()` + // reads the ambient location and resolves from an already published runtime + // snapshot, so it cannot express a pinned run; the file-scoped loader is the + // point. Ambient resolution in the same command does use `getRuntimeConfig()`. + "src/commands/agent-exec.ts", ]); const BROAD_CONFIG_RUNTIME_COMPAT_FILES = new Set([ diff --git a/src/cli/program/register.agent-turn.ts b/src/cli/program/register.agent-turn.ts index 82ecfbc3c2ce..bfef9514084f 100644 --- a/src/cli/program/register.agent-turn.ts +++ b/src/cli/program/register.agent-turn.ts @@ -126,6 +126,11 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age .option("--message-file ", "Read the UTF-8 prompt from a file; use - for stdin") .option("--cwd ", "Set both the agent workspace and tool working directory") .option("--state-dir ", "Use an existing state directory without deleting it") + .option( + "--config ", + "Run against this config file instead of the ambient config (pins a reproducible run)", + ) + .option("--isolated", "Ignore the ambient config and run against exec defaults only", false) .option("--model ", "Use an explicit primary model for this run") .option("--code-mode ", "Tool mode: direct | auto | code") .option("--local-model-lean", "Use the reduced local-model tool surface") @@ -139,7 +144,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age collectFallback, [], ) - .option("--auth-env-only", "Use provider credentials from environment variables only", true) + .option("--auth-env-only", "Use provider credentials from environment variables only", false) .option("--no-auth-env-only", "Allow stored and external CLI credential discovery") .option("--timeout ", "Agent deadline in seconds", "600") .option("--json", "Emit the stable agent-exec JSON envelope", false) diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index 273460c77562..3b7bae9d1a9e 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -213,7 +213,10 @@ describe("agent command registration", () => { codeMode: "code", localModelLean: true, fallback: ["anthropic/claude-sonnet-4-6", "google/gemini-3.1-pro-preview"], - authEnvOnly: true, + // Stored credentials are the default so exec reaches the same logins as + // the rest of the CLI; --auth-env-only is the opt-in restriction. + authEnvOnly: false, + isolated: false, timeout: "600", json: true, }), @@ -221,6 +224,26 @@ describe("agent command registration", () => { ); }); + it("restricts credentials and config to the process environment with --auth-env-only", async () => { + await runCli(["agent", "exec", "fix it", "--auth-env-only"]); + + expect(agentExecCommandMock).toHaveBeenCalledWith( + "fix it", + expect.objectContaining({ authEnvOnly: true }), + runtime, + ); + }); + + it("forwards the pinned-config and isolated run flags", async () => { + await runCli(["agent", "exec", "fix it", "--config", "/tmp/ci.json", "--isolated"]); + + expect(agentExecCommandMock).toHaveBeenCalledWith( + "fix it", + expect.objectContaining({ config: "/tmp/ci.json", isolated: true }), + runtime, + ); + }); + it("accepts parent options before the nested exec command", async () => { await runCli(["agent", "--model", "openai/gpt-5.6-sol", "exec", "fix it", "--json"]); diff --git a/src/commands/agent-exec.test.ts b/src/commands/agent-exec.test.ts index 06aabf83104d..2f96f708767e 100644 --- a/src/commands/agent-exec.test.ts +++ b/src/commands/agent-exec.test.ts @@ -12,8 +12,20 @@ import { loadAuthProfileStoreForRuntime, resolvePersistedAuthProfileOwnerAgentDir, } from "../agents/auth-profiles.js"; +import { + clearRuntimeConfigSnapshot, + getRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "../config/io.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; -import { agentExecCommand, classifyAgentExecResult, resolveAgentExecPrompt } from "./agent-exec.js"; +import { + agentExecCommand, + buildExecRunConfig, + classifyAgentExecResult, + resolveAgentExecPrompt, + resolveExecBaseConfig, +} from "./agent-exec.js"; const tempRoots: string[] = []; const execFileAsync = promisify(execFile); @@ -292,25 +304,30 @@ describe("agent exec command composition", () => { it("creates and removes ephemeral state around the embedded run", async () => { const { runtime } = createRuntime(); let observedStateDir = ""; + let observedConfigPath: string | undefined; let observedConfig: unknown; const result = await agentExecCommand("inspect", {}, runtime, { runAgent: vi.fn(async () => { observedStateDir = process.env.OPENCLAW_STATE_DIR ?? ""; - observedConfig = JSON.parse( - await fs.readFile(process.env.OPENCLAW_CONFIG_PATH ?? "", "utf8"), - ); + 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: { host: "gateway", mode: "full" }, + exec: { mode: "full" }, }, }); await expect(fs.stat(observedStateDir)).rejects.toMatchObject({ code: "ENOENT" }); @@ -326,9 +343,7 @@ describe("agent exec command composition", () => { runtime, { runAgent: vi.fn(async () => { - observedConfig = JSON.parse( - await fs.readFile(process.env.OPENCLAW_CONFIG_PATH ?? "", "utf8"), - ); + observedConfig = getRuntimeConfigSnapshot(); return successResult(); }), }, @@ -443,26 +458,114 @@ describe("agent exec command composition", () => { ); }); - it("keeps an explicit state directory and deletes only its temporary config", async () => { + it("undoes environment mutations made by loading the config", async () => { + const seedDir = await makeTempRoot("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 = await makeTempRoot("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 = await makeTempRoot("openclaw-agent-exec-state-"); const marker = path.join(stateDir, "keep.txt"); await fs.writeFile(marker, "keep", "utf8"); const { runtime } = createRuntime(); - let configPath = ""; await agentExecCommand("inspect", { stateDir }, runtime, { runAgent: vi.fn(async () => { - configPath = process.env.OPENCLAW_CONFIG_PATH ?? ""; expect(process.env.OPENCLAW_STATE_DIR).toBe(stateDir); return successResult(); }), }); await expect(fs.readFile(marker, "utf8")).resolves.toBe("keep"); - await expect(fs.stat(configPath)).rejects.toMatchObject({ code: "ENOENT" }); + // 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"]); }); - it("skips external Codex CLI credentials in default auth-env-only mode", async () => { + it("skips external Codex CLI credentials under --auth-env-only", async () => { const codexHome = await makeTempRoot("openclaw-agent-exec-codex-home-"); await fs.writeFile( path.join(codexHome, "auth.json"), @@ -486,7 +589,7 @@ describe("agent exec command composition", () => { try { const { withHostExecInheritedEnvOmitted } = await import("../infra/host-env-security.js"); await withHostExecInheritedEnvOmitted(["DATABASE_URL"], () => - agentExecCommand("inspect", {}, runtime, { + agentExecCommand("inspect", { authEnvOnly: true }, runtime, { runAgent: vi.fn(async () => { profileIds = Object.keys( ensureAuthProfileStore(undefined, { @@ -532,7 +635,42 @@ describe("agent exec command composition", () => { expect(hostExecDatabaseUrl).toBeUndefined(); }); - it("blocks direct persisted credential reads in default auth-env-only mode", async () => { + it("reads stored credentials from the configured agent directory", async () => { + const stateDir = await makeTempRoot("openclaw-agent-exec-cfg-auth-"); + const customAgentDir = path.join(stateDir, "custom-home"); + await fs.mkdir(customAgentDir, { recursive: true }); + const seedPath = path.join(stateDir, "openclaw.json"); + await fs.writeFile( + seedPath, + JSON.stringify({ + agents: { entries: { main: { agentDir: customAgentDir } } }, + }), + "utf8", + ); + const { saveAuthProfileStore } = await import("../agents/auth-profiles.js"); + saveAuthProfileStore( + { + version: 1, + profiles: { "openai:stored": { type: "api_key", provider: "openai", key: "test-key" } }, + }, + customAgentDir, + ); + const { runtime } = createRuntime(); + let scopedProfileIds: string[] = []; + + await agentExecCommand("inspect", { config: seedPath }, runtime, { + runAgent: vi.fn(async () => { + scopedProfileIds = Object.keys(loadAuthProfileStoreForRuntime()?.profiles ?? {}); + return successResult(); + }), + }); + + // The run config strips agentDir to keep run state ephemeral, but credential + // ownership must still follow the operator's configured directory. + expect(scopedProfileIds).toContain("openai:stored"); + }); + + it("blocks direct persisted credential reads under --auth-env-only", async () => { const normalStateDir = await makeTempRoot("openclaw-agent-exec-hidden-auth-"); const normalAgentDir = path.join(normalStateDir, "agents", "main", "agent"); const previousStateDir = process.env.OPENCLAW_STATE_DIR; @@ -551,7 +689,7 @@ describe("agent exec command composition", () => { let persistedCredential: unknown; let ownerAgentDir: string | undefined; try { - await agentExecCommand("inspect", {}, runtime, { + await agentExecCommand("inspect", { authEnvOnly: true }, runtime, { runAgent: vi.fn(async () => { persistedCredential = findPersistedAuthProfileCredential({ agentDir: normalAgentDir, @@ -617,3 +755,203 @@ describe("agent exec command composition", () => { expect(profileIds).toContain("openai:stored"); }); }); + +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); + }); + + 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 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("lets explicit flags outrank the resolved config", () => { + const config = buildExecRunConfig({ + base: { tools: { codeMode: { enabled: true } } }, + cwd: "/run/here", + opts: { codeMode: "direct", localModelLean: true }, + }); + + expect(config.tools?.codeMode).toBe(false); + 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 { + const dir = await makeTempRoot("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(await makeTempRoot("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("reads no config at all 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 config at all. + await expect(resolveExecBaseConfig({ authEnvOnly: true })).resolves.toEqual({}); + // Proves the assertion above is not vacuous. + const inherited = await resolveExecBaseConfig({ config: seedPath }); + expect(inherited.models?.providers?.custom?.apiKey).toBe("sk-config"); + }); + + it("ignores the ambient config under --isolated", async () => { + await expect(resolveExecBaseConfig({ isolated: true })).resolves.toEqual({}); + }); +}); diff --git a/src/commands/agent-exec.ts b/src/commands/agent-exec.ts index 3ae7d023cc2b..353fe814d5ba 100644 --- a/src/commands/agent-exec.ts +++ b/src/commands/agent-exec.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { createReadStream } from "node:fs"; +import { createReadStream, existsSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -8,6 +8,7 @@ import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-w import { findAgentRunTerminalOutcome } from "../agents/agent-run-terminal-outcome.js"; import type { EmbeddedAgentRunMeta } from "../agents/embedded-agent.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { mergeDeep } from "../infra/deep-merge.js"; import { formatErrorMessage } from "../infra/errors.js"; import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js"; import { writeRuntimeJson, writeRuntimeStdout, type RuntimeEnv } from "../runtime.js"; @@ -20,6 +21,8 @@ export type AgentExecCliOptions = { messageFile?: string; cwd?: string; stateDir?: string; + config?: string; + isolated?: boolean; model?: string; thinking?: string; fallback?: string[]; @@ -273,27 +276,147 @@ function normalizeCodeMode( throw new Error("--code-mode must be one of direct, auto, code."); } -function buildImplicitConfig(cwd: string, opts: AgentExecCliOptions): OpenClawConfig { - const codeMode = normalizeCodeMode(opts.codeMode); +/** + * Facts owned by this invocation rather than by any config, so they win over + * both the ambient config and `--config`: exec is always scoped to the folder + * it was pointed at, a one-shot turn never bootstraps, and explicit flags + * outrank whatever the resolved config says. + */ +/** + * Drops inherited per-agent location overrides, which outrank the facts this + * invocation owns. `agentDir` beats the state dir for session and transcript + * storage, so an ephemeral run would write state into the operator's persistent + * agent directory where deleting the temp state dir cannot reach it; a native + * harness `runtime.acp.cwd` beats `--cwd`, so the turn could edit the wrong + * repository. `agents.bindings[].acp.cwd` needs no equivalent because exec runs + * no channel, so no binding matches. + */ +function stripInheritedAgentLocations(base: OpenClawConfig): OpenClawConfig { + const entries = base.agents?.entries; + if (!entries) { + return base; + } + return { + ...base, + agents: { + ...base.agents, + entries: Object.fromEntries( + Object.entries(entries).map(([id, entry]) => { + const { agentDir: _agentDir, runtime, ...rest } = entry; + if (runtime?.type !== "acp" || runtime.acp?.cwd === undefined) { + return [id, { ...rest, ...(runtime ? { runtime } : {}) }]; + } + const { cwd: _cwd, ...acp } = runtime.acp; + return [id, { ...rest, runtime: { ...runtime, acp } }]; + }), + ), + }, + } as OpenClawConfig; +} + +function buildExecRunOverlay(params: { + base: OpenClawConfig; + cwd: string; + opts: Pick; +}): OpenClawConfig { + const codeMode = normalizeCodeMode(params.opts.codeMode); + // A per-agent `workspace` outranks `agents.defaults`, so pinning only the + // defaults would let an inherited entry silently run the turn against a + // different repository. Override every configured entry as well. + const entries = Object.keys(params.base.agents?.entries ?? {}); return { - env: { shellEnv: { enabled: false } }, agents: { defaults: { - workspace: cwd, + workspace: params.cwd, skipBootstrap: true, - sandbox: { mode: "off" }, - ...(opts.localModelLean ? { experimental: { localModelLean: true } } : {}), + ...(params.opts.localModelLean ? { experimental: { localModelLean: true } } : {}), }, + ...(entries.length > 0 + ? { entries: Object.fromEntries(entries.map((id) => [id, { workspace: params.cwd }])) } + : {}), }, + ...(codeMode !== undefined ? { tools: { codeMode } } : {}), + } as OpenClawConfig; +} + +/** + * Coding one-shot defaults. These merge *under* the resolved config so an + * operator who configured a tool profile, shell env, or sandbox keeps it; + * notably exec must never downgrade a configured sandbox to `off`. + */ +function buildExecConfigDefaults(): OpenClawConfig { + return { + env: { shellEnv: { enabled: false } }, + agents: { defaults: { sandbox: { mode: "off" } } }, tools: { profile: "coding", fs: { workspaceOnly: true }, - exec: { host: "gateway", mode: "full" }, - ...(codeMode !== undefined ? { codeMode } : {}), + // No `exec.host`: the default `auto` already resolves to the gateway when + // no sandbox is configured, and pinning `gateway` here would route + // commands back onto the host for an inherited config that enables one. + // `mode: "full"` stays because a headless one-shot has no approval channel. + exec: { mode: "full" }, }, }; } +/** + * Resolves the config exec runs against. Default is the ambient config, so a + * one-shot turn behaves like other folder-scoped coding CLIs and can reach + * configured providers, credentials, and `agentRuntime` harness choices. + * + * `--auth-env-only` opts out of that inheritance entirely rather than trying to + * launder the resolved config. A config is a credential store by design -- API + * keys, secret headers, request auth, an inline `env` block, and login-shell + * import all feed provider auth -- so the only closed way to promise + * environment-only credentials is to not read it. + */ +export async function resolveExecBaseConfig( + opts: Pick, +): Promise { + // `--isolated` and `--auth-env-only` both mean "read no config", so pairing + // either with `--config` is a contradiction. Failing beats silently ignoring + // the pinned file, which would run a CI invocation on bare exec defaults. + if (opts.config && (opts.isolated || opts.authEnvOnly === true)) { + const conflicting = opts.isolated ? "--isolated" : "--auth-env-only"; + throw new Error(`--config cannot be combined with ${conflicting}.`); + } + if (opts.isolated || opts.authEnvOnly === true) { + return {}; + } + const { createConfigIO, getRuntimeConfig } = await import("../config/io.js"); + if (!opts.config) { + // Ambient means "whatever this process considers effective", so this honors a + // runtime snapshot an in-process caller already published and otherwise loads + // the ordinary config file exactly as any other command does. + return getRuntimeConfig(); + } + // `--config` pins an exact file. The factory loader reads that file directly -- + // unlike the module-level loader it never resolves from a published runtime + // snapshot, so a pinned run cannot be shadowed by one. It throws on a config + // that exists but is invalid, so the run cannot silently degrade to exec + // defaults, and it finalizes the load (config `env` block, shell-env fallback). + const io = createConfigIO({ configPath: path.resolve(opts.config) }); + if (!existsSync(io.configPath)) { + throw new Error(`--config file not found: ${io.configPath}`); + } + return io.loadConfig(); +} + +export function buildExecRunConfig(params: { + base: OpenClawConfig; + cwd: string; + opts?: Pick; +}): OpenClawConfig { + const opts = params.opts ?? {}; + const base = stripInheritedAgentLocations(params.base); + const withDefaults = mergeDeep(buildExecConfigDefaults(), base) as OpenClawConfig; + return mergeDeep( + withDefaults, + buildExecRunOverlay({ base, cwd: params.cwd, opts }), + ) as OpenClawConfig; +} + function normalizeTimeoutSeconds(value: string | undefined): string { const raw = value ?? String(AGENT_EXEC_DEFAULT_TIMEOUT_SECONDS); if (parseStrictNonNegativeInteger(raw) === undefined) { @@ -324,16 +447,15 @@ async function requireDirectory(value: string, label: string): Promise { return resolved; } -function setAgentExecEnvironment(params: { - stateDir: string; - configPath: string; - cwd: string; -}): () => void { +function setAgentExecEnvironment(params: { stateDir: string; cwd: string }): () => void { const previousStateDir = process.env.OPENCLAW_STATE_DIR; + // Repointing the state dir would otherwise make the config resolve relative to + // it (see `resolveConfigDir`), so clear any inherited path override and let the + // published runtime snapshot own config for this run. const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; const previousWorkspaceDir = process.env.OPENCLAW_WORKSPACE_DIR; process.env.OPENCLAW_STATE_DIR = params.stateDir; - process.env.OPENCLAW_CONFIG_PATH = params.configPath; + delete process.env.OPENCLAW_CONFIG_PATH; process.env.OPENCLAW_WORKSPACE_DIR = params.cwd; return () => { if (previousStateDir === undefined) { @@ -422,8 +544,10 @@ export async function agentExecCommand( ): Promise { const sessionId = randomUUID(); let commandResult: AgentExecCommandResult; - let cleanupRoot: string | undefined; + let temporaryStateDir: string | undefined; let restoreEnvironment: (() => void) | undefined; + let restoreConfigEnvironment: (() => void) | undefined; + let restoreRuntimeConfigSnapshot: (() => void) | undefined; let runtimePaths: typeof import("../config/paths.js") | undefined; let configIo: typeof import("../config/io.js") | undefined; try { @@ -436,27 +560,61 @@ export async function agentExecCommand( const stateDir = opts.stateDir ? await requireDirectory(opts.stateDir, "State directory") : await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-exec-")); - cleanupRoot = opts.stateDir - ? await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-exec-config-")) - : stateDir; - const configPath = path.join(cleanupRoot, "openclaw.json"); - await fs.writeFile(configPath, `${JSON.stringify(buildImplicitConfig(cwd, opts), null, 2)}\n`, { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }); + // Only a state dir this command created is removed; `--state-dir` is the + // caller's and is left alone. + temporaryStateDir = opts.stateDir ? undefined : stateDir; + configIo = await import("../config/io.js"); + // Both process globals are captured before the config is resolved: an ambient + // load publishes a runtime snapshot of its own, so reading "previous" after it + // would record exec's snapshot as the caller's. + const previousRuntimeConfigSnapshot = configIo.getRuntimeConfigSnapshot(); + const snapshotIo = configIo; + restoreRuntimeConfigSnapshot = () => { + if (previousRuntimeConfigSnapshot) { + snapshotIo.setRuntimeConfigSnapshot(previousRuntimeConfigSnapshot); + } else { + snapshotIo.clearRuntimeConfigSnapshot(); + } + }; + // Resolve the config before the environment repoints the state dir, so the + // ordinary config location still applies. A successful load finalizes the + // config's `env` block and login-shell import against `process.env`, so undo + // exactly those mutations on the way out: otherwise an in-process caller's + // later isolated run would inherit provider keys from this one. A failed load + // needs no handling here -- the loader applies env vars as its last step and + // restores them from its own catch. + const { restoreEnvChangesIfUnchanged, snapshotEnv } = configIo; + const envBeforeConfigLoad = snapshotEnv(process.env); + const baseConfig = await resolveExecBaseConfig(opts); + const envAfterConfigLoad = snapshotEnv(process.env); + restoreConfigEnvironment = () => + restoreEnvChangesIfUnchanged({ + env: process.env, + before: envBeforeConfigLoad, + after: envAfterConfigLoad, + }); + const runConfig = buildExecRunConfig({ base: baseConfig, cwd, opts }); const timeout = normalizeTimeoutSeconds(opts.timeout); const fallbacks = normalizeFallbacks(opts.model, opts.fallback); const { resolveDefaultAgentDir } = await import("../agents/agent-scope-config.js"); - const storedAuthAgentDir = resolveDefaultAgentDir({}); - restoreEnvironment = setAgentExecEnvironment({ stateDir, configPath, cwd }); - [runtimePaths, configIo] = await Promise.all([ - import("../config/paths.js"), - import("../config/io.js"), - ]); - configIo.clearConfigCache(); - configIo.clearRuntimeConfigSnapshot(); + // Resolve from the inherited config, not `{}`: the default agent may declare + // its own `agentDir`, and that is where its stored auth profiles live. This + // reads `baseConfig` rather than `runConfig` because the run config + // deliberately strips agent directories to keep run state ephemeral, while + // credential ownership must still follow the operator's configuration. + // Computed before the environment repoints the state dir so the unconfigured + // case still resolves against the real one. + const storedAuthAgentDir = resolveDefaultAgentDir(baseConfig); + restoreEnvironment = setAgentExecEnvironment({ stateDir, cwd }); + runtimePaths = await import("../config/paths.js"); runtimePaths.pinRuntimePaths(); + // The runtime snapshot is the only in-process config cache (`clearConfigCache` + // is a no-op shim), so publishing the composed config here is what makes the + // run use it. Serializing it to a temporary file and repointing + // OPENCLAW_CONFIG_PATH would only feed this same snapshot, while writing + // env-substituted provider keys to disk where the run's own exec tool + // could read them. + snapshotIo.setRuntimeConfigSnapshot(runConfig); const [ { withAuthProfileStoreAgentDir, withEnvOnlyAuthProfileStore }, { withHostExecInheritedEnvOmitted }, @@ -500,10 +658,13 @@ export async function agentExecCommand( }, silentRuntime, ); + // Stored credentials are the default so a folder-scoped run reaches the + // same logins as the rest of the CLI; `--auth-env-only` opts back into an + // environment-only scope for automation. const runWithAuthScope = () => - opts.authEnvOnly === false - ? withAuthProfileStoreAgentDir(storedAuthAgentDir, invoke) - : withEnvOnlyAuthProfileStore(invoke); + opts.authEnvOnly === true + ? withEnvOnlyAuthProfileStore(invoke) + : withAuthProfileStoreAgentDir(storedAuthAgentDir, invoke); const result = await withHostExecInheritedEnvOmitted( listKnownProviderAuthEnvVarNames({ env: process.env }), runWithAuthScope, @@ -530,12 +691,17 @@ export async function agentExecCommand( } }; runCleanupStep(() => restoreEnvironment?.()); + runCleanupStep(() => restoreConfigEnvironment?.()); runCleanupStep(() => configIo?.clearConfigCache()); - runCleanupStep(() => configIo?.clearRuntimeConfigSnapshot()); + runCleanupStep(() => + restoreRuntimeConfigSnapshot + ? restoreRuntimeConfigSnapshot() + : configIo?.clearRuntimeConfigSnapshot(), + ); runCleanupStep(() => runtimePaths?.pinRuntimePaths()); - if (cleanupRoot) { + if (temporaryStateDir) { try { - await fs.rm(cleanupRoot, { recursive: true, force: true }); + await fs.rm(temporaryStateDir, { recursive: true, force: true }); } catch (error) { cleanupError ??= error; } diff --git a/src/config/io.ts b/src/config/io.ts index 09e80834e645..9e9a5b217dcf 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -4,6 +4,7 @@ export { parseConfigJson5, resolveConfigSnapshotHash, restoreEnvChangesIfUnchanged, + snapshotEnv, } from "./io.read-helpers.js"; export { clearConfigCache,