feat(plugin-sdk): add resolve_exec_env hook

Summary:
- Add the plugin SDK `resolve_exec_env` hook for bounded exec environment contributions.
- Wire resolved exec env through exec preparation/final execution without exposing plugin env values to generic tool hooks.
- Cover lazy exec loading, host and command rewrites, node/gateway execution, filtering, and EXEC shell snapshot cache behavior.

Verification:
- `pnpm changed:lanes --json`
- `node scripts/run-vitest.mjs src/agents/bash-tools.exec.resolve-env-hook.test.ts src/agents/agent-tool-definition-adapter.test.ts src/agents/agent-tool-definition-adapter.after-tool-call.test.ts src/agents/shell-snapshot.test.ts src/plugins/hook-resolve-exec-env.test.ts`
- `pnpm check:test-types`
- `pnpm lint src/agents/bash-tools.exec.ts src/agents/bash-tools.exec.resolve-env-hook.test.ts`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- PR CI clean on 1bbad8d071: https://github.com/openclaw/openclaw/actions/runs/26817910293

Co-authored-by: Lanzhi <lizhan3@xiaomi.com>
This commit is contained in:
兰之
2026-06-02 20:00:42 +08:00
committed by GitHub
parent e992af4b6e
commit 10d10faa25
11 changed files with 910 additions and 19 deletions

View File

@@ -120,6 +120,7 @@ observation-only.
- **`before_tool_call`** - rewrite tool params, block execution, or require approval
- `after_tool_call` - observe tool results, errors, and duration
- `resolve_exec_env` - contribute plugin-owned environment variables to `exec`
- **`tool_result_persist`** - rewrite the assistant message produced from a tool result
- **`before_message_write`** - inspect or block an in-progress message write (rare)
@@ -233,6 +234,28 @@ for host-trusted gates such as workspace policy, budget enforcement, or
reserved workflow safety. External plugins should use normal `before_tool_call`
hooks.
### Exec environment hook
`resolve_exec_env` lets plugins contribute environment variables to `exec`
tool invocations after the base exec environment is built and before the
command runs. It receives:
- `event.sessionKey`
- `event.toolName`, currently always `"exec"`
- `event.host`, one of `"gateway"`, `"sandbox"`, or `"node"`
- context fields such as `ctx.agentId`, `ctx.sessionKey`,
`ctx.messageProvider`, and `ctx.channelId`
Return a `Record<string, string>` to merge into the exec environment. Handlers
run in priority order, and later hook results override earlier hook results for
the same key.
Hook output is filtered through the host exec environment key policy before it
is merged. Invalid keys, `PATH`, and dangerous host override keys such as
`LD_*`, `DYLD_*`, `NODE_OPTIONS`, proxy variables, and TLS override variables
are dropped. The filtered plugin env is included in gateway approval/audit
metadata and forwarded to node-host execution requests.
### Tool result persistence
Tool results can include structured `details` for UI rendering, diagnostics,

View File

