mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
440
src/agents/bash-tools.exec.resolve-env-hook.test.ts
Normal file
440
src/agents/bash-tools.exec.resolve-env-hook.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
125
src/plugins/hook-resolve-exec-env.test.ts
Normal file
125
src/plugins/hook-resolve-exec-env.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user