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:
Vincent Koc
2026-03-01 20:31:06 -08:00
committed by GitHub
parent aeb817353f
commit b7615e0ce3
16 changed files with 145 additions and 7 deletions

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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,
};
})();

View File

@@ -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;

View File

@@ -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");
});

View File

@@ -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");
});
});

View File

@@ -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 = "";