@@ -23,6 +23,13 @@ import { normalizeToolName } from "./tool-policy.js";
import { jsonResult, payloadTextResult } from "./tools/common.js";
type AnyAgentTool = AgentTool;
type BeforeToolCallPreparingTool = AnyAgentTool & {
prepareBeforeToolCallParams?: (
params: unknown,
ctx: { toolCallId?: string; hookContext?: HookContext; signal?: AbortSignal },
) => unknown;
finalizeBeforeToolCallParams?: (params: unknown, preparedParams: unknown) => unknown;
};
type ToolExecuteArgsCurrent = [
string,
@@ -269,6 +276,32 @@ function splitToolExecuteArgs(args: ToolExecuteArgsAny): {
};
}
async function prepareToolParamsBeforeHook(params: {
tool: AnyAgentTool;
rawParams: unknown;
toolCallId?: string;
hookContext?: HookContext;
signal?: AbortSignal;
}): Promise<unknown> {
const prepare = (params.tool as BeforeToolCallPreparingTool).prepareBeforeToolCallParams;
return prepare
? await prepare(params.rawParams, {
...(params.toolCallId ? { toolCallId: params.toolCallId } : {}),
...(params.hookContext ? { hookContext: params.hookContext } : {}),
...(params.signal ? { signal: params.signal } : {}),
})
: params.rawParams;
}
function finalizeToolParamsBeforeExecute(params: {
tool: AnyAgentTool;
executeParams: unknown;
preparedParams: unknown;
}): unknown {
const finalize = (params.tool as BeforeToolCallPreparingTool).finalizeBeforeToolCallParams;
return finalize ? finalize(params.executeParams, params.preparedParams) : params.executeParams;
}
export const CLIENT_TOOL_NAME_CONFLICT_PREFIX = "client tool name conflict:";
export function findClientToolNameConflicts(params: {
@@ -331,8 +364,21 @@ export function toToolDefinitions(
let executeParams = params;
try {
if (!beforeHookWrapped) {
const hookParams = normalizeCodeModeExecBeforeHookParams({ tool, params });
const hookMetadata = getCodeModeExecBeforeHookMetadata({ tool, params });
const preparedParams = await prepareToolParamsBeforeHook({
tool,
rawParams: params,
...(toolCallId ? { toolCallId } : {}),
...(hookContext ? { hookContext } : {}),
...(signal ? { signal } : {}),
});
const hookParams = normalizeCodeModeExecBeforeHookParams({
tool,
params: preparedParams,
});
const hookMetadata = getCodeModeExecBeforeHookMetadata({
tool,
params: preparedParams,
});
const hookOutcome = await runBeforeToolCallHook({
toolName: name,
params: hookParams,
@@ -351,10 +397,15 @@ export function toToolDefinitions(
}
executeParams = reconcileCodeModeExecBeforeHookParams({
tool,
originalParams: params,
originalParams: preparedParams,
hookParams,
adjustedParams: hookOutcome.params,
});
executeParams = finalizeToolParamsBeforeExecute({
tool,
executeParams,
preparedParams,
});
recordAdjustedParamsForToolCall(toolCallId, executeParams, hookContext?.runId);
}
const rawResult = await tool.execute(toolCallId, executeParams, signal, onUpdate);

View File

@@ -155,6 +155,13 @@ type BeforeToolCallWrapperOptions = {
approvalMode?: "request" | "report" | "defer";
emitDiagnostics: boolean;
};
type BeforeToolCallPreparingTool = AnyAgentTool & {
prepareBeforeToolCallParams?: (
params: unknown,
ctx: { toolCallId?: string; hookContext?: HookContext; signal?: AbortSignal },
) => unknown;
finalizeBeforeToolCallParams?: (params: unknown, preparedParams: unknown) => unknown;
};
export type BeforeToolCallPolicyDiagnosticState = {
hasBeforeToolCallHook: boolean;
@@ -1099,8 +1106,16 @@ export function wrapToolWithBeforeToolCallHook(
const wrappedTool: AnyAgentTool = {
...tool,
execute: async (toolCallId, params, signal, onUpdate) => {
const hookParams = normalizeCodeModeExecBeforeHookParams({ tool, params });
const hookMetadata = getCodeModeExecBeforeHookMetadata({ tool, params });
const prepare = (tool as BeforeToolCallPreparingTool).prepareBeforeToolCallParams;
const preparedParams = prepare
? await prepare(params, {
...(toolCallId ? { toolCallId } : {}),
...(ctx ? { hookContext: ctx } : {}),
...(signal ? { signal } : {}),
})
: params;
const hookParams = normalizeCodeModeExecBeforeHookParams({ tool, params: preparedParams });
const hookMetadata = getCodeModeExecBeforeHookMetadata({ tool, params: preparedParams });
const outcome = await runBeforeToolCallHook({
toolName,
params: hookParams,
@@ -1149,12 +1164,17 @@ export function wrapToolWithBeforeToolCallHook(
});
return blockedResult;
}
const executeParams = reconcileCodeModeExecBeforeHookParams({
let executeParams = reconcileCodeModeExecBeforeHookParams({
tool,
originalParams: params,
originalParams: preparedParams,
hookParams,
adjustedParams: outcome.params,
});
executeParams =
(tool as BeforeToolCallPreparingTool).finalizeBeforeToolCallParams?.(
executeParams,
preparedParams,
) ?? executeParams;
recordAdjustedParamsForToolCall(toolCallId, executeParams, ctx?.runId);
const normalizedToolName = normalizeToolName(toolName || "tool");
const trace = ctx?.trace

View File

@@ -183,6 +183,10 @@ function createLazyExecTool(defaults?: ExecToolDefaults): AnyAgentTool {
});
},
parameters: execSchema,
prepareBeforeToolCallParams: async (...args) =>
(await loadTool()).prepareBeforeToolCallParams?.(...args) ?? args[0],
finalizeBeforeToolCallParams: (params, preparedParams) =>
loadedTool?.finalizeBeforeToolCallParams?.(params, preparedParams) ?? params,
execute: async (...args: Parameters<AnyAgentTool["execute"]>) =>
(await loadTool()).execute(...args),
} as AnyAgentTool;

View File

@@ -0,0 +1,440 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
import type { ExtensionContext } from "./sessions/index.js";
const mocks = vi.hoisted(() => ({
hookRunner: undefined as
| {
hasHooks: ReturnType<typeof vi.fn>;
runResolveExecEnv: ReturnType<typeof vi.fn>;
runBeforeToolCall?: ReturnType<typeof vi.fn>;
}
| undefined,
beforeToolCallParams: [] as Array<Record<string, unknown>>,
gatewayParams: [] as Array<{
env: Record<string, string>;
requestedEnv?: Record<string, string>;
}>,
nodeHostParams: [] as Array<{
env: Record<string, string>;
requestedEnv?: Record<string, string>;
}>,
spawnInputs: [] as Array<{
env?: Record<string, string>;
}>,
}));
vi.mock("../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => mocks.hookRunner,
}));
vi.mock("../infra/shell-env.js", () => ({
getShellPathFromLoginShell: vi.fn(() => null),
resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0),
}));
vi.mock("./bash-tools.exec-host-gateway.js", () => ({
processGatewayAllowlist: vi.fn(
async (params: { env: Record<string, string>; requestedEnv?: Record<string, string> }) => {
mocks.gatewayParams.push({
env: { ...params.env },
requestedEnv: params.requestedEnv ? { ...params.requestedEnv } : undefined,
});
return {};
},
),
}));
vi.mock("./bash-tools.exec-host-node.js", () => ({
executeNodeHostCommand: vi.fn(
async (params: { env: Record<string, string>; requestedEnv?: Record<string, string> }) => {
mocks.nodeHostParams.push({
env: { ...params.env },
requestedEnv: params.requestedEnv ? { ...params.requestedEnv } : undefined,
});
return {
content: [{ type: "text", text: "node ok" }],
details: {
status: "completed",
exitCode: 0,
durationMs: 0,
aggregated: "node ok",
},
};
},
),
}));
vi.mock("../process/supervisor/index.js", () => ({
getProcessSupervisor: () => ({
spawn: async (input: { env?: Record<string, string>; onStdout?: (chunk: string) => void }) => {
mocks.spawnInputs.push({ env: input.env ? { ...input.env } : undefined });
input.onStdout?.("ok\n");
return {
runId: "mock-run",
startedAtMs: Date.now(),
stdin: undefined,
wait: async () => ({
reason: "exit" as const,
exitCode: 0,
exitSignal: null,
durationMs: 0,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
cancel: vi.fn(),
};
},
cancel: vi.fn(),
cancelScope: vi.fn(),
reconcileOrphans: vi.fn(),
getRecord: vi.fn(),
}),
}));
let createExecTool: typeof import("./bash-tools.exec.js").createExecTool;
let toToolDefinitions: typeof import("./agent-tool-definition-adapter.js").toToolDefinitions;
let createOpenClawCodingTools: typeof import("./agent-tools.js").createOpenClawCodingTools;
const testExtensionContext = {} as ExtensionContext;
function installResolveExecEnvHook(result: Record<string, string>) {
mocks.hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "resolve_exec_env"),
runResolveExecEnv: vi.fn(async () => result),
};
}
describe("exec resolve_exec_env hook wiring", () => {
beforeAll(async () => {
({ createExecTool } = await import("./bash-tools.exec.js"));
({ toToolDefinitions } = await import("./agent-tool-definition-adapter.js"));
({ createOpenClawCodingTools } = await import("./agent-tools.js"));
});
beforeEach(() => {
mocks.hookRunner = undefined;
mocks.beforeToolCallParams.length = 0;
mocks.gatewayParams.length = 0;
mocks.nodeHostParams.length = 0;
mocks.spawnInputs.length = 0;
});
it("merges filtered plugin env into gateway execution and approval-visible requested env", async () => {
installResolveExecEnvHook({
EXISTING: "plugin",
PLUGIN_SAFE: "yes",
PATH: "/tmp/plugin-bin",
NODE_OPTIONS: "--require /tmp/hook.js",
OPENCLAW_CLI: "0",
"bad-key": "bad",
});
const tool = createExecTool({
host: "auto",
security: "full",
ask: "off",
sessionKey: "agent:main:telegram:chat-1",
messageProvider: "telegram",
currentChannelId: "chat-1",
});
await tool.execute("call-1", {
command: "echo ok",
env: { EXISTING: "request" },
yieldMs: 120_000,
});
expect(mocks.hookRunner?.runResolveExecEnv).toHaveBeenCalledWith(
{
sessionKey: "agent:main:telegram:chat-1",
toolName: "exec",
host: "gateway",
},
{
agentId: "main",
sessionKey: "agent:main:telegram:chat-1",
messageProvider: "telegram",
channelId: "chat-1",
},
);
expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({
EXISTING: "plugin",
PLUGIN_SAFE: "yes",
});
expect(mocks.gatewayParams[0]?.env).toMatchObject({
EXISTING: "plugin",
PLUGIN_SAFE: "yes",
});
expect(mocks.gatewayParams[0]?.env).not.toHaveProperty("NODE_OPTIONS");
expect(mocks.gatewayParams[0]?.env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE);
expect(mocks.gatewayParams[0]?.env.PATH).not.toBe("/tmp/plugin-bin");
expect(mocks.spawnInputs[0]?.env).toMatchObject({
EXISTING: "plugin",
PLUGIN_SAFE: "yes",
});
});
it("forwards filtered plugin env to node host requests", async () => {
installResolveExecEnvHook({
NODE_HOST_SAFE: "yes",
LD_PRELOAD: "/tmp/preload.dylib",
});
const tool = createExecTool({
host: "node",
security: "full",
ask: "off",
sessionKey: "agent:main:main",
});
await tool.execute("call-node", {
command: "echo ok",
env: { REQUEST_SAFE: "request" },
});
expect(mocks.nodeHostParams[0]?.requestedEnv).toEqual({
NODE_HOST_SAFE: "yes",
REQUEST_SAFE: "request",
});
expect(mocks.nodeHostParams[0]?.env).toMatchObject({
NODE_HOST_SAFE: "yes",
REQUEST_SAFE: "request",
});
expect(mocks.nodeHostParams[0]?.env).not.toHaveProperty("LD_PRELOAD");
});
it("keeps plugin env out of before_tool_call params before execution", async () => {
mocks.hookRunner = {
hasHooks: vi.fn(
(hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call",
),
runResolveExecEnv: vi.fn(async () => ({ PLUGIN_SAFE: "yes" })),
runBeforeToolCall: vi.fn(async (event: { params: Record<string, unknown> }) => {
expect(Object.getOwnPropertySymbols(event.params)).toHaveLength(0);
mocks.beforeToolCallParams.push({ ...event.params });
return undefined;
}),
};
const tool = createExecTool({
host: "auto",
security: "full",
ask: "off",
sessionKey: "agent:main:telegram:chat-1",
messageProvider: "telegram",
currentChannelId: "chat-1",
});
const [definition] = toToolDefinitions([tool], {
agentId: "main",
sessionKey: "agent:main:telegram:chat-1",
channelId: "chat-1",
});
await definition.execute(
"call-before",
{
command: "echo ok",
env: { EXISTING: "request" },
yieldMs: 120_000,
},
undefined,
undefined,
testExtensionContext,
);
expect(mocks.beforeToolCallParams[0]?.env).toEqual({
EXISTING: "request",
});
expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenCalledTimes(1);
expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({
EXISTING: "request",
PLUGIN_SAFE: "yes",
});
});
it("forwards private env preparation through the lazy exec tool", async () => {
mocks.hookRunner = {
hasHooks: vi.fn(
(hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call",
),
runResolveExecEnv: vi.fn(async () => ({ LAZY_PLUGIN_SAFE: "yes" })),
runBeforeToolCall: vi.fn(async (event: { params: Record<string, unknown> }) => {
expect(Object.getOwnPropertySymbols(event.params)).toHaveLength(0);
mocks.beforeToolCallParams.push({ ...event.params });
return undefined;
}),
};
const exec = createOpenClawCodingTools({
agentId: "main",
sessionKey: "agent:main:telegram:chat-1",
cwd: process.cwd(),
exec: { host: "gateway", security: "full", ask: "off" },
}).find((tool) => tool.name === "exec");
expect(exec).toBeDefined();
const [definition] = toToolDefinitions([exec!], {
agentId: "main",
sessionKey: "agent:main:telegram:chat-1",
channelId: "chat-1",
});
await definition.execute(
"call-lazy",
{
command: "echo ok",
env: { REQUEST_SAFE: "request" },
yieldMs: 120_000,
},
undefined,
undefined,
testExtensionContext,
);
expect(mocks.beforeToolCallParams[0]?.env).toEqual({
REQUEST_SAFE: "request",
});
expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenCalledTimes(1);
expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({
LAZY_PLUGIN_SAFE: "yes",
REQUEST_SAFE: "request",
});
});
it("recomputes plugin env when before_tool_call changes exec host", async () => {
mocks.hookRunner = {
hasHooks: vi.fn(
(hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call",
),
runResolveExecEnv: vi.fn(async (event: { host: "gateway" | "sandbox" | "node" }) =>
event.host === "node" ? { NODE_PLUGIN_SAFE: "node" } : { GATEWAY_PLUGIN_SAFE: "gateway" },
),
runBeforeToolCall: vi.fn(async (event: { params: Record<string, unknown> }) => ({
params: { ...event.params, host: "node" },
})),
};
const tool = createExecTool({
host: "auto",
security: "full",
ask: "off",
sessionKey: "agent:main:telegram:chat-1",
});
const [definition] = toToolDefinitions([tool], {
agentId: "main",
sessionKey: "agent:main:telegram:chat-1",
});
await definition.execute(
"call-host-rewrite",
{
command: "echo ok",
env: { REQUEST_SAFE: "request" },
},
undefined,
undefined,
testExtensionContext,
);
expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenCalledTimes(2);
expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ host: "gateway" }),
expect.anything(),
);
expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ host: "node" }),
expect.anything(),
);
expect(mocks.nodeHostParams[0]?.requestedEnv).toEqual({
NODE_PLUGIN_SAFE: "node",
REQUEST_SAFE: "request",
});
expect(mocks.nodeHostParams[0]?.requestedEnv).not.toHaveProperty("GATEWAY_PLUGIN_SAFE");
});
it("lets before_tool_call rewrite host when no resolve_exec_env hook is registered", async () => {
mocks.hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_tool_call"),
runResolveExecEnv: vi.fn(),
runBeforeToolCall: vi.fn(async (event: { params: Record<string, unknown> }) => ({
params: { ...event.params, host: "gateway" },
})),
};
const tool = createExecTool({
host: "gateway",
security: "full",
ask: "off",
sessionKey: "agent:main:telegram:chat-1",
});
const [definition] = toToolDefinitions([tool], {
agentId: "main",
sessionKey: "agent:main:telegram:chat-1",
});
await definition.execute(
"call-host-sanitize",
{
command: "echo ok",
host: "node",
env: { REQUEST_SAFE: "request" },
yieldMs: 120_000,
},
undefined,
undefined,
testExtensionContext,
);
expect(mocks.hookRunner.runResolveExecEnv).not.toHaveBeenCalled();
expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({
REQUEST_SAFE: "request",
});
});
it("resolves plugin env after before_tool_call adds a command", async () => {
mocks.hookRunner = {
hasHooks: vi.fn(
(hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call",
),
runResolveExecEnv: vi.fn(async () => ({ PLUGIN_SAFE: "yes" })),
runBeforeToolCall: vi.fn(async (event: { params: Record<string, unknown> }) => {
mocks.beforeToolCallParams.push({ ...event.params });
return {
params: { ...event.params, command: "echo ok" },
};
}),
};
const tool = createExecTool({
host: "gateway",
security: "full",
ask: "off",
sessionKey: "agent:main:telegram:chat-1",
});
const [definition] = toToolDefinitions([tool], {
agentId: "main",
sessionKey: "agent:main:telegram:chat-1",
});
await definition.execute(
"call-command-rewrite",
{
env: { REQUEST_SAFE: "request" },
yieldMs: 120_000,
},
undefined,
undefined,
testExtensionContext,
);
expect(mocks.beforeToolCallParams[0]?.env).toEqual({
REQUEST_SAFE: "request",
});
expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenCalledTimes(1);
expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({
PLUGIN_SAFE: "yes",
REQUEST_SAFE: "request",
});
});
});

View File

@@ -21,12 +21,19 @@ import {
resolveExecModePolicy,
} from "../infra/exec-approvals.js";
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js";
import {
isDangerousHostEnvOverrideVarName,
isDangerousHostEnvVarName,
normalizeHostOverrideEnvVarKey,
sanitizeHostExecEnvWithDiagnostics,
} from "../infra/host-env-security.js";
import { OPENCLAW_CLI_ENV_VAR } from "../infra/openclaw-exec-env.js";
import {
getShellPathFromLoginShell,
resolveShellEnvFallbackTimeoutMs,
} from "../infra/shell-env.js";
import { logInfo } from "../logger.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import {
normalizeAgentId,
parseAgentSessionKey,
@@ -35,6 +42,7 @@ import {
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import { splitShellArgs } from "../utils/shell-argv.js";
import type { HookContext } from "./agent-tools.before-tool-call.js";
import { stripMalformedXmlArgValueSuffixFromKeys } from "./agent-tools.params.js";
import { markBackgrounded } from "./bash-process-registry.js";
import { describeExecTool } from "./bash-tools.descriptions.js";
@@ -91,6 +99,11 @@ type ExecToolArgs = Record<string, unknown> & {
ask?: string;
node?: string;
};
type ResolvedExecEnvPreparedState = {
host?: ExecHost;
pluginEnv?: Record<string, string>;
};
const resolvedExecEnvPreparedStates = new WeakMap<ExecToolArgs, ResolvedExecEnvPreparedState>();
const XML_ARG_VALUE_EXEC_PARAM_KEYS = [
"command",
@@ -101,6 +114,45 @@ const XML_ARG_VALUE_EXEC_PARAM_KEYS = [
"node",
] as const;
function filterPluginExecEnv(rawEnv: Record<string, string>): Record<string, string> | undefined {
const env: Record<string, string> = {};
for (const [rawKey, value] of Object.entries(rawEnv)) {
const key = normalizeHostOverrideEnvVarKey(rawKey);
if (!key) {
continue;
}
const upperKey = key.toUpperCase();
if (
upperKey === "PATH" ||
upperKey === OPENCLAW_CLI_ENV_VAR ||
isDangerousHostEnvVarName(upperKey) ||
isDangerousHostEnvOverrideVarName(upperKey)
) {
continue;
}
env[key] = value;
}
return Object.keys(env).length > 0 ? env : undefined;
}
function markResolveExecEnvPrepared<T extends ExecToolArgs>(
params: T,
state: ResolvedExecEnvPreparedState = {},
): T {
resolvedExecEnvPreparedStates.set(params, state);
return params;
}
function getResolvedExecEnvPreparedState(
params: ExecToolArgs,
): ResolvedExecEnvPreparedState | undefined {
return resolvedExecEnvPreparedStates.get(params);
}
function isResolveExecEnvPrepared(params: ExecToolArgs): boolean {
return Boolean(getResolvedExecEnvPreparedState(params));
}
function buildExecForegroundResult(params: {
outcome: ExecProcessOutcome;
cwd?: string;
@@ -1316,6 +1368,74 @@ export function createExecTool(
const agentId =
defaults?.agentId ??
(parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
const resolveHostForParams = (params: ExecToolArgs): ExecHost => {
const elevatedDefaults = defaults?.elevated;
const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed);
const elevatedDefaultMode =
elevatedDefaults?.defaultLevel === "full"
? "full"
: elevatedDefaults?.defaultLevel === "ask"
? "ask"
: elevatedDefaults?.defaultLevel === "on"
? "ask"
: "off";
const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off";
const elevatedMode =
typeof params.elevated === "boolean"
? params.elevated
? elevatedDefaultMode === "full"
? "full"
: "ask"
: "off"
: effectiveDefaultMode;
const requestedTarget = requireValidExecTarget(params.host);
return resolveExecTarget({
configuredTarget: defaults?.host,
requestedTarget,
elevatedRequested: elevatedMode !== "off",
sandboxAvailable: Boolean(defaults?.sandbox),
}).effectiveHost;
};
const prepareParamsWithResolvedExecEnv = async (
rawArgs: unknown,
context?: { hookContext?: HookContext },
): Promise<ExecToolArgs> => {
const params = stripMalformedXmlArgValueSuffixFromKeys(
rawArgs as ExecToolArgs,
XML_ARG_VALUE_EXEC_PARAM_KEYS,
);
if (!params.command) {
return params;
}
if (isResolveExecEnvPrepared(params)) {
return markResolveExecEnvPrepared(params);
}
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("resolve_exec_env")) {
return markResolveExecEnvPrepared(params);
}
let host: ExecHost;
try {
host = resolveHostForParams(params);
} catch {
return params;
}
const rawPluginEnv = await hookRunner.runResolveExecEnv(
{
sessionKey: defaults?.sessionKey ?? context?.hookContext?.sessionKey,
toolName: "exec",
host,
},
{
agentId: agentId ?? context?.hookContext?.agentId,
sessionKey: defaults?.sessionKey ?? context?.hookContext?.sessionKey,
messageProvider: defaults?.messageProvider,
channelId: defaults?.currentChannelId ?? context?.hookContext?.channelId,
},
);
const pluginEnv = filterPluginExecEnv(rawPluginEnv);
return markResolveExecEnvPrepared(params, { host, ...(pluginEnv ? { pluginEnv } : {}) });
};
const autoReviewer =
defaults?.autoReviewer ??
createModelExecAutoReviewer({
@@ -1332,11 +1452,29 @@ export function createExecTool(
return describeExecTool({ agentId, hasCronTool: defaults?.hasCronTool === true });
},
parameters: execSchema,
prepareBeforeToolCallParams: async (args, context) =>
prepareParamsWithResolvedExecEnv(args, {
hookContext: context.hookContext as HookContext | undefined,
}),
finalizeBeforeToolCallParams: (params, preparedParams) =>
(() => {
const state = getResolvedExecEnvPreparedState(preparedParams as ExecToolArgs);
if (!state) {
return params;
}
const execParams = params as ExecToolArgs;
if (state.host && execParams.command && resolveHostForParams(execParams) !== state.host) {
return { ...execParams };
}
return markResolveExecEnvPrepared(execParams, state);
})(),
execute: async (_toolCallId, args, signal, onUpdate) => {
const params = stripMalformedXmlArgValueSuffixFromKeys(
args as ExecToolArgs,
XML_ARG_VALUE_EXEC_PARAM_KEYS,
);
const params = isResolveExecEnvPrepared(args as ExecToolArgs)
? stripMalformedXmlArgValueSuffixFromKeys(
args as ExecToolArgs,
XML_ARG_VALUE_EXEC_PARAM_KEYS,
)
: await prepareParamsWithResolvedExecEnv(args);
if (!params.command) {
throw new Error("Provide a command to start.");
@@ -1526,17 +1664,21 @@ export function createExecTool(
rejectUnsafeControlShellCommand(params.command);
const inheritedBaseEnv = coerceEnv(process.env);
const resolvedExecEnvState = getResolvedExecEnvPreparedState(params);
const requestedEnv = resolvedExecEnvState?.pluginEnv
? { ...params.env, ...resolvedExecEnvState.pluginEnv }
: params.env;
const hostEnvResult =
host === "sandbox"
? null
: sanitizeHostExecEnvWithDiagnostics({
baseEnv: inheritedBaseEnv,
overrides: params.env,
overrides: requestedEnv,
blockPathOverrides: true,
});
if (
hostEnvResult &&
params.env &&
requestedEnv &&
(hostEnvResult.rejectedOverrideBlockedKeys.length > 0 ||
hostEnvResult.rejectedOverrideInvalidKeys.length > 0)
) {
@@ -1573,13 +1715,13 @@ export function createExecTool(
sandbox && host === "sandbox"
? buildSandboxEnv({
defaultPath: DEFAULT_PATH,
paramsEnv: params.env,
paramsEnv: requestedEnv,
sandboxEnv: sandbox.env,
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: (hostEnvResult?.env ?? inheritedBaseEnv);
if (!sandbox && host === "gateway" && !params.env?.PATH) {
if (!sandbox && host === "gateway" && !requestedEnv?.PATH) {
const shellPath = getShellPathFromLoginShell({
env: process.env,
timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env),
@@ -1602,7 +1744,7 @@ export function createExecTool(
command: params.command,
workdir,
env,
requestedEnv: params.env,
requestedEnv,
requestedNode: params.node?.trim(),
boundNode: defaults?.node?.trim(),
sessionKey: defaults?.sessionKey,
@@ -1639,7 +1781,7 @@ export function createExecTool(
workdir,
env,
pathPrepend: defaultPathPrepend,
requestedEnv: params.env,
requestedEnv,
pty: params.pty === true && !sandbox,
timeoutSec: params.timeout,
defaultTimeoutSec,

View File

@@ -304,6 +304,44 @@ describe("exec shell snapshots", () => {
await expect(runWithPnpmHome("/second")).resolves.toBe("/second");
});
it("preserves per-call env outside the snapshot allowlist", async () => {
const bash = resolveBashForTest();
if (!bash) {
return;
}
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-plugin-env-home-"));
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-plugin-env-state-"));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-plugin-env-cwd-"));
tempDirs.push(home, stateDir, cwd);
setSnapshotStateForTest(stateDir, { home });
fs.writeFileSync(path.join(home, ".bashrc"), "alias oc_snapshot_alias='printf alias-ok'\n");
const env = {
...process.env,
HOME: home,
OPENCLAW_STATE_DIR: stateDir,
PLUGIN_SAFE: "plugin-ok",
};
const shellArgs = getPosixShellArgs(bash);
const wrapped = await maybeWrapCommandWithShellSnapshot({
command: 'oc_snapshot_alias; printf ":%s" "$PLUGIN_SAFE"',
shell: bash,
shellArgs,
cwd,
env,
});
const result = spawnSync(bash, [...shellArgs, wrapped], {
cwd,
env,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
expect(result.status).toBe(0);
expect(result.stdout).toBe("alias-ok:plugin-ok");
});
it("does not let non-fingerprinted env change captured shell state", async () => {
const bash = resolveBashForTest();
if (!bash) {

View File

@@ -22,6 +22,11 @@ export type AgentToolWithMeta<TParameters extends TSchema, TResult> = AgentTool<
TResult
> & {
displaySummary?: string;
prepareBeforeToolCallParams?: (
params: unknown,
ctx: { toolCallId?: string; hookContext?: unknown; signal?: AbortSignal },
) => unknown;
finalizeBeforeToolCallParams?: (params: unknown, preparedParams: unknown) => unknown;
};
type ErasedAgentToolExecute = {
@@ -37,6 +42,14 @@ type ErasedAgentToolExecute = {
export type AnyAgentTool = Omit<AgentTool, "execute"> &
ErasedAgentToolExecute & {
displaySummary?: string;
prepareBeforeToolCallParams?: AgentToolWithMeta<
TSchema,
unknown
>["prepareBeforeToolCallParams"];
finalizeBeforeToolCallParams?: AgentToolWithMeta<
TSchema,
unknown
>["finalizeBeforeToolCallParams"];
};
export function asToolParamsRecord(params: unknown): Record<string, unknown> {

View File

@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createHookRunner } from "./hooks.js";
import { addStaticTestHooks, addTestHook } from "./hooks.test-helpers.js";
import { createEmptyPluginRegistry } from "./registry.js";
import type { PluginHookResolveExecEnvContext } from "./types.js";
const ctx: PluginHookResolveExecEnvContext = {
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
messageProvider: "telegram",
channelId: "chat-1",
};
describe("resolve_exec_env hook", () => {
afterEach(() => {
vi.useRealTimers();
});
it("returns an empty env when no handlers are registered", async () => {
const runner = createHookRunner(createEmptyPluginRegistry());
await expect(
runner.runResolveExecEnv(
{ sessionKey: ctx.sessionKey, toolName: "exec", host: "gateway" },
ctx,
),
).resolves.toEqual({});
});
it("merges env vars from multiple plugins in priority order", async () => {
const registry = createEmptyPluginRegistry();
addStaticTestHooks<Record<string, string>>(registry, {
hookName: "resolve_exec_env",
hooks: [
{
pluginId: "first",
priority: 100,
result: { SHARED: "first", FIRST_ONLY: "1" },
},
{
pluginId: "second",
priority: 50,
result: { SHARED: "second", SECOND_ONLY: "2" },
},
],
});
const runner = createHookRunner(registry);
await expect(
runner.runResolveExecEnv(
{ sessionKey: ctx.sessionKey, toolName: "exec", host: "gateway" },
ctx,
),
).resolves.toEqual({
FIRST_ONLY: "1",
SECOND_ONLY: "2",
SHARED: "second",
});
});
it("isolates handler errors so other plugins can still contribute env", async () => {
const registry = createEmptyPluginRegistry();
const logger = { error: vi.fn(), warn: vi.fn(), debug: vi.fn() };
addTestHook({
registry,
pluginId: "crasher",
hookName: "resolve_exec_env",
handler: async () => {
throw new Error("plugin failed");
},
priority: 100,
});
addTestHook({
registry,
pluginId: "healthy",
hookName: "resolve_exec_env",
handler: async () => ({ HEALTHY_ENV: "ok" }),
priority: 50,
});
const runner = createHookRunner(registry, { logger });
await expect(
runner.runResolveExecEnv(
{ sessionKey: ctx.sessionKey, toolName: "exec", host: "gateway" },
ctx,
),
).resolves.toEqual({ HEALTHY_ENV: "ok" });
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining("resolve_exec_env handler from crasher failed"),
);
});
it("skips handlers that exceed the default timeout", async () => {
vi.useFakeTimers();
const logger = { error: vi.fn(), warn: vi.fn(), debug: vi.fn() };
const registry = createEmptyPluginRegistry();
addTestHook({
registry,
pluginId: "hanging",
hookName: "resolve_exec_env",
handler: () => new Promise<never>(() => {}),
priority: 100,
});
addTestHook({
registry,
pluginId: "healthy",
hookName: "resolve_exec_env",
handler: async () => ({ HEALTHY_ENV: "ok" }),
priority: 50,
});
const runner = createHookRunner(registry, { logger });
const resultPromise = runner.runResolveExecEnv(
{ sessionKey: ctx.sessionKey, toolName: "exec", host: "gateway" },
ctx,
);
await vi.advanceTimersByTimeAsync(15_000);
await expect(resultPromise).resolves.toEqual({ HEALTHY_ENV: "ok" });
expect(logger.error).toHaveBeenCalledWith(
"[hooks] resolve_exec_env handler from hanging failed: timed out after 15000ms",
);
});
});

View File

@@ -109,7 +109,8 @@ export type PluginHookName =
| "before_dispatch"
| "reply_dispatch"
| "before_install"
| "before_agent_run";
| "before_agent_run"
| "resolve_exec_env";
export const PLUGIN_HOOK_NAMES = [
"before_model_resolve",
@@ -150,6 +151,7 @@ export const PLUGIN_HOOK_NAMES = [
"reply_dispatch",
"before_install",
"before_agent_run",
"resolve_exec_env",
] as const satisfies readonly PluginHookName[];
type MissingPluginHookNames = Exclude<PluginHookName, (typeof PLUGIN_HOOK_NAMES)[number]>;
@@ -982,6 +984,14 @@ export type PluginHookBeforeAgentRunEvent = {
/** Result type for before_agent_run. Returns pass/block or void (= pass). */
export type PluginHookBeforeAgentRunResult = InputGateDecision | void;
export type PluginHookResolveExecEnvEvent = {
sessionKey?: string;
toolName: "exec";
host: "gateway" | "sandbox" | "node";
};
export type PluginHookResolveExecEnvContext = PluginHookAgentContext;
export type PluginHookHandlerMap = {
agent_turn_prepare: (
event: PluginAgentTurnPrepareEvent,
@@ -1159,6 +1169,10 @@ export type PluginHookHandlerMap = {
event: PluginHookBeforeAgentRunEvent,
ctx: PluginHookAgentContext,
) => Promise<PluginHookBeforeAgentRunResult> | PluginHookBeforeAgentRunResult;
resolve_exec_env: (
event: PluginHookResolveExecEnvEvent,
ctx: PluginHookResolveExecEnvContext,
) => Promise<Record<string, string> | void> | Record<string, string> | void;
};
export type PluginHookRegistration<K extends PluginHookName = PluginHookName> = {

View File

@@ -90,6 +90,8 @@ import type {
PluginHookBeforeInstallContext,
PluginHookBeforeInstallEvent,
PluginHookBeforeInstallResult,
PluginHookResolveExecEnvContext,
PluginHookResolveExecEnvEvent,
} from "./hook-types.js";
// Re-export types for consumers
@@ -161,6 +163,8 @@ export type {
PluginHookBeforeInstallContext,
PluginHookBeforeInstallEvent,
PluginHookBeforeInstallResult,
PluginHookResolveExecEnvContext,
PluginHookResolveExecEnvEvent,
};
export type HookRunnerLogger = {
@@ -225,6 +229,7 @@ const DEFAULT_MODIFYING_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName,
// logged and the run proceeds without its modifications.
before_agent_start: 15_000,
before_prompt_build: 15_000,
resolve_exec_env: 15_000,
};
type ModifyingHookPolicy<K extends PluginHookName, TResult> = {
@@ -1586,6 +1591,21 @@ export function createHookRunner(
);
}
async function runResolveExecEnv(
event: PluginHookResolveExecEnvEvent,
ctx: PluginHookResolveExecEnvContext,
): Promise<Record<string, string>> {
const result = await runModifyingHook<"resolve_exec_env", Record<string, string>>(
"resolve_exec_env",
event,
ctx,
{
mergeResults: (acc, next) => (acc ? { ...acc, ...next } : next),
},
);
return result ?? {};
}
// =========================================================================
// Utility
// =========================================================================
@@ -1649,6 +1669,7 @@ export function createHookRunner(
runCronChanged,
// Install hooks
runBeforeInstall,
runResolveExecEnv,
// Utility
hasHooks,
getHookCount,