mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
951 lines
30 KiB
TypeScript
951 lines
30 KiB
TypeScript
/**
|
|
* Resolve-exec-env hook tests.
|
|
* Verifies plugin-provided env values are filtered and forwarded to the chosen
|
|
* exec host without leaking unsafe overrides.
|
|
*/
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
|
|
import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
|
|
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
|
import type { ExtensionContext } from "./sessions/index.js";
|
|
|
|
declare module "../plugins/hook-types.js" {
|
|
interface PluginHookChannelSenderContext {
|
|
unionId?: string;
|
|
}
|
|
}
|
|
|
|
const CHANNEL_CONTEXT_ENV_KEY = "OPENCLAW_CHANNEL_CONTEXT";
|
|
type CapturedNodeHostParams = Pick<
|
|
ExecuteNodeHostCommandParams,
|
|
"env" | "requestedEnv" | "workdir"
|
|
>;
|
|
|
|
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 CapturedNodeHostParams[],
|
|
spawnInputs: [] as Array<{
|
|
env?: Record<string, string>;
|
|
}>,
|
|
}));
|
|
|
|
vi.mock("../plugins/hook-runner-global.js", () => ({
|
|
getGlobalHookRunner: () => mocks.hookRunner,
|
|
getGlobalHookRunnerRegistry: () => null,
|
|
}));
|
|
|
|
vi.mock("../infra/shell-env.js", () => ({
|
|
getShellEnvAppliedKeys: vi.fn(() => []),
|
|
getShellPathFromLoginShell: vi.fn(() => null),
|
|
resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0),
|
|
shouldDeferShellEnvFallback: vi.fn(() => false),
|
|
shouldEnableShellEnvFallback: vi.fn(() => false),
|
|
}));
|
|
|
|
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: Pick<ExecuteNodeHostCommandParams, "env" | "requestedEnv" | "workdir">) => {
|
|
mocks.nodeHostParams.push({
|
|
env: { ...params.env },
|
|
requestedEnv: params.requestedEnv ? { ...params.requestedEnv } : undefined,
|
|
workdir: params.workdir,
|
|
});
|
|
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(),
|
|
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("adds only channel identity env to gateway exec subprocesses", async () => {
|
|
const tool = createExecTool({
|
|
host: "auto",
|
|
security: "full",
|
|
ask: "off",
|
|
channelContext: {
|
|
sender: { id: "ou_1", unionId: "on_1" },
|
|
chat: { id: "oc_1" },
|
|
},
|
|
});
|
|
|
|
await tool.execute("call-channel-env", {
|
|
command: "echo ok",
|
|
yieldMs: 120_000,
|
|
});
|
|
|
|
expect(JSON.parse(mocks.gatewayParams[0]?.env[CHANNEL_CONTEXT_ENV_KEY] ?? "{}")).toEqual({
|
|
chat: { id: "oc_1" },
|
|
sender: { id: "ou_1" },
|
|
});
|
|
expect(mocks.spawnInputs[0]?.env?.[CHANNEL_CONTEXT_ENV_KEY]).toBe(
|
|
mocks.gatewayParams[0]?.env[CHANNEL_CONTEXT_ENV_KEY],
|
|
);
|
|
});
|
|
|
|
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",
|
|
channelContext: {
|
|
sender: { id: "ou_1", unionId: "on_1" },
|
|
chat: { id: "oc_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",
|
|
channelContext: {
|
|
sender: { id: "ou_1", unionId: "on_1" },
|
|
chat: { id: "oc_1" },
|
|
},
|
|
},
|
|
);
|
|
expect(mocks.gatewayParams[0]?.requestedEnv).toMatchObject({
|
|
EXISTING: "plugin",
|
|
PLUGIN_SAFE: "yes",
|
|
});
|
|
expect(
|
|
JSON.parse(mocks.gatewayParams[0]?.requestedEnv?.[CHANNEL_CONTEXT_ENV_KEY] ?? "{}"),
|
|
).toEqual({
|
|
chat: { id: "oc_1" },
|
|
sender: { id: "ou_1" },
|
|
});
|
|
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",
|
|
channelContext: {
|
|
sender: { id: "ou_node" },
|
|
chat: { id: "oc_node" },
|
|
},
|
|
});
|
|
await tool.execute("call-node", {
|
|
command: "echo ok",
|
|
env: { REQUEST_SAFE: "request" },
|
|
});
|
|
|
|
expect(mocks.nodeHostParams[0]?.requestedEnv).toMatchObject({
|
|
NODE_HOST_SAFE: "yes",
|
|
REQUEST_SAFE: "request",
|
|
});
|
|
expect(mocks.nodeHostParams[0]?.env).toMatchObject({
|
|
NODE_HOST_SAFE: "yes",
|
|
REQUEST_SAFE: "request",
|
|
});
|
|
expect(
|
|
JSON.parse(mocks.nodeHostParams[0]?.requestedEnv?.[CHANNEL_CONTEXT_ENV_KEY] ?? "{}"),
|
|
).toEqual({
|
|
chat: { id: "oc_node" },
|
|
sender: { id: "ou_node" },
|
|
});
|
|
expect(JSON.parse(mocks.nodeHostParams[0]?.env?.[CHANNEL_CONTEXT_ENV_KEY] ?? "{}")).toEqual({
|
|
chat: { id: "oc_node" },
|
|
sender: { id: "ou_node" },
|
|
});
|
|
expect(mocks.nodeHostParams[0]?.env).not.toHaveProperty("LD_PRELOAD");
|
|
});
|
|
|
|
it("does not forward configured gateway cwd defaults to node host requests", async () => {
|
|
const tool = createExecTool({
|
|
cwd: "/gateway/default/that/node/cannot/use",
|
|
host: "node",
|
|
security: "full",
|
|
ask: "off",
|
|
});
|
|
|
|
await tool.execute("call-node-default-cwd", {
|
|
command: "echo ok",
|
|
});
|
|
|
|
expect(mocks.nodeHostParams[0]?.workdir).toBeUndefined();
|
|
});
|
|
|
|
it("fails blank explicit node host workdirs before node invocation", async () => {
|
|
const tool = createExecTool({
|
|
host: "node",
|
|
security: "full",
|
|
ask: "off",
|
|
});
|
|
|
|
const result = await tool.execute("call-node-blank-cwd", {
|
|
command: "echo ok",
|
|
workdir: " ",
|
|
});
|
|
const text = result.content.find((entry) => entry.type === "text")?.text ?? "";
|
|
|
|
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
|
expect(text).toContain('workdir " " is unavailable or not a directory');
|
|
expect(text).toContain("command was not executed");
|
|
expect(mocks.nodeHostParams).toHaveLength(0);
|
|
});
|
|
|
|
it("prevalidates node workdirs before resolving exec env when a backend sandbox exists", async () => {
|
|
installResolveExecEnvHook({ PLUGIN_SAFE: "yes" });
|
|
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
|
const tool = createExecTool({
|
|
host: "node",
|
|
security: "full",
|
|
ask: "off",
|
|
sandbox: {
|
|
containerName: "remote-sandbox-workdir-test",
|
|
workspaceDir: process.cwd(),
|
|
containerWorkdir: "/remote/workspace",
|
|
workdirValidation: "backend",
|
|
validateWorkdir,
|
|
},
|
|
});
|
|
|
|
const result = await tool.execute("call-node-invalid-cwd-with-backend-sandbox", {
|
|
command: "echo ok",
|
|
workdir: " ",
|
|
});
|
|
|
|
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
|
expect(mocks.hookRunner?.runResolveExecEnv).not.toHaveBeenCalled();
|
|
expect(validateWorkdir).not.toHaveBeenCalled();
|
|
expect(mocks.nodeHostParams).toHaveLength(0);
|
|
});
|
|
|
|
it("fails invalid workdirs before resolving exec env", async () => {
|
|
installResolveExecEnvHook({ PLUGIN_SAFE: "yes" });
|
|
const tool = createExecTool({
|
|
host: "gateway",
|
|
security: "full",
|
|
ask: "off",
|
|
});
|
|
|
|
const result = await tool.execute("call-invalid-cwd-before-env", {
|
|
command: "echo ok",
|
|
workdir: " ",
|
|
});
|
|
|
|
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
|
expect(mocks.hookRunner?.runResolveExecEnv).not.toHaveBeenCalled();
|
|
expect(mocks.gatewayParams).toHaveLength(0);
|
|
expect(mocks.spawnInputs).toHaveLength(0);
|
|
});
|
|
|
|
it("prevalidates gateway workdirs before resolving exec env when a backend sandbox exists", async () => {
|
|
installResolveExecEnvHook({ PLUGIN_SAFE: "yes" });
|
|
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
|
const tool = createExecTool({
|
|
host: "gateway",
|
|
security: "full",
|
|
ask: "off",
|
|
sandbox: {
|
|
containerName: "remote-sandbox-workdir-test",
|
|
workspaceDir: process.cwd(),
|
|
containerWorkdir: "/remote/workspace",
|
|
workdirValidation: "backend",
|
|
validateWorkdir,
|
|
},
|
|
});
|
|
|
|
const result = await tool.execute("call-gateway-invalid-cwd-with-backend-sandbox", {
|
|
command: "echo ok",
|
|
workdir: " ",
|
|
});
|
|
|
|
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
|
expect(mocks.hookRunner?.runResolveExecEnv).not.toHaveBeenCalled();
|
|
expect(validateWorkdir).not.toHaveBeenCalled();
|
|
expect(mocks.gatewayParams).toHaveLength(0);
|
|
expect(mocks.spawnInputs).toHaveLength(0);
|
|
});
|
|
|
|
it("lets before_tool_call see invalid wrapped workdirs before failing unchanged params", 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 () => undefined),
|
|
};
|
|
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",
|
|
});
|
|
|
|
const result = await definition.execute(
|
|
"call-invalid-wrapped-cwd-before-hooks",
|
|
{
|
|
command: "echo ok",
|
|
workdir: " ",
|
|
},
|
|
undefined,
|
|
undefined,
|
|
testExtensionContext,
|
|
);
|
|
const text = result.content.find((entry) => entry.type === "text")?.text ?? "";
|
|
|
|
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
|
expect(text).toContain('workdir " " is unavailable or not a directory');
|
|
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledTimes(1);
|
|
expect(mocks.hookRunner.runResolveExecEnv!).not.toHaveBeenCalled();
|
|
expect(mocks.gatewayParams).toHaveLength(0);
|
|
expect(mocks.spawnInputs).toHaveLength(0);
|
|
});
|
|
|
|
it("does not validate backend sandbox workdirs before before_tool_call veto", async () => {
|
|
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
|
mocks.hookRunner = {
|
|
hasHooks: vi.fn((hookName: string) => hookName === "before_tool_call"),
|
|
runBeforeToolCall: vi.fn(async () => ({
|
|
block: true,
|
|
blockReason: "blocked by test hook",
|
|
})),
|
|
};
|
|
const tool = createExecTool({
|
|
host: "sandbox",
|
|
security: "full",
|
|
ask: "off",
|
|
sandbox: {
|
|
containerName: "remote-sandbox-workdir-test",
|
|
workspaceDir: process.cwd(),
|
|
containerWorkdir: "/remote/workspace",
|
|
workdirValidation: "backend",
|
|
validateWorkdir,
|
|
},
|
|
});
|
|
const [definition] = toToolDefinitions([tool], {
|
|
agentId: "main",
|
|
sessionKey: "agent:main:telegram:chat-1",
|
|
});
|
|
|
|
const result = await definition.execute(
|
|
"call-backend-cwd-vetoed-before-validation",
|
|
{
|
|
command: "echo ok",
|
|
workdir: "/remote/workspace/generated",
|
|
},
|
|
undefined,
|
|
undefined,
|
|
testExtensionContext,
|
|
);
|
|
|
|
expect(
|
|
result.details as { status?: unknown; deniedReason?: unknown } | undefined,
|
|
).toMatchObject({
|
|
status: "blocked",
|
|
deniedReason: "plugin-before-tool-call",
|
|
});
|
|
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce();
|
|
expect(validateWorkdir).not.toHaveBeenCalled();
|
|
expect(mocks.gatewayParams).toHaveLength(0);
|
|
expect(mocks.spawnInputs).toHaveLength(0);
|
|
});
|
|
|
|
it("defers resolve_exec_env for backend sandboxes until workdir validation succeeds", async () => {
|
|
const validateWorkdir = vi.fn(async () => null);
|
|
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 () => undefined),
|
|
};
|
|
const tool = createExecTool({
|
|
host: "sandbox",
|
|
security: "full",
|
|
ask: "off",
|
|
sandbox: {
|
|
containerName: "remote-sandbox-workdir-test",
|
|
workspaceDir: process.cwd(),
|
|
containerWorkdir: "/remote/workspace",
|
|
workdirValidation: "backend",
|
|
validateWorkdir,
|
|
},
|
|
});
|
|
const [definition] = toToolDefinitions([tool], {
|
|
agentId: "main",
|
|
sessionKey: "agent:main:telegram:chat-1",
|
|
});
|
|
|
|
const result = await definition.execute(
|
|
"call-backend-invalid-cwd-before-env",
|
|
{
|
|
command: "echo ok",
|
|
workdir: "/remote/workspace/missing",
|
|
},
|
|
undefined,
|
|
undefined,
|
|
testExtensionContext,
|
|
);
|
|
|
|
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
|
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce();
|
|
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/missing");
|
|
expect(mocks.hookRunner.runResolveExecEnv!).not.toHaveBeenCalled();
|
|
expect(mocks.gatewayParams).toHaveLength(0);
|
|
expect(mocks.spawnInputs).toHaveLength(0);
|
|
});
|
|
|
|
it("preserves hook context when backend sandbox env resolution is deferred", async () => {
|
|
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
|
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
|
|
async (params) => ({
|
|
argv: ["remote-shell", params.command],
|
|
env: {},
|
|
stdinMode: "pipe-open" as const,
|
|
}),
|
|
);
|
|
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 () => undefined),
|
|
};
|
|
const tool = createExecTool({
|
|
host: "sandbox",
|
|
security: "full",
|
|
ask: "off",
|
|
sandbox: {
|
|
containerName: "remote-sandbox-workdir-test",
|
|
workspaceDir: process.cwd(),
|
|
containerWorkdir: "/remote/workspace",
|
|
workdirValidation: "backend",
|
|
validateWorkdir,
|
|
buildExecSpec,
|
|
},
|
|
});
|
|
const [definition] = toToolDefinitions([tool], {
|
|
agentId: "ctx-agent",
|
|
sessionKey: "agent:ctx-agent:telegram:chat-2",
|
|
channelId: "ctx-channel",
|
|
});
|
|
|
|
const result = await definition.execute(
|
|
"call-backend-deferred-env-context",
|
|
{
|
|
command: "echo ok",
|
|
workdir: "/remote/workspace/generated",
|
|
},
|
|
undefined,
|
|
undefined,
|
|
testExtensionContext,
|
|
);
|
|
|
|
expect((result.details as { status?: unknown } | undefined)?.status).toBe("completed");
|
|
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated");
|
|
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce();
|
|
expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledOnce();
|
|
expect(mocks.hookRunner.runResolveExecEnv!.mock.calls[0]?.[0]).toMatchObject({
|
|
sessionKey: "agent:ctx-agent:telegram:chat-2",
|
|
toolName: "exec",
|
|
host: "sandbox",
|
|
});
|
|
expect(mocks.hookRunner.runResolveExecEnv!.mock.calls[0]?.[1]).toMatchObject({
|
|
agentId: "ctx-agent",
|
|
sessionKey: "agent:ctx-agent:telegram:chat-2",
|
|
channelId: "ctx-channel",
|
|
});
|
|
expect(buildExecSpec.mock.calls[0]?.[0]?.env).toMatchObject({
|
|
PLUGIN_SAFE: "yes",
|
|
});
|
|
});
|
|
|
|
it("lets lazy before_tool_call see invalid workdirs before failing unchanged params", 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 () => 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",
|
|
});
|
|
|
|
const result = await definition.execute(
|
|
"call-invalid-lazy-cwd-before-hooks",
|
|
{
|
|
command: "echo ok",
|
|
workdir: " ",
|
|
},
|
|
undefined,
|
|
undefined,
|
|
testExtensionContext,
|
|
);
|
|
const text = result.content.find((entry) => entry.type === "text")?.text ?? "";
|
|
|
|
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
|
expect(text).toContain('workdir " " is unavailable or not a directory');
|
|
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledTimes(1);
|
|
expect(mocks.hookRunner.runResolveExecEnv!).not.toHaveBeenCalled();
|
|
expect(mocks.gatewayParams).toHaveLength(0);
|
|
expect(mocks.spawnInputs).toHaveLength(0);
|
|
});
|
|
|
|
it("forwards explicit node host workdirs without local gateway validation", async () => {
|
|
const remoteWorkdir = "/remote/node/workspace";
|
|
const tool = createExecTool({
|
|
host: "node",
|
|
security: "full",
|
|
ask: "off",
|
|
});
|
|
|
|
await tool.execute("call-node-explicit-cwd", {
|
|
command: "echo ok",
|
|
workdir: remoteWorkdir,
|
|
});
|
|
|
|
expect(mocks.nodeHostParams[0]?.workdir).toBe(remoteWorkdir);
|
|
});
|
|
|
|
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 reroute gateway-invalid workdirs to node host execution", 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-with-remote-cwd",
|
|
{
|
|
command: "echo ok",
|
|
env: { REQUEST_SAFE: "request" },
|
|
workdir: "/remote/node/workspace",
|
|
},
|
|
undefined,
|
|
undefined,
|
|
testExtensionContext,
|
|
);
|
|
|
|
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce();
|
|
expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledOnce();
|
|
expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledWith(
|
|
expect.objectContaining({ host: "node" }),
|
|
expect.anything(),
|
|
);
|
|
expect(mocks.nodeHostParams[0]?.requestedEnv).toEqual({
|
|
NODE_PLUGIN_SAFE: "node",
|
|
REQUEST_SAFE: "request",
|
|
});
|
|
expect(mocks.nodeHostParams[0]?.workdir).toBe("/remote/node/workspace");
|
|
expect(mocks.gatewayParams).toHaveLength(0);
|
|
expect(mocks.spawnInputs).toHaveLength(0);
|
|
});
|
|
|
|
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("skips stale hook runners that report resolve_exec_env without the runner method", async () => {
|
|
mocks.hookRunner = {
|
|
hasHooks: vi.fn((hookName: string) => hookName === "resolve_exec_env"),
|
|
};
|
|
|
|
const tool = createExecTool({
|
|
host: "gateway",
|
|
security: "full",
|
|
ask: "off",
|
|
sessionKey: "agent:main:telegram:chat-1",
|
|
});
|
|
await tool.execute("call-stale-hook-runner", {
|
|
command: "echo ok",
|
|
env: { REQUEST_SAFE: "request" },
|
|
yieldMs: 120_000,
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
});
|