diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 577cfb02cdf6..a4fbc44acd7f 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -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` 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, diff --git a/src/agents/agent-tool-definition-adapter.ts b/src/agents/agent-tool-definition-adapter.ts index 6bf94d6b7bdd..06d4599f724e 100644 --- a/src/agents/agent-tool-definition-adapter.ts +++ b/src/agents/agent-tool-definition-adapter.ts @@ -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 { + 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); diff --git a/src/agents/agent-tools.before-tool-call.ts b/src/agents/agent-tools.before-tool-call.ts index 49cec34f960e..73590f8ba023 100644 --- a/src/agents/agent-tools.before-tool-call.ts +++ b/src/agents/agent-tools.before-tool-call.ts @@ -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 diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index f4239a808ac0..22ee934a42d9 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -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) => (await loadTool()).execute(...args), } as AnyAgentTool; diff --git a/src/agents/bash-tools.exec.resolve-env-hook.test.ts b/src/agents/bash-tools.exec.resolve-env-hook.test.ts new file mode 100644 index 000000000000..4e8390e97a8d --- /dev/null +++ b/src/agents/bash-tools.exec.resolve-env-hook.test.ts @@ -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; + runResolveExecEnv: ReturnType; + runBeforeToolCall?: ReturnType; + } + | undefined, + beforeToolCallParams: [] as Array>, + gatewayParams: [] as Array<{ + env: Record; + requestedEnv?: Record; + }>, + nodeHostParams: [] as Array<{ + env: Record; + requestedEnv?: Record; + }>, + spawnInputs: [] as Array<{ + env?: Record; + }>, +})); + +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; requestedEnv?: Record }) => { + 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; requestedEnv?: Record }) => { + 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; 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) { + 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 }) => { + 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 }) => { + 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 }) => ({ + 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 }) => ({ + 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 }) => { + 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", + }); + }); +}); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index f7a5b7ffc94c..27ab70067076 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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 & { ask?: string; node?: string; }; +type ResolvedExecEnvPreparedState = { + host?: ExecHost; + pluginEnv?: Record; +}; +const resolvedExecEnvPreparedStates = new WeakMap(); 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): Record | undefined { + const env: Record = {}; + 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( + 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 => { + 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, diff --git a/src/agents/shell-snapshot.test.ts b/src/agents/shell-snapshot.test.ts index 96be04538db1..0735e91ed7c7 100644 --- a/src/agents/shell-snapshot.test.ts +++ b/src/agents/shell-snapshot.test.ts @@ -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) { diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 2807904d4b55..ce264ef1c107 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -22,6 +22,11 @@ export type AgentToolWithMeta = 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 & ErasedAgentToolExecute & { displaySummary?: string; + prepareBeforeToolCallParams?: AgentToolWithMeta< + TSchema, + unknown + >["prepareBeforeToolCallParams"]; + finalizeBeforeToolCallParams?: AgentToolWithMeta< + TSchema, + unknown + >["finalizeBeforeToolCallParams"]; }; export function asToolParamsRecord(params: unknown): Record { diff --git a/src/plugins/hook-resolve-exec-env.test.ts b/src/plugins/hook-resolve-exec-env.test.ts new file mode 100644 index 000000000000..3944807a5f3d --- /dev/null +++ b/src/plugins/hook-resolve-exec-env.test.ts @@ -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>(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(() => {}), + 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", + ); + }); +}); diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 125c8944dd90..3efab1de4ba4 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -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; @@ -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; + resolve_exec_env: ( + event: PluginHookResolveExecEnvEvent, + ctx: PluginHookResolveExecEnvContext, + ) => Promise | void> | Record | void; }; export type PluginHookRegistration = { diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index fed204db13ef..57abb8ce3ffa 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -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 = { @@ -1586,6 +1591,21 @@ export function createHookRunner( ); } + async function runResolveExecEnv( + event: PluginHookResolveExecEnvEvent, + ctx: PluginHookResolveExecEnvContext, + ): Promise> { + const result = await runModifyingHook<"resolve_exec_env", Record>( + "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,