feat: default exec shell snapshots

This commit is contained in:
Peter Steinberger
2026-05-31 16:09:32 +01:00
parent 89cdf164ca
commit 6b1b2ff20a
5 changed files with 163 additions and 83 deletions

View File

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

View File

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

View File

@@ -829,7 +829,6 @@ export async function runExecProcess(opts: {
shellArgs,
cwd: opts.workdir,
env: shellRuntimeEnv,
enabled: true,
});
const childArgv = [shell, ...shellArgs, commandWithShellSnapshot];

View File

@@ -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], {

View File

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