import crypto from "node:crypto" ;
import fs from "node:fs/promises" ;
import os from "node:os" ;
import path from "node:path" ;
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" ;
import { sendMessage } from "../infra/outbound/message.js" ;
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js" ;
import { createExecTool } from "./bash-tools.exec.js" ;
import { callGatewayTool } from "./tools/gateway.js" ;
vi.mock("./tools/gateway.js" , () => ({
callGatewayTool: vi.fn(),
readGatewayCallOptions: vi.fn(() => ({})),
}));
vi.mock("./tools/nodes-utils.js" , () => ({
listNodes: vi.fn(async () => [
{ nodeId: "node-1" , commands: ["system.run" ], platform: "darwin" },
]),
resolveNodeIdFromList: vi.fn((nodes: Array<{ nodeId: string }>) => nodes[0 ]?.nodeId),
}));
vi.mock("../infra/outbound/message.js" , () => ({
sendMessage: vi.fn(async () => ({ ok: true })),
}));
vi.mock("../utils/message-channel.js" , () => {
const normalizeMessageChannel = (raw?: string | null ) => {
const normalized = raw?.trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (normalized === "web" || normalized === "webchat" ) {
return "internal" ;
}
return normalized;
};
const isGatewayMessageChannel = (value: string) => Boolean (normalizeMessageChannel(value));
return {
INTERNAL_MESSAGE_CHANNEL: "internal" ,
isDeliverableMessageChannel: (value: string) => {
const channel = normalizeMessageChannel(value);
return Boolean (channel && channel !== "internal" && channel !== "tui" );
},
isGatewayMessageChannel,
normalizeMessageChannel,
resolveGatewayMessageChannel: normalizeMessageChannel,
resolveMessageChannel: (primary?: string | null , fallback?: string | null ) =>
normalizeMessageChannel(primary) ?? normalizeMessageChannel(fallback),
};
});
vi.mock("../utils/delivery-context.js" , () => ({
normalizeDeliveryContext: (context?: {
channel?: string | null ;
to?: string | number | null ;
accountId?: string | null ;
threadId?: string | number | null ;
}) => {
if (!context) {
return undefined;
}
const channel = context.channel?.trim().toLowerCase();
const to = context.to == null ? undefined : String(context.to).trim();
const accountId = context.accountId?.trim();
const threadId = context.threadId == null ? undefined : context.threadId;
if (!channel && !to && !accountId && threadId == null ) {
return undefined;
}
return {
channel: channel || undefined,
to: to || undefined,
accountId: accountId || undefined,
...(threadId != null && threadId !== "" ? { threadId } : {}),
};
},
}));
vi.mock("../infra/exec-approval-surface.js" , () => ({
describeNativeExecApprovalClientSetup: () => null ,
listNativeExecApprovalClientLabels: () => [],
resolveExecApprovalInitiatingSurfaceState: (params: {
channel?: string | null ;
accountId?: string | null ;
}) => {
const channel = params.channel ?? undefined;
return {
kind: "enabled" ,
channel,
channelLabel:
channel === "tui" ? "terminal UI" : channel === "internal" ? "Web UI" : "this platform" ,
accountId: params.accountId ?? undefined,
};
},
supportsNativeExecApprovalClient: (channel?: string | null ) =>
!channel || channel === "internal" || channel === "tui" ,
}));
vi.mock("../infra/shell-env.js" , () => ({
getShellPathFromLoginShell: vi.fn(() => null ),
resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0 ),
}));
vi.mock("../process/supervisor/index.js" , () => {
const stdoutFor = (command: string) => {
if (
command.includes("calendar events primary --today --json" ) ||
command.includes("gog-wrapper" )
) {
return '{"events":[]}\n' ;
}
if (command.includes("printf delayed-ok" )) {
return "delayed-ok" ;
}
if (command.includes("printf webchat-ok" )) {
return "webchat-ok" ;
}
if (command.includes("printf approval-one" )) {
return "approval-one" ;
}
if (command.includes("printf approval-two" )) {
return "approval-two" ;
}
if (command.includes("echo allow-always" )) {
return "allow-always\n" ;
}
if (command.includes("echo cron-ok" )) {
return "cron-ok\n" ;
}
if (command.includes("echo ok" )) {
return "ok\n" ;
}
return "" ;
};
return {
getProcessSupervisor: () => ({
spawn: async (input: { argv?: string[]; onStdout?: (chunk: string) => void }) => {
const command = input.argv?.join(" " ) ?? "" ;
const stdout = stdoutFor(command);
if (stdout) {
input.onStdout?.(stdout);
}
return {
runId: "mock-approval-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(),
}),
};
});
function buildPreparedSystemRunPayload(rawInvokeParams: unknown) {
const invoke = (rawInvokeParams ?? {}) as {
params?: {
command?: unknown;
rawCommand?: unknown;
cwd?: unknown;
agentId?: unknown;
sessionKey?: unknown;
};
};
const params = invoke.params ?? {};
return buildSystemRunPreparePayload(params);
}
async function writeExecApprovalsConfig(config: Record<string, unknown>) {
const approvalsPath = path.join(process.env.HOME ?? "" , ".openclaw" , "exec-approvals.json" );
await fs.mkdir(path.dirname(approvalsPath), { recursive: true });
await fs.writeFile(approvalsPath, JSON.stringify(config, null , 2 ));
}
function acceptedApprovalResponse(params: unknown) {
return { status: "accepted" , id: (params as { id?: string })?.id };
}
function getResultText(result: { content: Array<{ type?: string; text?: string }> }) {
return result.content.find((part) => part.type === "text" )?.text ?? "" ;
}
function expectPendingApprovalText(
result: {
details: { status?: string };
content: Array<{ type?: string; text?: string }>;
},
options: {
command: string;
host: "gateway" | "node" ;
nodeId?: string;
interactive?: boolean ;
allowedDecisions?: string;
cwdText?: string;
},
) {
expect(result.details.status).toBe("approval-pending" );
const details = result.details as { approvalId: string; approvalSlug: string };
const pendingText = getResultText(result);
expect(pendingText).toContain(
`Reply with: /approve ${details.approvalSlug} ${options.allowedDecisions ?? "allow-once|allow-always|deny" }`,
);
expect(pendingText).toContain(`full ${details.approvalId}`);
expect(pendingText).toContain(`Host: ${options.host}`);
if (options.nodeId) {
expect(pendingText).toContain(`Node: ${options.nodeId}`);
}
expect(pendingText).toContain(`CWD: ${options.cwdText ?? process.cwd()}`);
expect(pendingText).toContain("Command:\n```sh\n" );
expect(pendingText).toContain(options.command);
if (options.interactive) {
expect(pendingText).toContain("Mode: foreground (interactive approvals available)." );
expect(pendingText).toContain(
(options.allowedDecisions ?? "" ).includes("allow-always" )
? "Background mode requires pre-approved policy"
: "Background mode requires an effective policy that allows pre-approval" ,
);
}
return details;
}
function expectPendingCommandText(
result: {
details: { status?: string };
content: Array<{ type?: string; text?: string }>;
},
command: string,
) {
expect(result.details.status).toBe("approval-pending" );
const text = getResultText(result);
expect(text).toContain("Command:\n```sh\n" );
expect(text).toContain(command);
}
function mockGatewayOkCalls(calls: string[]) {
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
return { ok: true };
});
}
function createElevatedAllowlistExecTool() {
return createExecTool({
ask: "on-miss" ,
security: "allowlist" ,
approvalRunningNoticeMs: 0 ,
elevated: { enabled: true , allowed: true , defaultLevel: "ask" },
});
}
async function expectGatewayExecWithoutApproval(options: {
config: Record<string, unknown>;
command: string;
ask?: "always" | "on-miss" | "off" ;
security?: "allowlist" | "full" ;
}) {
await writeExecApprovalsConfig(options.config);
const calls: string[] = [];
mockGatewayOkCalls(calls);
const tool = createExecTool({
host: "gateway" ,
ask: options.ask,
security: options.security,
approvalRunningNoticeMs: 0 ,
});
const result = await tool.execute("call-no-approval" , { command: options.command });
expect(result.details.status).toBe("completed" );
expect(calls).not.toContain("exec.approval.request" );
expect(calls).not.toContain("exec.approval.waitDecision" );
}
async function expectGatewayAskAlwaysPrompt(options: {
turnId: string;
command?: string;
allowlist?: Array<{ pattern: string; source?: "allow-always" }>;
}) {
await writeExecApprovalsConfig({
version: 1 ,
defaults: { security: "full" , ask: "always" , askFallback: "full" },
agents: {
main: options.allowlist ? { allowlist: options.allowlist } : {},
},
});
mockPendingApprovalRegistration();
const tool = createExecTool({
host: "gateway" ,
ask: "always" ,
security: "full" ,
approvalRunningNoticeMs: 0 ,
});
return await tool.execute(options.turnId, {
command: options.command ?? `${JSON.stringify(process.execPath)} --version`,
});
}
function mockAcceptedApprovalFlow(options: {
onAgent?: (params: Record<string, unknown>) => void ;
onNodeInvoke?: (params: unknown) => unknown;
}) {
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request" ) {
return acceptedApprovalResponse(params);
}
if (method === "exec.approval.waitDecision" ) {
return { decision: "allow-once" };
}
if (method === "agent" && options.onAgent) {
options.onAgent(params as Record<string, unknown>);
return { status: "ok" };
}
if (method === "node.invoke" && options.onNodeInvoke) {
return await options.onNodeInvoke(params);
}
return { ok: true };
});
}
function mockPendingApprovalRegistration() {
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
if (method === "exec.approval.request" ) {
return { status: "accepted" , id: "approval-id" };
}
if (method === "exec.approval.waitDecision" ) {
return { decision: null };
}
return { ok: true };
});
}
function mockNoApprovalRouteRegistration() {
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
if (method === "exec.approval.request" ) {
return { id: "approval-id" , decision: null };
}
if (method === "exec.approval.waitDecision" ) {
return { decision: null };
}
return { ok: true };
});
}
describe("exec approvals" , () => {
let previousHome: string | undefined;
let previousUserProfile: string | undefined;
let previousBundledPluginsDir: string | undefined;
let previousDisableBundledPlugins: string | undefined;
let tempRoot = "" ;
let tempCaseIndex = 0 ;
beforeAll(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-exec-approvals-" ));
});
beforeEach(async () => {
previousHome = process.env.HOME;
previousUserProfile = process.env.USERPROFILE;
previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
previousDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
const tempDir = path.join(tempRoot, `case-${++tempCaseIndex}`);
await fs.mkdir(tempDir, { recursive: true });
process.env.HOME = tempDir;
// Windows uses USERPROFILE for os.homedir()
process.env.USERPROFILE = tempDir;
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1" ;
vi.mocked(callGatewayTool).mockReset();
vi.mocked(sendMessage).mockClear();
});
afterEach(() => {
vi.clearAllMocks();
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
if (previousUserProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previousUserProfile;
}
if (previousBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir;
}
if (previousDisableBundledPlugins === undefined) {
delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
} else {
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = previousDisableBundledPlugins;
}
});
afterAll(async () => {
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true , force: true , maxRetries: 5 , retryDelay: 20 });
}
});
it("reuses approval id as the node runId" , async () => {
let invokeParams: unknown;
let agentParams: unknown;
mockAcceptedApprovalFlow({
onAgent: (params) => {
agentParams = params;
},
onNodeInvoke: (params) => {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare" ) {
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run" ) {
invokeParams = params;
return { payload: { success: true , stdout: "ok" } };
}
return undefined;
},
});
const tool = createExecTool({
host: "node" ,
ask: "always" ,
approvalRunningNoticeMs: 0 ,
sessionKey: "agent:main:main" ,
});
const result = await tool.execute("call1" , { command: "ls -la" });
const details = expectPendingApprovalText(result, {
command: "ls -la" ,
host: "node" ,
nodeId: "node-1" ,
interactive: true ,
allowedDecisions: "allow-once|deny" ,
cwdText: "(node default)" ,
});
const approvalId = details.approvalId;
await expect
.poll(() => (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId, {
timeout: 2000 ,
interval: 1 ,
})
.toBe(approvalId);
expect(
(invokeParams as { params?: { suppressNotifyOnExit?: boolean } } | undefined)?.params,
).toMatchObject({
suppressNotifyOnExit: true ,
});
await expect.poll(() => agentParams, { timeout: 2000 , interval: 1 }).toBeTruthy();
});
it("skips approval when node allowlist is satisfied" , async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-bin-" ));
const binDir = path.join(tempDir, "bin" );
await fs.mkdir(binDir, { recursive: true });
const exeName = process.platform === "win32" ? "tool.cmd" : "tool" ;
const exePath = path.join(binDir, exeName);
await fs.writeFile(exePath, "" );
if (process.platform !== "win32" ) {
await fs.chmod(exePath, 0 o755);
}
const approvalsFile = {
version: 1 ,
defaults: { security: "allowlist" , ask: "on-miss" , askFallback: "deny" },
agents: {
main: {
allowlist: [{ pattern: exePath }],
},
},
};
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approvals.node.get" ) {
return { file: approvalsFile };
}
if (method === "node.invoke" ) {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare" ) {
return buildPreparedSystemRunPayload(params);
}
return { payload: { success: true , stdout: "ok" } };
}
// exec.approval.request should NOT be called when allowlist is satisfied
return { ok: true };
});
const tool = createExecTool({
host: "node" ,
security: "allowlist" ,
ask: "on-miss" ,
approvalRunningNoticeMs: 0 ,
});
const result = await tool.execute("call2" , {
command: `"${exePath}" --help`,
});
expect(result.details.status).toBe("completed" );
expect(calls).toContain("exec.approvals.node.get" );
expect(calls).toContain("node.invoke" );
expect(calls).not.toContain("exec.approval.request" );
});
it("preserves explicit workdir for node exec" , async () => {
const remoteWorkdir = "/Users/vv" ;
let prepareCwd: string | undefined;
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "node.invoke" ) {
const invoke = params as { command?: string; params?: { cwd?: string } };
if (invoke.command === "system.run.prepare" ) {
prepareCwd = invoke.params?.cwd;
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run" ) {
return { payload: { success: true , stdout: "ok" } };
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node" ,
ask: "off" ,
security: "full" ,
approvalRunningNoticeMs: 0 ,
});
const result = await tool.execute("call-node-cwd" , {
command: "/bin/pwd" ,
workdir: remoteWorkdir,
});
expect(result.details.status).toBe("completed" );
expect(prepareCwd).toBe(remoteWorkdir);
});
it("does not forward the gateway default cwd to node exec when workdir is omitted" , async () => {
const gatewayWorkspace = "/gateway/workspace" ;
let prepareHasCwd = false ;
let prepareCwd: string | undefined;
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "node.invoke" ) {
const invoke = params as { command?: string; params?: { cwd?: string } };
if (invoke.command === "system.run.prepare" ) {
prepareHasCwd = Object.hasOwn(invoke.params ?? {}, "cwd" );
prepareCwd = invoke.params?.cwd;
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run" ) {
return { payload: { success: true , stdout: "ok" } };
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node" ,
ask: "off" ,
security: "full" ,
approvalRunningNoticeMs: 0 ,
cwd: gatewayWorkspace,
});
const result = await tool.execute("call-node-default-cwd" , {
command: "/bin/pwd" ,
});
expect(result.details.status).toBe("completed" );
expect(prepareHasCwd).toBe(false );
expect(prepareCwd).toBeUndefined();
});
it("routes explicit host=node to node invoke when elevated default is on under auto host" , async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "node.invoke" ) {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare" ) {
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run" ) {
return { payload: { success: true , stdout: "node-ok" } };
}
}
return { ok: true };
});
const tool = createExecTool({
host: "auto" ,
ask: "off" ,
security: "full" ,
approvalRunningNoticeMs: 0 ,
elevated: { enabled: true , allowed: true , defaultLevel: "on" },
});
const result = await tool.execute("call-auto-node-elevated-default" , {
command: "echo gateway-ok" ,
host: "node" ,
});
expect(result.details.status).toBe("completed" );
expect(getResultText(result)).toContain("node-ok" );
expect(calls).toContain("node.invoke" );
});
it("honors ask=off for elevated gateway exec without prompting" , async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
return { ok: true };
});
const tool = createExecTool({
ask: "off" ,
security: "full" ,
approvalRunningNoticeMs: 0 ,
elevated: { enabled: true , allowed: true , defaultLevel: "ask" },
});
const result = await tool.execute("call3" , { command: "echo ok" , elevated: true });
expect(result.details.status).toBe("completed" );
expect(calls).not.toContain("exec.approval.request" );
});
it("uses exec-approvals defaults to suppress gateway prompts" , async () => {
const cases: Array<{
config: Record<string, unknown>;
ask?: "always" | "on-miss" | "off" ;
security?: "allowlist" | "full" ;
}> = [
{
config: {
version: 1 ,
defaults: { security: "full" , ask: "off" , askFallback: "full" },
agents: {
main: { security: "full" , ask: "off" , askFallback: "full" },
},
},
ask: "on-miss" ,
},
{
config: {
version: 1 ,
defaults: { security: "full" , ask: "off" , askFallback: "full" },
agents: {},
},
},
{
config: {
version: 1 ,
defaults: { security: "full" , ask: "off" , askFallback: "full" },
agents: {},
},
security: undefined,
},
];
for (const testCase of cases) {
await expectGatewayExecWithoutApproval({
...testCase,
command: "echo ok" ,
});
}
});
it("keeps ask=always prompts for durable and static allowlist entries" , async () => {
const durable = await expectGatewayAskAlwaysPrompt({
turnId: "call-gateway-durable-still-prompts" ,
allowlist: [{ pattern: process.execPath, source: "allow-always" }],
});
expect(durable.details.status).toBe("approval-pending" );
expect(durable.details).toMatchObject({
allowedDecisions: ["allow-once" , "deny" ],
});
expect(getResultText(durable)).toContain("Reply with: /approve " );
expect(getResultText(durable)).toContain("allow-once|deny" );
expect(getResultText(durable)).not.toContain("allow-once|allow-always|deny" );
expect(getResultText(durable)).toContain("Allow Always is unavailable" );
const staticAllowlist = await expectGatewayAskAlwaysPrompt({
turnId: "call-static-allowlist-still-prompts" ,
allowlist: [{ pattern: process.execPath }],
});
expect(staticAllowlist.details.status).toBe("approval-pending" );
});
it("reuses gateway allow-always approvals for repeated exact commands" , async () => {
await writeExecApprovalsConfig({
version: 1 ,
defaults: { security: "allowlist" , ask: "on-miss" , askFallback: "deny" },
agents: {},
});
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approval.request" ) {
return acceptedApprovalResponse(params);
}
if (method === "exec.approval.waitDecision" ) {
return { decision: "allow-always" };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway" ,
ask: "on-miss" ,
security: "allowlist" ,
approvalRunningNoticeMs: 0 ,
});
const command = "echo allow-always" ;
const first = await tool.execute("call-gateway-allow-always-initial" , {
command,
});
expect(first.details.status).toBe("approval-pending" );
expect(calls).toContain("exec.approval.request" );
expect(calls).toContain("exec.approval.waitDecision" );
const approvalsPath = path.join(process.env.HOME ?? "" , ".openclaw" , "exec-approvals.json" );
await expect
.poll(
async () => {
try {
const raw = await fs.readFile(approvalsPath, "utf8" );
const parsed = JSON.parse(raw) as {
agents?: { main?: { allowlist?: Array<{ source?: string }> } };
};
return (
parsed.agents?.main?.allowlist?.some((entry) => entry.source === "allow-always" ) ===
true
);
} catch {
return false ;
}
},
{ timeout: 2000 , interval: 1 },
)
.toBe(true );
calls.length = 0 ;
const second = await tool.execute("call-gateway-allow-always-repeat" , {
command,
});
expect(second.details.status).toBe("completed" );
expect(calls).not.toContain("exec.approval.request" );
expect(calls).not.toContain("exec.approval.waitDecision" );
});
it("keeps ask=always prompts for node-host runs even with durable trust" , async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approvals.node.get" ) {
return {
file: {
version: 1 ,
agents: {
main: {
allowlist: [{ pattern: process.execPath, source: "allow-always" }],
},
},
},
};
}
if (method === "node.invoke" ) {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare" ) {
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run" ) {
return { payload: { success: true , stdout: "node-ok" } };
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node" ,
ask: "always" ,
security: "full" ,
approvalRunningNoticeMs: 0 ,
});
const result = await tool.execute("call-node-durable-allow-always" , {
command: `${JSON.stringify(process.execPath)} --version`,
});
expect(result.details.status).toBe("approval-pending" );
expect(result.details).toMatchObject({
allowedDecisions: ["allow-once" , "deny" ],
});
expect(calls).toContain("exec.approval.request" );
});
it("reuses exact-command durable trust for node shell-wrapper reruns" , async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approvals.node.get" ) {
const prepared = buildPreparedSystemRunPayload({
params: { command: ["/bin/sh" , "-lc" , "cd ." ], cwd: process.cwd() },
}) as { payload?: { plan?: { commandText?: string } } };
const commandText = prepared.payload?.plan?.commandText ?? "" ;
return {
file: {
version: 1 ,
agents: {
main: {
allowlist: [
{
pattern: `=command:${crypto
.createHash("sha256" )
.update(commandText)
.digest("hex" )
.slice(0 , 16 )}`,
source: "allow-always" ,
},
],
},
},
},
};
}
if (method === "node.invoke" ) {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare" ) {
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run" ) {
return { payload: { success: true , stdout: "node-shell-wrapper-ok" } };
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node" ,
ask: "on-miss" ,
security: "allowlist" ,
approvalRunningNoticeMs: 0 ,
});
const result = await tool.execute("call-node-shell-wrapper-durable-allow-always" , {
command: "cd ." ,
});
expect(result.details.status).toBe("completed" );
expect(getResultText(result)).toContain("node-shell-wrapper-ok" );
expect(calls).not.toContain("exec.approval.request" );
expect(calls).not.toContain("exec.approval.waitDecision" );
});
it("requires approval for elevated ask when allowlist misses" , async () => {
const calls: string[] = [];
let resolveApproval: (() => void ) | undefined;
const approvalSeen = new Promise<void >((resolve) => {
resolveApproval = resolve;
});
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approval.request" ) {
resolveApproval?.();
// Return registration confirmation
return acceptedApprovalResponse(params);
}
if (method === "exec.approval.waitDecision" ) {
return { decision: "deny" };
}
return { ok: true };
});
const tool = createElevatedAllowlistExecTool();
const result = await tool.execute("call4" , { command: "echo ok" , elevated: true });
expectPendingApprovalText(result, { command: "echo ok" , host: "gateway" });
await approvalSeen;
expect(calls).toContain("exec.approval.request" );
expect(calls).toContain("exec.approval.waitDecision" );
});
it("starts an internal agent follow-up after approved gateway exec completes without an external route" , async () => {
const agentCalls: Array<Record<string, unknown>> = [];
mockAcceptedApprovalFlow({
onAgent: (params) => {
agentCalls.push(params);
},
});
const tool = createExecTool({
host: "gateway" ,
ask: "always" ,
approvalRunningNoticeMs: 0 ,
sessionKey: "agent:main:main" ,
elevated: { enabled: true , allowed: true , defaultLevel: "ask" },
});
const result = await tool.execute("call-gw-followup" , {
command: "echo ok" ,
workdir: process.cwd(),
});
expect(result.details.status).toBe("approval-pending" );
await expect.poll(() => agentCalls.length, { timeout: 3000 , interval: 1 }).toBe(1 );
expect(agentCalls[0 ]).toEqual(
expect.objectContaining({
sessionKey: "agent:main:main" ,
deliver: false ,
idempotencyKey: expect.stringContaining("exec-approval-followup:" ),
}),
);
expect(typeof agentCalls[0 ]?.message).toBe("string" );
expect(agentCalls[0 ]?.message).toContain(
"An async command the user already approved has completed." ,
);
});
it("continues the original agent session after approved gateway exec completes with an external route" , async () => {
const agentCalls: Array<Record<string, unknown>> = [];
mockAcceptedApprovalFlow({
onAgent: (params) => {
agentCalls.push(params);
},
});
const tool = createExecTool({
host: "gateway" ,
ask: "always" ,
approvalRunningNoticeMs: 0 ,
sessionKey: "agent:main:discord:channel:123" ,
elevated: { enabled: true , allowed: true , defaultLevel: "ask" },
messageProvider: "discord" ,
currentChannelId: "123" ,
accountId: "default" ,
currentThreadTs: "456" ,
});
const result = await tool.execute("call-gw-followup-discord" , {
command: "echo ok" ,
workdir: process.cwd(),
});
expect(result.details.status).toBe("approval-pending" );
await expect.poll(() => agentCalls.length, { timeout: 3000 , interval: 1 }).toBe(1 );
expect(agentCalls[0 ]).toEqual(
expect.objectContaining({
sessionKey: "agent:main:discord:channel:123" ,
deliver: true ,
bestEffortDeliver: true ,
channel: "discord" ,
to: "123" ,
accountId: "default" ,
threadId: "456" ,
idempotencyKey: expect.stringContaining("exec-approval-followup:" ),
}),
);
expect(typeof agentCalls[0 ]?.message).toBe("string" );
expect(agentCalls[0 ]?.message).toContain(
"If the task requires more steps, continue from this result before replying to the user." ,
);
expect(sendMessage).not.toHaveBeenCalled();
});
it("auto-continues the same Discord session after approval resolves without a second user turn" , async () => {
const agentCalls: Array<Record<string, unknown>> = [];
let resolveDecision: ((value: { decision: string }) => void ) | undefined;
const decisionPromise = new Promise<{ decision: string }>((resolve) => {
resolveDecision = resolve;
});
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request" ) {
return acceptedApprovalResponse(params);
}
if (method === "exec.approval.waitDecision" ) {
return await decisionPromise;
}
if (method === "agent" ) {
agentCalls.push(params as Record<string, unknown>);
return { status: "ok" };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway" ,
ask: "always" ,
approvalRunningNoticeMs: 0 ,
sessionKey: "agent:main:discord:channel:123" ,
elevated: { enabled: true , allowed: true , defaultLevel: "ask" },
messageProvider: "discord" ,
currentChannelId: "123" ,
accountId: "default" ,
currentThreadTs: "456" ,
});
const result = await tool.execute("call-gw-followup-discord-delayed" , {
command: "printf delayed-ok" ,
workdir: process.cwd(),
});
expect(result.details.status).toBe("approval-pending" );
expect(agentCalls).toHaveLength(0 );
resolveDecision?.({ decision: "allow-once" });
await expect.poll(() => agentCalls.length, { timeout: 3000 , interval: 1 }).toBe(1 );
expect(agentCalls[0 ]).toEqual(
expect.objectContaining({
sessionKey: "agent:main:discord:channel:123" ,
deliver: true ,
bestEffortDeliver: true ,
channel: "discord" ,
to: "123" ,
accountId: "default" ,
threadId: "456" ,
}),
);
expect(typeof agentCalls[0 ]?.message).toBe("string" );
expect(agentCalls[0 ]?.message).toContain(
"If the task requires more steps, continue from this result before replying to the user." ,
);
expect(agentCalls[0 ]?.message).toContain("delayed-ok" );
expect(sendMessage).not.toHaveBeenCalled();
});
it("executes approved commands and emits a session-only followup in webchat-only mode" , async () => {
const agentCalls: Array<Record<string, unknown>> = [];
mockAcceptedApprovalFlow({
onAgent: (params) => {
agentCalls.push(params);
},
});
const tool = createExecTool({
host: "gateway" ,
ask: "always" ,
approvalRunningNoticeMs: 0 ,
sessionKey: "agent:main:main" ,
elevated: { enabled: true , allowed: true , defaultLevel: "ask" },
});
const result = await tool.execute("call-gw-followup-webchat" , {
command: "printf webchat-ok" ,
workdir: process.cwd(),
});
expect(result.details.status).toBe("approval-pending" );
await expect.poll(() => agentCalls.length, { timeout: 3000 , interval: 1 }).toBe(1 );
expect(agentCalls[0 ]).toEqual(
expect.objectContaining({
sessionKey: "agent:main:main" ,
deliver: false ,
}),
);
expect(agentCalls[0 ]?.message).toContain("webchat-ok" );
});
it("uses a deny-specific followup prompt so prior output is not reused" , async () => {
const agentCalls: Array<Record<string, unknown>> = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request" ) {
return acceptedApprovalResponse(params);
}
if (method === "exec.approval.waitDecision" ) {
return { decision: "deny" };
}
if (method === "agent" ) {
agentCalls.push(params as Record<string, unknown>);
return { status: "ok" };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway" ,
ask: "always" ,
approvalRunningNoticeMs: 0 ,
sessionKey: "agent:main:main" ,
elevated: { enabled: true , allowed: true , defaultLevel: "ask" },
});
const result = await tool.execute("call-gw-followup-deny" , {
command: "echo ok" ,
workdir: process.cwd(),
});
expect(result.details.status).toBe("approval-pending" );
await expect.poll(() => agentCalls.length, { timeout: 3000 , interval: 1 }).toBe(1 );
expect(typeof agentCalls[0 ]?.message).toBe("string" );
expect(agentCalls[0 ]?.message).toContain("An async command did not run." );
expect(agentCalls[0 ]?.message).toContain(
"Do not mention, summarize, or reuse output from any earlier run in this session." ,
);
expect(agentCalls[0 ]?.message).not.toContain(
"An async command the user already approved has completed." ,
);
});
it("requires a separate approval for each elevated command after allow-once" , async () => {
const requestCommands: string[] = [];
const requestIds: string[] = [];
const waitIds: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request" ) {
const request = params as { id?: string; command?: string };
if (typeof request.command === "string" ) {
requestCommands.push(request.command);
}
if (typeof request.id === "string" ) {
requestIds.push(request.id);
}
return acceptedApprovalResponse(request);
}
if (method === "exec.approval.waitDecision" ) {
const wait = params as { id?: string };
if (typeof wait.id === "string" ) {
waitIds.push(wait.id);
}
return { decision: "allow-once" };
}
return { ok: true };
});
const tool = createElevatedAllowlistExecTool();
const first = await tool.execute("call-seq-1" , {
command: "printf approval-one" ,
elevated: true ,
});
const second = await tool.execute("call-seq-2" , {
command: "printf approval-two" ,
elevated: true ,
});
expect(first.details.status).toBe("approval-pending" );
expect(second.details.status).toBe("approval-pending" );
expect(requestCommands).toEqual(["printf approval-one" , "printf approval-two" ]);
expect(requestIds).toHaveLength(2 );
expect(requestIds[0 ]).not.toBe(requestIds[1 ]);
expect(waitIds).toEqual(requestIds);
});
it("shows full chained gateway commands in approval-pending message" , async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approval.request" ) {
return acceptedApprovalResponse(params);
}
if (method === "exec.approval.waitDecision" ) {
return { decision: "deny" };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway" ,
ask: "on-miss" ,
security: "allowlist" ,
approvalRunningNoticeMs: 0 ,
});
const result = await tool.execute("call-chain-gateway" , {
command: "npm view diver --json | jq .name && brew outdated" ,
});
expectPendingCommandText(result, "npm view diver --json | jq .name && brew outdated" );
expect(calls).toContain("exec.approval.request" );
});
it("runs a skill wrapper chain without prompting when the wrapper is allowlisted" , async () => {
if (process.platform === "win32" ) {
return ;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skill-wrapper-" ));
try {
const skillDir = path.join(tempDir, ".openclaw" , "skills" , "gog" );
const skillPath = path.join(skillDir, "SKILL.md" );
const binDir = path.join(tempDir, "bin" );
const wrapperPath = path.join(binDir, "gog-wrapper" );
await fs.mkdir(skillDir, { recursive: true });
await fs.mkdir(binDir, { recursive: true });
await fs.writeFile(skillPath, "# gog skill\n" );
await fs.writeFile(wrapperPath, "#!/bin/sh\necho '{\" events\":[]}'\n" );
await fs.chmod(wrapperPath, 0 o755);
await writeExecApprovalsConfig({
version: 1 ,
defaults: { security: "allowlist" , ask: "off" , askFallback: "deny" },
agents: {
main: {
allowlist: [{ pattern: wrapperPath }],
},
},
});
const calls: string[] = [];
mockGatewayOkCalls(calls);
const tool = createExecTool({
host: "gateway" ,
ask: "off" ,
security: "allowlist" ,
approvalRunningNoticeMs: 0 ,
});
const result = await tool.execute("call-skill-wrapper" , {
command: `cat ${JSON.stringify(skillPath)} && printf '\\n---CMD---\\n' && ${JSON.stringify(wrapperPath)} calendar events primary --today --json`,
workdir: tempDir,
});
expect(result.details.status).toBe("completed" );
expect(getResultText(result)).toContain('{"events":[]}' );
expect(calls).not.toContain("exec.approval.request" );
} finally {
await fs.rm(tempDir, { recursive: true , force: true });
}
});
it("shows full chained node commands in approval-pending message" , async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "node.invoke" ) {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare" ) {
return buildPreparedSystemRunPayload(params);
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node" ,
ask: "always" ,
security: "full" ,
approvalRunningNoticeMs: 0 ,
});
const result = await tool.execute("call-chain-node" , {
command: "npm view diver --json | jq .name && brew outdated" ,
});
expectPendingCommandText(result, "npm view diver --json | jq .name && brew outdated" );
expect(calls).toContain("exec.approval.request" );
});
it("waits for approval registration before returning approval-pending" , async () => {
const calls: string[] = [];
let resolveRegistration: ((value: unknown) => void ) | undefined;
const registrationPromise = new Promise<unknown>((resolve) => {
resolveRegistration = resolve;
});
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approval.request" ) {
return await registrationPromise;
}
if (method === "exec.approval.waitDecision" ) {
return { decision: "deny" };
}
return { ok: true , id: (params as { id?: string })?.id };
});
const tool = createExecTool({
host: "gateway" ,
ask: "on-miss" ,
security: "allowlist" ,
approvalRunningNoticeMs: 0 ,
});
let settled = false ;
const executePromise = tool.execute("call-registration-gate" , { command: "echo register" });
void executePromise.finally (() => {
settled = true ;
});
await Promise.resolve();
await Promise.resolve();
expect(settled).toBe(false );
resolveRegistration?.({ status: "accepted" , id: "approval-id" });
const result = await executePromise;
expect(result.details.status).toBe("approval-pending" );
expect(calls[0 ]).toBe("exec.approval.request" );
expect(calls).toContain("exec.approval.waitDecision" );
});
it("fails fast when approval registration fails" , async () => {
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
if (method === "exec.approval.request" ) {
throw new Error("gateway offline" );
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway" ,
ask: "on-miss" ,
security: "allowlist" ,
approvalRunningNoticeMs: 0 ,
});
await expect(tool.execute("call-registration-fail" , { command: "echo fail" })).rejects.toThrow(
"Exec approval registration failed" ,
);
});
it("resolves cron no-route approvals inline when askFallback permits trusted automation" , async () => {
await writeExecApprovalsConfig({
version: 1 ,
defaults: { security: "full" , ask: "always" , askFallback: "full" },
agents: {},
});
mockNoApprovalRouteRegistration();
const tool = createExecTool({
host: "gateway" ,
ask: "always" ,
security: "full" ,
trigger: "cron" ,
approvalRunningNoticeMs: 0 ,
});
const result = await tool.execute("call-cron-inline-approval" , {
command: "echo cron-ok" ,
});
expect(result.details.status).toBe("completed" );
expect(getResultText(result)).toContain("cron-ok" );
expect(vi.mocked(callGatewayTool)).toHaveBeenCalledWith(
"exec.approval.request" ,
expect.anything(),
expect.anything(),
expect.objectContaining({ expectFinal: false }),
);
expect(
vi
.mocked(callGatewayTool)
.mock.calls.some(([method]) => method === "exec.approval.waitDecision" ),
).toBe(false );
});
it("forwards inline cron approval state to node system.run" , async () => {
await writeExecApprovalsConfig({
version: 1 ,
defaults: { security: "full" , ask: "always" , askFallback: "full" },
agents: {},
});
mockNoApprovalRouteRegistration();
let systemRunInvoke: unknown;
const preparedPlan = {
argv: ["/bin/sh" , "-lc" , "echo cron-node-ok" ],
cwd: null ,
commandText: "/bin/sh -lc 'echo cron-node-ok'" ,
commandPreview: "echo cron-node-ok" ,
agentId: null ,
sessionKey: null ,
mutableFileOperand: {
argvIndex: 2 ,
path: "/tmp/cron-node-ok.sh" ,
sha256: "deadbeef" ,
},
};
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request" ) {
return { id: "approval-id" , decision: null };
}
if (method === "exec.approval.waitDecision" ) {
return { decision: null };
}
if (method === "node.invoke" ) {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare" ) {
return {
payload: {
plan: preparedPlan,
},
};
}
if (invoke.command === "system.run" ) {
systemRunInvoke = params;
return { payload: { success: true , stdout: "cron-node-ok" } };
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node" ,
ask: "always" ,
security: "full" ,
trigger: "cron" ,
approvalRunningNoticeMs: 0 ,
});
const result = await tool.execute("call-cron-inline-node-approval" , {
command: "echo cron-node-ok" ,
});
expect(result.details.status).toBe("completed" );
expect(getResultText(result)).toContain("cron-node-ok" );
expect(systemRunInvoke).toMatchObject({
command: "system.run" ,
params: {
approved: true ,
approvalDecision: "allow-once" ,
systemRunPlan: preparedPlan,
},
});
expect((systemRunInvoke as { params?: { runId?: string } }).params?.runId).toEqual(
expect.any(String),
);
});
it("explains cron no-route denials with a host-policy fix hint" , async () => {
await writeExecApprovalsConfig({
version: 1 ,
defaults: { security: "full" , ask: "always" , askFallback: "deny" },
agents: {},
});
mockNoApprovalRouteRegistration();
const tool = createExecTool({
host: "gateway" ,
ask: "always" ,
security: "full" ,
trigger: "cron" ,
approvalRunningNoticeMs: 0 ,
});
await expect(
tool.execute("call-cron-denied" , {
command: "echo cron-denied" ,
}),
).rejects.toThrow("Cron runs cannot wait for interactive exec approval" );
});
});
Messung V0.5 in Prozent C=99 H=97 G=97
¤ Dauer der Verarbeitung: 0.21 Sekunden
(vorverarbeitet am 2026-05-26)
¤
*© Formatika GbR, Deutschland