From 2581b67cdbc4a2e772657dd5b8b8b8de0419c7ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:23:21 +0000 Subject: [PATCH] refactor: share exec approval request helper --- .../bash-tools.exec-approval-request.test.ts | 81 +++++++++++++++++++ .../bash-tools.exec-approval-request.ts | 44 ++++++++++ src/agents/bash-tools.exec-host-gateway.ts | 35 +++----- src/agents/bash-tools.exec-host-node.ts | 33 +++----- 4 files changed, 148 insertions(+), 45 deletions(-) create mode 100644 src/agents/bash-tools.exec-approval-request.test.ts create mode 100644 src/agents/bash-tools.exec-approval-request.ts diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts new file mode 100644 index 0000000000..349663abaa --- /dev/null +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, + DEFAULT_APPROVAL_TIMEOUT_MS, +} from "./bash-tools.exec-runtime.js"; + +vi.mock("./tools/gateway.js", () => ({ + callGatewayTool: vi.fn(), +})); + +describe("requestExecApprovalDecision", () => { + beforeEach(async () => { + const { callGatewayTool } = await import("./tools/gateway.js"); + vi.mocked(callGatewayTool).mockReset(); + }); + + it("returns string decisions", async () => { + const { requestExecApprovalDecision } = await import("./bash-tools.exec-approval-request.js"); + const { callGatewayTool } = await import("./tools/gateway.js"); + vi.mocked(callGatewayTool).mockResolvedValue({ decision: "allow-once" }); + + const result = await requestExecApprovalDecision({ + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "always", + agentId: "main", + resolvedPath: "/usr/bin/echo", + sessionKey: "session", + }); + + expect(result).toBe("allow-once"); + expect(callGatewayTool).toHaveBeenCalledWith( + "exec.approval.request", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "always", + agentId: "main", + resolvedPath: "/usr/bin/echo", + sessionKey: "session", + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }, + ); + }); + + it("returns null for missing or non-string decisions", async () => { + const { requestExecApprovalDecision } = await import("./bash-tools.exec-approval-request.js"); + const { callGatewayTool } = await import("./tools/gateway.js"); + + vi.mocked(callGatewayTool).mockResolvedValueOnce({}); + await expect( + requestExecApprovalDecision({ + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "node", + security: "allowlist", + ask: "on-miss", + }), + ).resolves.toBeNull(); + + vi.mocked(callGatewayTool).mockResolvedValueOnce({ decision: 123 }); + await expect( + requestExecApprovalDecision({ + id: "approval-id-2", + command: "echo hi", + cwd: "/tmp", + host: "node", + security: "allowlist", + ask: "on-miss", + }), + ).resolves.toBeNull(); + }); +}); diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts new file mode 100644 index 0000000000..b68aa37d39 --- /dev/null +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -0,0 +1,44 @@ +import type { ExecAsk, ExecSecurity } from "../infra/exec-approvals.js"; +import { + DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, + DEFAULT_APPROVAL_TIMEOUT_MS, +} from "./bash-tools.exec-runtime.js"; +import { callGatewayTool } from "./tools/gateway.js"; + +export type RequestExecApprovalDecisionParams = { + id: string; + command: string; + cwd: string; + host: "gateway" | "node"; + security: ExecSecurity; + ask: ExecAsk; + agentId?: string; + resolvedPath?: string; + sessionKey?: string; +}; + +export async function requestExecApprovalDecision( + params: RequestExecApprovalDecisionParams, +): Promise { + const decisionResult = await callGatewayTool<{ decision: string }>( + "exec.approval.request", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { + id: params.id, + command: params.command, + cwd: params.cwd, + host: params.host, + security: params.security, + ask: params.ask, + agentId: params.agentId, + resolvedPath: params.resolvedPath, + sessionKey: params.sessionKey, + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }, + ); + const decisionValue = + decisionResult && typeof decisionResult === "object" + ? (decisionResult as { decision?: unknown }).decision + : undefined; + return typeof decisionValue === "string" ? decisionValue : null; +} diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 9fd591458a..3a804abc9e 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -14,8 +14,8 @@ import { resolveExecApprovals, } from "../infra/exec-approvals.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; +import { requestExecApprovalDecision } from "./bash-tools.exec-approval-request.js"; import { - DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, DEFAULT_APPROVAL_TIMEOUT_MS, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, @@ -24,7 +24,6 @@ import { runExecProcess, } from "./bash-tools.exec-runtime.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; -import { callGatewayTool } from "./tools/gateway.js"; export type ProcessGatewayAllowlistParams = { command: string; @@ -99,27 +98,17 @@ export async function processGatewayAllowlist( void (async () => { let decision: string | null = null; try { - const decisionResult = await callGatewayTool<{ decision: string }>( - "exec.approval.request", - { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, - { - id: approvalId, - command: params.command, - cwd: params.workdir, - host: "gateway", - security: hostSecurity, - ask: hostAsk, - agentId: params.agentId, - resolvedPath, - sessionKey: params.sessionKey, - timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, - }, - ); - const decisionValue = - decisionResult && typeof decisionResult === "object" - ? (decisionResult as { decision?: unknown }).decision - : undefined; - decision = typeof decisionValue === "string" ? decisionValue : null; + decision = await requestExecApprovalDecision({ + id: approvalId, + command: params.command, + cwd: params.workdir, + host: "gateway", + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + resolvedPath, + sessionKey: params.sessionKey, + }); } catch { emitExecSystemEvent( `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 7023473cdb..3cca1bc121 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -12,8 +12,8 @@ import { resolveExecApprovalsFromFile, } from "../infra/exec-approvals.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; +import { requestExecApprovalDecision } from "./bash-tools.exec-approval-request.js"; import { - DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, DEFAULT_APPROVAL_TIMEOUT_MS, createApprovalSlug, emitExecSystemEvent, @@ -178,27 +178,16 @@ export async function executeNodeHostCommand( void (async () => { let decision: string | null = null; try { - const decisionResult = await callGatewayTool<{ decision: string }>( - "exec.approval.request", - { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, - { - id: approvalId, - command: params.command, - cwd: params.workdir, - host: "node", - security: hostSecurity, - ask: hostAsk, - agentId: params.agentId, - resolvedPath: undefined, - sessionKey: params.sessionKey, - timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, - }, - ); - const decisionValue = - decisionResult && typeof decisionResult === "object" - ? (decisionResult as { decision?: unknown }).decision - : undefined; - decision = typeof decisionValue === "string" ? decisionValue : null; + decision = await requestExecApprovalDecision({ + id: approvalId, + command: params.command, + cwd: params.workdir, + host: "node", + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); } catch { emitExecSystemEvent( `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,