mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
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
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AcpC
|
||||
const agent = spawn(serverCommand, effectiveArgs, {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
cwd,
|
||||
env: resolveAcpClientSpawnEnv(),
|
||||
});
|
||||
|
||||
if (!agent.stdin || !agent.stdout) {
|
||||
|
||||
@@ -291,6 +291,10 @@ export async function runExecProcess(opts: {
|
||||
const sessionId = createSessionSlug();
|
||||
const execCommand = opts.execCommand ?? opts.command;
|
||||
const supervisor = getProcessSupervisor();
|
||||
const shellRuntimeEnv: Record<string, string> = {
|
||||
...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,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<typeof createSelector> | 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<typeof createSelector> | 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<string, string> };
|
||||
expect(spawnOptions.env?.OPENCLAW_SHELL).toBe("tui-local");
|
||||
expect(spawnOptions.env?.PATH).toBe("/tmp/bin");
|
||||
expect(messages).toContain("local shell: enabled for this session");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user