mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat: default exec shell snapshots
This commit is contained in:
@@ -106,11 +106,10 @@ Env var equivalents:
|
||||
|
||||
## Exec shell snapshots
|
||||
|
||||
`OPENCLAW_EXEC_SHELL_SNAPSHOT=1` is an opt-in gateway exec optimization for bash and zsh. Set it in the Gateway
|
||||
process environment; per-call `exec.env` values cannot enable it or redirect its cache directory. When enabled,
|
||||
OpenClaw captures sourceable aliases/functions and a small safe environment set from shell startup files into
|
||||
`$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources that snapshot before each `exec` command. Secret-looking
|
||||
variables are excluded from the snapshot. Sandbox and node exec do not use it.
|
||||
On non-Windows Gateway hosts, bash and zsh `exec` commands use a startup snapshot by default.
|
||||
Set `OPENCLAW_EXEC_SHELL_SNAPSHOT=0` in the Gateway process environment to disable this path.
|
||||
Values `false`, `no`, and `off` also disable it. Per-call `exec.env` values cannot toggle
|
||||
snapshots or redirect the snapshot cache.
|
||||
|
||||
## Runtime-injected env vars
|
||||
|
||||
|
||||
@@ -80,11 +80,11 @@ Notes:
|
||||
from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists.
|
||||
- On Windows hosts, exec prefers PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, then PATH),
|
||||
then falls back to Windows PowerShell 5.1.
|
||||
- On non-Windows gateway hosts, setting `OPENCLAW_EXEC_SHELL_SNAPSHOT=1` in the Gateway process environment
|
||||
enables an opt-in startup snapshot for bash and zsh. OpenClaw captures sourceable aliases/functions and a small
|
||||
safe environment set from shell startup files into `$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources
|
||||
that snapshot before each exec command. Secret-looking variables are excluded; sandbox and node exec do not use
|
||||
this snapshot.
|
||||
- On non-Windows gateway hosts, bash and zsh exec commands use a startup snapshot. OpenClaw captures sourceable
|
||||
aliases/functions and a small safe environment set from shell startup files into
|
||||
`$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources that snapshot before each exec command.
|
||||
Secret-looking variables are excluded; sandbox and node exec do not use this snapshot. Set
|
||||
`OPENCLAW_EXEC_SHELL_SNAPSHOT=0` in the Gateway process environment to disable this snapshot path.
|
||||
- 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.
|
||||
|
||||
@@ -829,7 +829,6 @@ export async function runExecProcess(opts: {
|
||||
shellArgs,
|
||||
cwd: opts.workdir,
|
||||
env: shellRuntimeEnv,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const childArgv = [shell, ...shellArgs, commandWithShellSnapshot];
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
EXEC_SHELL_SNAPSHOT_ENV,
|
||||
maybeWrapCommandWithShellSnapshot,
|
||||
resetShellSnapshotCacheForTests,
|
||||
resolveShellSnapshotDir,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
import { getPosixShellArgs, resolveShellFromPath } from "./shell-utils.js";
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const EXEC_SHELL_SNAPSHOT_ENV = "OPENCLAW_EXEC_SHELL_SNAPSHOT";
|
||||
|
||||
function resolveBashForTest(): string | null {
|
||||
if (isWin) {
|
||||
@@ -34,9 +34,19 @@ function resolveZshForTest(): string | null {
|
||||
return resolveShellFromPath("zsh") ?? null;
|
||||
}
|
||||
|
||||
function enableSnapshotForTest(stateDir: string): void {
|
||||
function setSnapshotStateForTest(
|
||||
stateDir: string,
|
||||
options: { home?: string; zdotdir?: string } = {},
|
||||
): void {
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
process.env[EXEC_SHELL_SNAPSHOT_ENV] = "1";
|
||||
if (options.home) {
|
||||
process.env.HOME = options.home;
|
||||
}
|
||||
if (options.zdotdir) {
|
||||
process.env.ZDOTDIR = options.zdotdir;
|
||||
} else {
|
||||
delete process.env.ZDOTDIR;
|
||||
}
|
||||
}
|
||||
|
||||
describe("exec shell snapshots", () => {
|
||||
@@ -44,7 +54,13 @@ describe("exec shell snapshots", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
|
||||
beforeEach(() => {
|
||||
envSnapshot = captureEnv(["HOME", "OPENCLAW_STATE_DIR", EXEC_SHELL_SNAPSHOT_ENV]);
|
||||
envSnapshot = captureEnv([
|
||||
"HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_EXEC_SHELL_SNAPSHOT",
|
||||
"PNPM_HOME",
|
||||
"ZDOTDIR",
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -55,40 +71,82 @@ describe("exec shell snapshots", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves commands unchanged unless explicitly enabled", async () => {
|
||||
it("leaves commands unchanged for unsupported shells", async () => {
|
||||
const command = "echo unchanged";
|
||||
const wrapped = await maybeWrapCommandWithShellSnapshot({
|
||||
command,
|
||||
shell: "/bin/bash",
|
||||
shellArgs: ["--noprofile", "--norc", "-c"],
|
||||
shell: "/bin/fish",
|
||||
shellArgs: ["-c"],
|
||||
cwd: os.tmpdir(),
|
||||
env: {},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(wrapped).toBe(command);
|
||||
});
|
||||
|
||||
it("does not honor per-call env for enabling snapshots or selecting state dir", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-untrusted-state-"));
|
||||
tempDirs.push(stateDir);
|
||||
it("leaves commands unchanged when trusted process env disables snapshots", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-disabled-state-"));
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-disabled-home-"));
|
||||
tempDirs.push(stateDir, home);
|
||||
setSnapshotStateForTest(stateDir, { home });
|
||||
process.env[EXEC_SHELL_SNAPSHOT_ENV] = "0";
|
||||
const command = "echo unchanged";
|
||||
const wrapped = await maybeWrapCommandWithShellSnapshot({
|
||||
command,
|
||||
shell: "/bin/bash",
|
||||
shellArgs: ["--noprofile", "--norc", "-c"],
|
||||
shellArgs: ["-c"],
|
||||
cwd: os.tmpdir(),
|
||||
env: {
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
[EXEC_SHELL_SNAPSHOT_ENV]: "1",
|
||||
...process.env,
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(wrapped).toBe(command);
|
||||
expect(fs.existsSync(resolveShellSnapshotDir({ OPENCLAW_STATE_DIR: stateDir }))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not honor per-call env for selecting the snapshot state dir", async () => {
|
||||
const trustedStateDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "openclaw-snapshot-trusted-state-"),
|
||||
);
|
||||
const untrustedStateDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "openclaw-snapshot-untrusted-state-"),
|
||||
);
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-state-home-"));
|
||||
const untrustedHome = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "openclaw-snapshot-untrusted-home-"),
|
||||
);
|
||||
const sideEffectPath = path.join(untrustedHome, "side-effect");
|
||||
tempDirs.push(trustedStateDir, untrustedStateDir, home, untrustedHome);
|
||||
setSnapshotStateForTest(trustedStateDir, { home });
|
||||
fs.writeFileSync(
|
||||
path.join(untrustedHome, ".bashrc"),
|
||||
`touch ${JSON.stringify(sideEffectPath)}\n`,
|
||||
);
|
||||
const command = "echo unchanged";
|
||||
const wrapped = await maybeWrapCommandWithShellSnapshot({
|
||||
command,
|
||||
shell: "/bin/bash",
|
||||
shellArgs: ["-c"],
|
||||
cwd: os.tmpdir(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: untrustedHome,
|
||||
[EXEC_SHELL_SNAPSHOT_ENV]: "0",
|
||||
OPENCLAW_STATE_DIR: untrustedStateDir,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapped).not.toBe(command);
|
||||
expect(fs.existsSync(resolveShellSnapshotDir({ OPENCLAW_STATE_DIR: untrustedStateDir }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(fs.existsSync(resolveShellSnapshotDir({ OPENCLAW_STATE_DIR: trustedStateDir }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(sideEffectPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("captures bash startup aliases, functions, and safe environment without secrets", async () => {
|
||||
const bash = resolveBashForTest();
|
||||
if (!bash) {
|
||||
@@ -99,12 +157,13 @@ describe("exec shell snapshots", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-state-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-cwd-"));
|
||||
tempDirs.push(home, stateDir, cwd);
|
||||
enableSnapshotForTest(stateDir);
|
||||
setSnapshotStateForTest(stateDir, { home });
|
||||
fs.writeFileSync(
|
||||
path.join(home, ".bashrc"),
|
||||
[
|
||||
"alias oc_snap_alias='printf alias-ok'",
|
||||
'alias oc_snap_secret="printf $OPENAI_API_KEY"',
|
||||
'[ "$OPENCLAW_SHELL" = exec ] && alias oc_snap_exec_alias="printf marker-ok"',
|
||||
"oc_snap_fn() { printf fn-ok; }",
|
||||
'export PATH="/snapshot/bin:$PATH"',
|
||||
'export OPENAI_API_KEY="snapshot-secret"',
|
||||
@@ -116,18 +175,17 @@ describe("exec shell snapshots", () => {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_SHELL: "exec",
|
||||
OPENAI_API_KEY: "inherited-secret",
|
||||
[EXEC_SHELL_SNAPSHOT_ENV]: "1",
|
||||
};
|
||||
const shellArgs = getPosixShellArgs(bash);
|
||||
const wrapped = await maybeWrapCommandWithShellSnapshot({
|
||||
command:
|
||||
"oc_snap_fn; printf ' '; oc_snap_alias; printf ' '; case \":$PATH:\" in *:/snapshot/bin:*) printf path-ok;; *) printf path-missing;; esac",
|
||||
"oc_snap_fn; printf ' '; oc_snap_alias; printf ' '; oc_snap_exec_alias; printf ' '; case \":$PATH:\" in *:/snapshot/bin:*) printf path-ok;; *) printf path-missing;; esac",
|
||||
shell: bash,
|
||||
shellArgs,
|
||||
cwd,
|
||||
env,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const result = spawnSync(bash, [...shellArgs, wrapped], {
|
||||
@@ -138,7 +196,7 @@ describe("exec shell snapshots", () => {
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe("fn-ok alias-ok path-ok");
|
||||
expect(result.stdout).toBe("fn-ok alias-ok marker-ok path-ok");
|
||||
|
||||
const snapshotFiles = fs
|
||||
.readdirSync(resolveShellSnapshotDir(env))
|
||||
@@ -165,7 +223,7 @@ describe("exec shell snapshots", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-interactive-state-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-interactive-cwd-"));
|
||||
tempDirs.push(home, stateDir, cwd);
|
||||
enableSnapshotForTest(stateDir);
|
||||
setSnapshotStateForTest(stateDir, { home });
|
||||
fs.writeFileSync(
|
||||
path.join(home, ".bashrc"),
|
||||
[
|
||||
@@ -181,7 +239,6 @@ describe("exec shell snapshots", () => {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
[EXEC_SHELL_SNAPSHOT_ENV]: "1",
|
||||
};
|
||||
const shellArgs = getPosixShellArgs(bash);
|
||||
const wrapped = await maybeWrapCommandWithShellSnapshot({
|
||||
@@ -190,7 +247,6 @@ describe("exec shell snapshots", () => {
|
||||
shellArgs,
|
||||
cwd,
|
||||
env,
|
||||
enabled: true,
|
||||
});
|
||||
const result = spawnSync(bash, [...shellArgs, wrapped], {
|
||||
cwd,
|
||||
@@ -203,7 +259,7 @@ describe("exec shell snapshots", () => {
|
||||
expect(result.stdout).toBe("interactive-ok");
|
||||
});
|
||||
|
||||
it("does not reuse captured safe env exports across different exec environments", async () => {
|
||||
it("preserves per-call safe env overrides after trusted capture", async () => {
|
||||
const bash = resolveBashForTest();
|
||||
if (!bash) {
|
||||
return;
|
||||
@@ -213,7 +269,8 @@ describe("exec shell snapshots", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-env-state-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-env-cwd-"));
|
||||
tempDirs.push(home, stateDir, cwd);
|
||||
enableSnapshotForTest(stateDir);
|
||||
setSnapshotStateForTest(stateDir, { home });
|
||||
process.env.PNPM_HOME = "/trusted";
|
||||
fs.writeFileSync(path.join(home, ".bashrc"), 'export PNPM_HOME="${PNPM_HOME}/from-rc"\n');
|
||||
|
||||
const shellArgs = getPosixShellArgs(bash);
|
||||
@@ -223,7 +280,6 @@ describe("exec shell snapshots", () => {
|
||||
HOME: home,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
PNPM_HOME: pnpmHome,
|
||||
[EXEC_SHELL_SNAPSHOT_ENV]: "1",
|
||||
};
|
||||
const wrapped = await maybeWrapCommandWithShellSnapshot({
|
||||
command: 'printf "%s" "$PNPM_HOME"',
|
||||
@@ -231,7 +287,6 @@ describe("exec shell snapshots", () => {
|
||||
shellArgs,
|
||||
cwd,
|
||||
env,
|
||||
enabled: true,
|
||||
});
|
||||
const result = spawnSync(bash, [...shellArgs, wrapped], {
|
||||
cwd,
|
||||
@@ -243,8 +298,8 @@ describe("exec shell snapshots", () => {
|
||||
return result.stdout;
|
||||
};
|
||||
|
||||
await expect(runWithPnpmHome("/first")).resolves.toBe("/first/from-rc");
|
||||
await expect(runWithPnpmHome("/second")).resolves.toBe("/second/from-rc");
|
||||
await expect(runWithPnpmHome("/first")).resolves.toBe("/first");
|
||||
await expect(runWithPnpmHome("/second")).resolves.toBe("/second");
|
||||
});
|
||||
|
||||
it("does not let non-fingerprinted env change captured shell state", async () => {
|
||||
@@ -257,7 +312,7 @@ describe("exec shell snapshots", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-branch-state-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-branch-cwd-"));
|
||||
tempDirs.push(home, stateDir, cwd);
|
||||
enableSnapshotForTest(stateDir);
|
||||
setSnapshotStateForTest(stateDir, { home });
|
||||
fs.writeFileSync(
|
||||
path.join(home, ".bashrc"),
|
||||
[
|
||||
@@ -275,7 +330,6 @@ describe("exec shell snapshots", () => {
|
||||
HOME: home,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
VIRTUAL_ENV: "/tmp/venv",
|
||||
[EXEC_SHELL_SNAPSHOT_ENV]: "1",
|
||||
};
|
||||
const shellArgs = getPosixShellArgs(bash);
|
||||
const wrapped = await maybeWrapCommandWithShellSnapshot({
|
||||
@@ -284,7 +338,6 @@ describe("exec shell snapshots", () => {
|
||||
shellArgs,
|
||||
cwd,
|
||||
env,
|
||||
enabled: true,
|
||||
});
|
||||
const result = spawnSync(bash, [...shellArgs, wrapped], {
|
||||
cwd,
|
||||
@@ -317,7 +370,7 @@ describe("exec shell snapshots", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-refresh-state-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-refresh-cwd-"));
|
||||
tempDirs.push(home, stateDir, cwd);
|
||||
enableSnapshotForTest(stateDir);
|
||||
setSnapshotStateForTest(stateDir, { home });
|
||||
const aliasPath = path.join(home, ".bash_aliases");
|
||||
fs.writeFileSync(path.join(home, ".bashrc"), `. ${JSON.stringify(aliasPath)}\n`);
|
||||
fs.writeFileSync(aliasPath, "alias oc_refresh_alias='printf old'\n");
|
||||
@@ -325,7 +378,6 @@ describe("exec shell snapshots", () => {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
[EXEC_SHELL_SNAPSHOT_ENV]: "1",
|
||||
};
|
||||
const shellArgs = getPosixShellArgs(bash);
|
||||
const runAlias = async (): Promise<string> => {
|
||||
@@ -335,7 +387,6 @@ describe("exec shell snapshots", () => {
|
||||
shellArgs,
|
||||
cwd,
|
||||
env,
|
||||
enabled: true,
|
||||
});
|
||||
const result = spawnSync(bash, [...shellArgs, wrapped], {
|
||||
cwd,
|
||||
@@ -369,7 +420,7 @@ describe("exec shell snapshots", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-secret-state-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-secret-cwd-"));
|
||||
tempDirs.push(home, stateDir, cwd);
|
||||
enableSnapshotForTest(stateDir);
|
||||
setSnapshotStateForTest(stateDir, { home });
|
||||
fs.writeFileSync(
|
||||
path.join(home, ".bashrc"),
|
||||
[
|
||||
@@ -383,7 +434,6 @@ describe("exec shell snapshots", () => {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
[EXEC_SHELL_SNAPSHOT_ENV]: "1",
|
||||
};
|
||||
const command = "echo fallback";
|
||||
const wrapped = await maybeWrapCommandWithShellSnapshot({
|
||||
@@ -392,7 +442,6 @@ describe("exec shell snapshots", () => {
|
||||
shellArgs: getPosixShellArgs(bash),
|
||||
cwd,
|
||||
env,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(wrapped).toBe(command);
|
||||
@@ -413,7 +462,7 @@ describe("exec shell snapshots", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-zsh-state-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-zsh-cwd-"));
|
||||
tempDirs.push(home, stateDir, cwd);
|
||||
enableSnapshotForTest(stateDir);
|
||||
setSnapshotStateForTest(stateDir, { home });
|
||||
fs.writeFileSync(
|
||||
path.join(home, ".zshrc"),
|
||||
[
|
||||
@@ -427,7 +476,6 @@ describe("exec shell snapshots", () => {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
[EXEC_SHELL_SNAPSHOT_ENV]: "1",
|
||||
};
|
||||
const shellArgs = getPosixShellArgs(zsh);
|
||||
const wrapped = await maybeWrapCommandWithShellSnapshot({
|
||||
@@ -436,7 +484,6 @@ describe("exec shell snapshots", () => {
|
||||
shellArgs,
|
||||
cwd,
|
||||
env,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const result = spawnSync(zsh, [...shellArgs, wrapped], {
|
||||
@@ -461,7 +508,7 @@ describe("exec shell snapshots", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-zdot-state-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-zdot-cwd-"));
|
||||
tempDirs.push(home, zdotdir, stateDir, cwd);
|
||||
enableSnapshotForTest(stateDir);
|
||||
setSnapshotStateForTest(stateDir, { home, zdotdir });
|
||||
fs.writeFileSync(path.join(home, ".zshrc"), "alias oc_snap_zdot_alias='printf wrong-home'\n");
|
||||
fs.writeFileSync(
|
||||
path.join(zdotdir, ".zshrc"),
|
||||
@@ -475,7 +522,6 @@ describe("exec shell snapshots", () => {
|
||||
HOME: home,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
ZDOTDIR: zdotdir,
|
||||
[EXEC_SHELL_SNAPSHOT_ENV]: "1",
|
||||
};
|
||||
const shellArgs = getPosixShellArgs(zsh);
|
||||
const wrapped = await maybeWrapCommandWithShellSnapshot({
|
||||
@@ -484,7 +530,6 @@ describe("exec shell snapshots", () => {
|
||||
shellArgs,
|
||||
cwd,
|
||||
env,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const result = spawnSync(zsh, [...shellArgs, wrapped], {
|
||||
|
||||
@@ -7,15 +7,15 @@ import path from "node:path";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { killProcessTree } from "../process/kill-tree.js";
|
||||
|
||||
export const EXEC_SHELL_SNAPSHOT_ENV = "OPENCLAW_EXEC_SHELL_SNAPSHOT";
|
||||
|
||||
const SNAPSHOT_VERSION = 1;
|
||||
const SNAPSHOT_REFRESH_MS = 5 * 60 * 1000;
|
||||
const SNAPSHOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
const CAPTURE_MARKER = "__OPENCLAW_SHELL_SNAPSHOT_CAPTURE__";
|
||||
const ENV_MARKER = "__OPENCLAW_SHELL_SNAPSHOT_ENV__";
|
||||
const EXEC_SHELL_SNAPSHOT_ENV = "OPENCLAW_EXEC_SHELL_SNAPSHOT";
|
||||
const VALID_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const SNAPSHOT_SHELLS = new Set(["bash", "zsh"]);
|
||||
const SNAPSHOT_DISABLE_VALUES = new Set(["0", "false", "no", "off"]);
|
||||
const SAFE_ENV_NAMES = new Set([
|
||||
"ASDF_DIR",
|
||||
"BUN_INSTALL",
|
||||
@@ -64,7 +64,6 @@ export type ShellSnapshotWrapOptions = {
|
||||
shellArgs: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string | undefined>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const snapshotCache = new Map<
|
||||
@@ -73,24 +72,26 @@ const snapshotCache = new Map<
|
||||
>();
|
||||
let cleanupPromise: Promise<void> | null = null;
|
||||
|
||||
export function shouldEnableExecShellSnapshot(env: Record<string, string | undefined>): boolean {
|
||||
const value = env[EXEC_SHELL_SNAPSHOT_ENV]?.trim().toLowerCase();
|
||||
return value === "1" || value === "true" || value === "yes" || value === "on";
|
||||
}
|
||||
|
||||
export async function maybeWrapCommandWithShellSnapshot(
|
||||
opts: ShellSnapshotWrapOptions,
|
||||
): Promise<string> {
|
||||
if (!opts.enabled || !shouldEnableExecShellSnapshot(process.env)) {
|
||||
return opts.command;
|
||||
}
|
||||
if (process.platform === "win32" || !isSupportedSnapshotShell(opts.shell, opts.shellArgs)) {
|
||||
if (
|
||||
process.platform === "win32" ||
|
||||
isExecShellSnapshotDisabled(process.env) ||
|
||||
!isSupportedSnapshotShell(opts.shell, opts.shellArgs)
|
||||
) {
|
||||
return opts.command;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await getOrCreateShellSnapshot(opts);
|
||||
return snapshot ? buildSnapshotWrappedCommand(opts.command, snapshot.path) : opts.command;
|
||||
return snapshot
|
||||
? buildSnapshotWrappedCommand(
|
||||
opts.command,
|
||||
snapshot.path,
|
||||
buildRuntimeEnvRestoreScript(opts.env),
|
||||
)
|
||||
: opts.command;
|
||||
} catch {
|
||||
return opts.command;
|
||||
}
|
||||
@@ -111,6 +112,11 @@ function isSupportedSnapshotShell(shell: string, shellArgs: string[]): boolean {
|
||||
return shellArgs.includes("-c") && SNAPSHOT_SHELLS.has(path.basename(shell));
|
||||
}
|
||||
|
||||
function isExecShellSnapshotDisabled(env: Record<string, string | undefined>): boolean {
|
||||
const value = env[EXEC_SHELL_SNAPSHOT_ENV]?.trim().toLowerCase();
|
||||
return Boolean(value && SNAPSHOT_DISABLE_VALUES.has(value));
|
||||
}
|
||||
|
||||
async function getOrCreateShellSnapshot(
|
||||
opts: ShellSnapshotWrapOptions,
|
||||
): Promise<ShellSnapshot | null> {
|
||||
@@ -126,6 +132,8 @@ async function getOrCreateShellSnapshot(
|
||||
}
|
||||
|
||||
function buildSnapshotKey(opts: ShellSnapshotWrapOptions): string {
|
||||
// Snapshot capture executes shell startup files before the approved command.
|
||||
// Use process-owned roots/env only; per-call exec.env is model-controlled.
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
@@ -133,10 +141,10 @@ function buildSnapshotKey(opts: ShellSnapshotWrapOptions): string {
|
||||
shell: opts.shell,
|
||||
shellArgs: opts.shellArgs,
|
||||
cwd: path.resolve(opts.cwd),
|
||||
home: opts.env.HOME ?? opts.env.USERPROFILE ?? os.homedir(),
|
||||
home: getTrustedShellHome(),
|
||||
stateDir: resolveStateDir(process.env),
|
||||
env: buildSafeEnvSignature(opts.env),
|
||||
startup: buildStartupSignature(opts),
|
||||
env: buildSafeEnvSignature(process.env),
|
||||
startup: buildStartupSignature(opts.shell),
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
@@ -150,20 +158,16 @@ function buildSafeEnvSignature(
|
||||
.map((key): [string, string | null] => [key, env[key] ?? null]);
|
||||
}
|
||||
|
||||
function buildStartupSignature(
|
||||
opts: ShellSnapshotWrapOptions,
|
||||
): Array<[string, number, number] | [string, null]> {
|
||||
const shellName = path.basename(opts.shell);
|
||||
const home = opts.env.HOME ?? opts.env.USERPROFILE ?? os.homedir();
|
||||
const zdotdir = opts.env.ZDOTDIR?.trim() || home;
|
||||
function buildStartupSignature(shell: string): Array<[string, number, number] | [string, null]> {
|
||||
const shellName = path.basename(shell);
|
||||
const home = getTrustedShellHome();
|
||||
const zdotdir = process.env.ZDOTDIR?.trim() || home;
|
||||
const candidates =
|
||||
shellName === "zsh"
|
||||
? [path.join(zdotdir, ".zshrc")]
|
||||
: shellName === "bash"
|
||||
? [path.join(home, ".bashrc")]
|
||||
: opts.env.ENV
|
||||
? [opts.env.ENV]
|
||||
: [];
|
||||
: [];
|
||||
return candidates.map((candidate) => {
|
||||
try {
|
||||
const stat = statSync(candidate);
|
||||
@@ -174,6 +178,10 @@ function buildStartupSignature(
|
||||
});
|
||||
}
|
||||
|
||||
function getTrustedShellHome(): string {
|
||||
return process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
|
||||
}
|
||||
|
||||
async function createShellSnapshot(
|
||||
opts: ShellSnapshotWrapOptions,
|
||||
key: string,
|
||||
@@ -264,7 +272,7 @@ async function captureShellSnapshot(opts: ShellSnapshotWrapOptions): Promise<str
|
||||
shell: opts.shell,
|
||||
shellArgs: buildCaptureShellArgs(shellName, opts.shellArgs),
|
||||
cwd: opts.cwd,
|
||||
env: buildSnapshotCaptureEnv(opts.env),
|
||||
env: buildTrustedSnapshotCaptureEnv(opts.env),
|
||||
command: captureCommand,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
@@ -298,6 +306,18 @@ function buildSnapshotCaptureEnv(
|
||||
);
|
||||
}
|
||||
|
||||
function buildTrustedSnapshotCaptureEnv(
|
||||
runtimeEnv: Record<string, string | undefined>,
|
||||
): Record<string, string | undefined> {
|
||||
const env = buildSnapshotCaptureEnv(process.env);
|
||||
// OPENCLAW_SHELL is injected by the exec runtime, so startup files can keep
|
||||
// their documented exec-specific branches without trusting model input.
|
||||
if (runtimeEnv.OPENCLAW_SHELL === "exec") {
|
||||
env.OPENCLAW_SHELL = "exec";
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function buildStartupSourceScript(shellName: string): string {
|
||||
if (shellName === "zsh") {
|
||||
return `if [ -r "\${ZDOTDIR:-$HOME}/.zshrc" ]; then . "\${ZDOTDIR:-$HOME}/.zshrc"; fi`;
|
||||
@@ -380,13 +400,30 @@ function parseSafeEnvExports(envJson: string): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildSnapshotWrappedCommand(command: string, snapshotPath: string): string {
|
||||
function buildRuntimeEnvRestoreScript(env: Record<string, string | undefined>): string {
|
||||
return [...SAFE_ENV_NAMES]
|
||||
.toSorted()
|
||||
.filter((key) => env[key] !== process.env[key] && !SECRET_ENV_PATTERN.test(key))
|
||||
.map((key) =>
|
||||
typeof env[key] === "string" ? `export ${key}=${shQuote(env[key])}` : `unset ${key}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildSnapshotWrappedCommand(
|
||||
command: string,
|
||||
snapshotPath: string,
|
||||
runtimeEnvRestoreScript: string,
|
||||
): string {
|
||||
return [
|
||||
`if [ -r ${shQuote(snapshotPath)} ]; then . ${shQuote(snapshotPath)}; fi`,
|
||||
runtimeEnvRestoreScript,
|
||||
// Alias expansion happens while shells parse a command. Re-parse the user command
|
||||
// after sourcing the snapshot so zsh/bash aliases captured from startup files work.
|
||||
`eval ${shQuote(command)}`,
|
||||
].join("\n");
|
||||
]
|
||||
.filter((part) => part.trim().length > 0)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function shQuote(value: string): string {
|
||||
|
||||
Reference in New Issue
Block a user