From b1d409738b30f8b6301bedefcf19a687db9347be Mon Sep 17 00:00:00 2001 From: rshirali Date: Wed, 28 Jan 2026 14:54:03 +0100 Subject: [PATCH] fix(exec): await approval registration before returning approval-pending Ensures the approval ID is registered in the gateway before the tool returns. Uses exec.approval.request with expectFinal:false for registration, then fire-and-forget exec.approval.waitDecision for the decision phase. Fixes #2402 --- src/agents/bash-tools.exec.ts | 92 ++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 28 deletions(-) diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index b9de81872..b67da4a48 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1008,29 +1008,47 @@ export function createExecTool( if (requiresAsk) { const approvalId = crypto.randomUUID(); const approvalSlug = createApprovalSlug(approvalId); - const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const contextKey = `exec:${approvalId}`; const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000)); const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : ""; + // Register the approval with expectFinal:false to get immediate confirmation. + // This ensures the approval ID is valid before we return. + let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + try { + const registrationResult = (await callGatewayTool( + "exec.approval.request", + { timeoutMs: 10_000 }, + { + id: approvalId, + command: commandText, + cwd: workdir, + host: "node", + security: hostSecurity, + ask: hostAsk, + agentId, + resolvedPath: undefined, + sessionKey: defaults?.sessionKey, + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }, + { expectFinal: false }, + )) as { status?: string; expiresAtMs?: number } | null; + if (registrationResult?.expiresAtMs) { + expiresAtMs = registrationResult.expiresAtMs; + } + } catch (err) { + // Registration failed - throw to caller + throw new Error(`Exec approval registration failed: ${String(err)}`); + } + + // Fire-and-forget: wait for decision via waitDecision endpoint, then execute. void (async () => { let decision: string | null = null; try { const decisionResult = (await callGatewayTool( - "exec.approval.request", + "exec.approval.waitDecision", { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, - { - id: approvalId, - command: commandText, - cwd: workdir, - host: "node", - security: hostSecurity, - ask: hostAsk, - agentId, - resolvedPath: undefined, - sessionKey: defaults?.sessionKey, - timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, - }, + { id: approvalId }, )) as { decision?: string } | null; decision = decisionResult && typeof decisionResult === "object" @@ -1185,7 +1203,6 @@ export function createExecTool( if (requiresAsk) { const approvalId = crypto.randomUUID(); const approvalSlug = createApprovalSlug(approvalId); - const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const contextKey = `exec:${approvalId}`; const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000)); @@ -1194,24 +1211,43 @@ export function createExecTool( typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec; const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : ""; + // Register the approval with expectFinal:false to get immediate confirmation. + // This ensures the approval ID is valid before we return. + let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + try { + const registrationResult = (await callGatewayTool( + "exec.approval.request", + { timeoutMs: 10_000 }, + { + id: approvalId, + command: commandText, + cwd: workdir, + host: "gateway", + security: hostSecurity, + ask: hostAsk, + agentId, + resolvedPath, + sessionKey: defaults?.sessionKey, + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }, + { expectFinal: false }, + )) as { status?: string; expiresAtMs?: number } | null; + if (registrationResult?.expiresAtMs) { + expiresAtMs = registrationResult.expiresAtMs; + } + } catch (err) { + // Registration failed - throw to caller + throw new Error(`Exec approval registration failed: ${String(err)}`); + } + + // Fire-and-forget: wait for decision via waitDecision endpoint, then execute. void (async () => { let decision: string | null = null; try { const decisionResult = (await callGatewayTool( - "exec.approval.request", + "exec.approval.waitDecision", { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, - { - id: approvalId, - command: commandText, - cwd: workdir, - host: "gateway", - security: hostSecurity, - ask: hostAsk, - agentId, - resolvedPath, - sessionKey: defaults?.sessionKey, - timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, - }, + { id: approvalId }, )) as { decision?: string } | null; decision = decisionResult && typeof decisionResult === "object"