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
This commit is contained in:
rshirali 2026-01-28 14:54:03 +01:00
parent 20c2a99476
commit b1d409738b

View File

@ -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"