From b7615e0ce37f11e5158a1f0837d15335e7230e95 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 1 Mar 2026 20:31:06 -0800 Subject: [PATCH] Exec/ACP: inject OPENCLAW_SHELL into child shell env (#31271) * exec: mark runtime shell context in exec env * tests(exec): cover OPENCLAW_SHELL in gateway exec * tests(exec): cover OPENCLAW_SHELL in pty mode * acpx: mark runtime shell context for spawned process * tests(acpx): log OPENCLAW_SHELL in runtime fixture * tests(acpx): assert OPENCLAW_SHELL in runtime prompt * docs(env): document OPENCLAW_SHELL runtime markers * docs(exec): describe OPENCLAW_SHELL exec marker * docs(acp): document OPENCLAW_SHELL acp marker * docs(gateway): note OPENCLAW_SHELL for background exec * tui: tag local shell runs with OPENCLAW_SHELL * tests(tui): assert OPENCLAW_SHELL in local shell runner * acp client: tag spawned bridge env with OPENCLAW_SHELL * tests(acp): cover acp client OPENCLAW_SHELL env helper * docs(env): include acp-client and tui-local shell markers * docs(acp): document acp-client OPENCLAW_SHELL marker * docs(tui): document tui-local OPENCLAW_SHELL marker * exec: keep shell runtime env string-only for docker args * changelog: note OPENCLAW_SHELL runtime markers --- CHANGELOG.md | 1 + docs/cli/acp.md | 2 + docs/gateway/background-process.md | 1 + docs/help/environment.md | 12 +++++ docs/tools/exec.md | 1 + docs/web/tui.md | 1 + .../acpx/src/runtime-internals/process.ts | 2 +- .../src/runtime-internals/test-fixtures.ts | 10 +++- extensions/acpx/src/runtime.test.ts | 1 + src/acp/client.test.ts | 22 +++++++- src/acp/client.ts | 7 +++ src/agents/bash-tools.exec-runtime.ts | 10 ++-- src/agents/bash-tools.exec.path.test.ts | 14 +++++ src/agents/bash-tools.exec.pty.test.ts | 12 +++++ src/tui/tui-local-shell.test.ts | 54 +++++++++++++++++++ src/tui/tui-local-shell.ts | 2 +- 16 files changed, 145 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b2b22ec3ee..74a8d88c7ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Tools/Diffs: add a new optional `diffs` plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras. - Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc. - ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz. +- Shell env markers: set `OPENCLAW_SHELL` across shell-like runtimes (`exec`, `acp`, `acp-client`, `tui-local`) so shell startup/config rules can target OpenClaw contexts consistently, and document the markers in env/exec/acp/TUI docs. Thanks @vincentkoc. - Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez. - OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control. - Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`. diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 3367173ace0..23c6feabc52 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -179,6 +179,8 @@ Security note: - `--token` and `--password` can be visible in local process listings on some systems. - Prefer `--token-file`/`--password-file` or environment variables (`OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_GATEWAY_PASSWORD`). +- ACP runtime backend child processes receive `OPENCLAW_SHELL=acp`, which can be used for context-specific shell/profile rules. +- `openclaw acp client` sets `OPENCLAW_SHELL=acp-client` on the spawned bridge process. ### `acp client` options diff --git a/docs/gateway/background-process.md b/docs/gateway/background-process.md index 9d745a9e884..f9e328f0386 100644 --- a/docs/gateway/background-process.md +++ b/docs/gateway/background-process.md @@ -28,6 +28,7 @@ Behavior: - When backgrounded (explicit or timeout), the tool returns `status: "running"` + `sessionId` and a short tail. - Output is kept in memory until the session is polled or cleared. - If the `process` tool is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`. +- Spawned exec commands receive `OPENCLAW_SHELL=exec` for context-aware shell/profile rules. ## Child process bridging diff --git a/docs/help/environment.md b/docs/help/environment.md index d261faeaa07..7fa1fdfa6c5 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -56,6 +56,18 @@ Env var equivalents: - `OPENCLAW_LOAD_SHELL_ENV=1` - `OPENCLAW_SHELL_ENV_TIMEOUT_MS=15000` +## Runtime-injected env vars + +OpenClaw also injects context markers into spawned child processes: + +- `OPENCLAW_SHELL=exec`: set for commands run through the `exec` tool. +- `OPENCLAW_SHELL=acp`: set for ACP runtime backend process spawns (for example `acpx`). +- `OPENCLAW_SHELL=acp-client`: set for `openclaw acp client` when it spawns the ACP bridge process. +- `OPENCLAW_SHELL=tui-local`: set for local TUI `!` shell commands. + +These are runtime markers (not required user config). They can be used in shell/profile logic +to apply context-specific rules. + ## Env var substitution in config You can reference env vars directly in config string values using `${VAR_NAME}` syntax: diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 822717fcf38..3a8fc33f45c 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -40,6 +40,7 @@ Notes: then falls back to Windows PowerShell 5.1. - Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to prevent binary hijacking or injected code. +- OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context. - Important: sandboxing is **off by default**. If sandboxing is off and `host=sandbox` is explicitly configured/requested, exec now fails closed instead of silently running on the gateway host. Enable sandboxing or use `host=gateway` with approvals. diff --git a/docs/web/tui.md b/docs/web/tui.md index 8398cedfe1e..1553fd5d668 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -113,6 +113,7 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate - Prefix a line with `!` to run a local shell command on the TUI host. - The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session. - Commands run in a fresh, non-interactive shell in the TUI working directory (no persistent `cd`/env). +- Local shell commands receive `OPENCLAW_SHELL=tui-local` in their environment. - A lone `!` is sent as a normal message; leading spaces do not trigger local exec. ## Tool output diff --git a/extensions/acpx/src/runtime-internals/process.ts b/extensions/acpx/src/runtime-internals/process.ts index 3032393df92..f215aec8b51 100644 --- a/extensions/acpx/src/runtime-internals/process.ts +++ b/extensions/acpx/src/runtime-internals/process.ts @@ -132,7 +132,7 @@ export function spawnWithResolvedCommand( return spawn(resolved.command, resolved.args, { cwd: params.cwd, - env: process.env, + env: { ...process.env, OPENCLAW_SHELL: "acp" }, stdio: ["pipe", "pipe", "pipe"], shell: resolved.shell, windowsHide: resolved.windowsHide, diff --git a/extensions/acpx/src/runtime-internals/test-fixtures.ts b/extensions/acpx/src/runtime-internals/test-fixtures.ts index 4aba03fa09d..dcab6a829f5 100644 --- a/extensions/acpx/src/runtime-internals/test-fixtures.ts +++ b/extensions/acpx/src/runtime-internals/test-fixtures.ts @@ -20,6 +20,7 @@ const fs = require("node:fs"); const args = process.argv.slice(2); const logPath = process.env.MOCK_ACPX_LOG; +const openclawShell = process.env.OPENCLAW_SHELL || ""; const writeLog = (entry) => { if (!logPath) return; fs.appendFileSync(logPath, JSON.stringify(entry) + "\n"); @@ -146,7 +147,14 @@ if (command === "sessions" && args[commandIndex + 1] === "close") { if (command === "prompt") { const stdinText = fs.readFileSync(0, "utf8"); - writeLog({ kind: "prompt", agent, args, sessionName: sessionFromOption, stdinText }); + writeLog({ + kind: "prompt", + agent, + args, + sessionName: sessionFromOption, + stdinText, + openclawShell, + }); const requestId = "req-1"; emitJson({ diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 5af339bf7f8..0c32065004e 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -102,6 +102,7 @@ describe("AcpxRuntime", () => { const prompt = logs.find((entry) => entry.kind === "prompt"); expect(ensure).toBeDefined(); expect(prompt).toBeDefined(); + expect(prompt?.openclawShell).toBe("acp"); expect(Array.isArray(prompt?.args)).toBe(true); const promptArgs = (prompt?.args as string[]) ?? []; expect(promptArgs).toContain("--ttl"); diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index 6721cd4b4e5..ec08fc7d9d2 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -1,6 +1,6 @@ import type { RequestPermissionRequest } from "@agentclientprotocol/sdk"; import { describe, expect, it, vi } from "vitest"; -import { resolvePermissionRequest } from "./client.js"; +import { resolveAcpClientSpawnEnv, resolvePermissionRequest } from "./client.js"; import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js"; function makePermissionRequest( @@ -28,6 +28,26 @@ function makePermissionRequest( }; } +describe("resolveAcpClientSpawnEnv", () => { + it("sets OPENCLAW_SHELL marker and preserves existing env values", () => { + const env = resolveAcpClientSpawnEnv({ + PATH: "/usr/bin", + USER: "openclaw", + }); + + expect(env.OPENCLAW_SHELL).toBe("acp-client"); + expect(env.PATH).toBe("/usr/bin"); + expect(env.USER).toBe("openclaw"); + }); + + it("overrides pre-existing OPENCLAW_SHELL to acp-client", () => { + const env = resolveAcpClientSpawnEnv({ + OPENCLAW_SHELL: "wrong", + }); + expect(env.OPENCLAW_SHELL).toBe("acp-client"); + }); +}); + describe("resolvePermissionRequest", () => { it("auto-approves safe tools without prompting", async () => { const prompt = vi.fn(async () => true); diff --git a/src/acp/client.ts b/src/acp/client.ts index d9b87599ddd..a716c4d5469 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -342,6 +342,12 @@ function buildServerArgs(opts: AcpClientOptions): string[] { return args; } +export function resolveAcpClientSpawnEnv( + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return { ...baseEnv, OPENCLAW_SHELL: "acp-client" }; +} + function resolveSelfEntryPath(): string | null { // Prefer a path relative to the built module location (dist/acp/client.js -> dist/entry.js). try { @@ -413,6 +419,7 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise = { + ...opts.env, + OPENCLAW_SHELL: "exec", + }; const session: ProcessSession = { id: sessionId, @@ -385,7 +389,7 @@ export async function runExecProcess(opts: { containerName: opts.sandbox.containerName, command: execCommand, workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, - env: opts.env, + env: shellRuntimeEnv, tty: opts.usePty, }), ], @@ -400,14 +404,14 @@ export async function runExecProcess(opts: { mode: "pty" as const, ptyCommand: execCommand, childFallbackArgv: childArgv, - env: opts.env, + env: shellRuntimeEnv, stdinMode: "pipe-open" as const, }; } return { mode: "child" as const, argv: childArgv, - env: opts.env, + env: shellRuntimeEnv, stdinMode: "pipe-closed" as const, }; })(); diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 041ee86723e..766bfe22107 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -95,6 +95,20 @@ describe("exec PATH login shell merge", () => { expect(shellPathMock).toHaveBeenCalledTimes(1); }); + it("sets OPENCLAW_SHELL for host=gateway commands", async () => { + if (isWin) { + return; + } + + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + const result = await tool.execute("call-openclaw-shell", { + command: 'printf "%s" "${OPENCLAW_SHELL:-}"', + }); + const value = normalizeText(result.content.find((c) => c.type === "text")?.text); + + expect(value).toBe("exec"); + }); + it("throws security violation when env.PATH is provided", async () => { if (isWin) { return; diff --git a/src/agents/bash-tools.exec.pty.test.ts b/src/agents/bash-tools.exec.pty.test.ts index 10de0bfdb99..10185f57282 100644 --- a/src/agents/bash-tools.exec.pty.test.ts +++ b/src/agents/bash-tools.exec.pty.test.ts @@ -17,3 +17,15 @@ test("exec supports pty output", async () => { const text = result.content?.find((item) => item.type === "text")?.text ?? ""; expect(text).toContain("ok"); }); + +test("exec sets OPENCLAW_SHELL in pty mode", async () => { + const tool = createExecTool({ allowBackground: false, security: "full", ask: "off" }); + const result = await tool.execute("toolcall-openclaw-shell", { + command: "node -e \"process.stdout.write(process.env.OPENCLAW_SHELL || '')\"", + pty: true, + }); + + expect(result.details.status).toBe("completed"); + const text = result.content?.find((item) => item.type === "text")?.text ?? ""; + expect(text).toContain("exec"); +}); diff --git a/src/tui/tui-local-shell.test.ts b/src/tui/tui-local-shell.test.ts index 5b8ff0d08a7..0c8f324c3b3 100644 --- a/src/tui/tui-local-shell.test.ts +++ b/src/tui/tui-local-shell.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; import { createLocalShellRunner } from "./tui-local-shell.js"; @@ -51,4 +52,57 @@ describe("createLocalShellRunner", () => { expect(createSelectorSpy).toHaveBeenCalledTimes(1); expect(spawnCommand).not.toHaveBeenCalled(); }); + + it("sets OPENCLAW_SHELL when running local shell commands", async () => { + const messages: string[] = []; + const chatLog = { + addSystem: (line: string) => { + messages.push(line); + }, + }; + const tui = { requestRender: vi.fn() }; + const openOverlay = vi.fn(); + const closeOverlay = vi.fn(); + let lastSelector: ReturnType | null = null; + const createSelectorSpy = vi.fn(() => { + lastSelector = createSelector(); + return lastSelector; + }); + const spawnCommand = vi.fn((_command: string, _options: unknown) => { + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + return { + stdout, + stderr, + on: (event: string, callback: (...args: unknown[]) => void) => { + if (event === "close") { + setImmediate(() => callback(0, null)); + } + }, + }; + }); + + const { runLocalShellLine } = createLocalShellRunner({ + chatLog, + tui, + openOverlay, + closeOverlay, + createSelector: createSelectorSpy, + spawnCommand: spawnCommand as unknown as typeof import("node:child_process").spawn, + env: { PATH: "/tmp/bin", USER: "dev" }, + }); + + const firstRun = runLocalShellLine("!echo hi"); + expect(openOverlay).toHaveBeenCalledTimes(1); + const selector = lastSelector as ReturnType | null; + selector?.onSelect?.({ value: "yes", label: "Yes" }); + await firstRun; + + expect(createSelectorSpy).toHaveBeenCalledTimes(1); + expect(spawnCommand).toHaveBeenCalledTimes(1); + const spawnOptions = spawnCommand.mock.calls[0]?.[1] as { env?: Record }; + expect(spawnOptions.env?.OPENCLAW_SHELL).toBe("tui-local"); + expect(spawnOptions.env?.PATH).toBe("/tmp/bin"); + expect(messages).toContain("local shell: enabled for this session"); + }); }); diff --git a/src/tui/tui-local-shell.ts b/src/tui/tui-local-shell.ts index 94c850ca9e3..defea7397f8 100644 --- a/src/tui/tui-local-shell.ts +++ b/src/tui/tui-local-shell.ts @@ -111,7 +111,7 @@ export function createLocalShellRunner(deps: LocalShellDeps) { // and is gated behind an explicit in-session approval prompt. shell: true, cwd: getCwd(), - env, + env: { ...env, OPENCLAW_SHELL: "tui-local" }, }); let stdout = "";