Files
openclaw/src/gateway/server-methods/exec-approval.ts

209 lines
6.9 KiB
TypeScript
Raw Normal View History

import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
import {
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
type ExecApprovalDecision,
} from "../../infra/exec-approvals.js";
2026-02-17 13:36:48 +09:00
import type { ExecApprovalManager } from "../exec-approval-manager.js";
2026-01-19 02:31:18 +00:00
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateExecApprovalRequestParams,
validateExecApprovalResolveParams,
} from "../protocol/index.js";
2026-02-17 13:36:48 +09:00
import type { GatewayRequestHandlers } from "./types.js";
2026-01-19 02:31:18 +00:00
export function createExecApprovalHandlers(
manager: ExecApprovalManager,
opts?: { forwarder?: ExecApprovalForwarder },
): GatewayRequestHandlers {
2026-01-19 02:31:18 +00:00
return {
"exec.approval.request": async ({ params, respond, context, client }) => {
2026-01-19 02:31:18 +00:00
if (!validateExecApprovalRequestParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approval.request params: ${formatValidationErrors(
validateExecApprovalRequestParams.errors,
)}`,
),
);
return;
}
const p = params as {
2026-01-22 00:49:02 +00:00
id?: string;
2026-01-19 02:31:18 +00:00
command: string;
cwd?: string;
host?: string;
security?: string;
ask?: string;
agentId?: string;
resolvedPath?: string;
sessionKey?: string;
timeoutMs?: number;
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * 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 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
twoPhase?: boolean;
2026-01-19 02:31:18 +00:00
};
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * 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 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
const twoPhase = p.twoPhase === true;
const timeoutMs =
typeof p.timeoutMs === "number" ? p.timeoutMs : DEFAULT_EXEC_APPROVAL_TIMEOUT_MS;
2026-01-22 00:49:02 +00:00
const explicitId = typeof p.id === "string" && p.id.trim().length > 0 ? p.id.trim() : null;
if (explicitId && manager.getSnapshot(explicitId)) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "approval id already pending"),
);
return;
}
2026-01-19 02:31:18 +00:00
const request = {
command: p.command,
cwd: p.cwd ?? null,
host: p.host ?? null,
security: p.security ?? null,
ask: p.ask ?? null,
agentId: p.agentId ?? null,
resolvedPath: p.resolvedPath ?? null,
sessionKey: p.sessionKey ?? null,
};
2026-01-22 00:49:02 +00:00
const record = manager.create(request, timeoutMs, explicitId);
record.requestedByConnId = client?.connId ?? null;
record.requestedByDeviceId = client?.connect?.device?.id ?? null;
record.requestedByClientId = client?.connect?.client?.id ?? null;
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * 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 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
// Use register() to synchronously add to pending map before sending any response.
// This ensures the approval ID is valid immediately after the "accepted" response.
let decisionPromise: Promise<
import("../../infra/exec-approvals.js").ExecApprovalDecision | null
>;
try {
decisionPromise = manager.register(record, timeoutMs);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `registration failed: ${String(err)}`),
);
return;
}
2026-01-19 02:31:18 +00:00
context.broadcast(
"exec.approval.requested",
{
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
{ dropIfSlow: true },
);
void opts?.forwarder
?.handleRequested({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
})
.catch((err) => {
context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`);
});
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * 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 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
// Only send immediate "accepted" response when twoPhase is requested.
// This preserves single-response semantics for existing callers.
if (twoPhase) {
respond(
true,
{
status: "accepted",
id: record.id,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
undefined,
);
}
const decision = await decisionPromise;
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * 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 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
// Send final response with decision for callers using expectFinal:true.
2026-01-19 11:32:15 +00:00
respond(
true,
{
id: record.id,
decision,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
undefined,
);
2026-01-19 02:31:18 +00:00
},
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * 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 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
"exec.approval.waitDecision": async ({ params, respond }) => {
const p = params as { id?: string };
const id = typeof p.id === "string" ? p.id.trim() : "";
if (!id) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id is required"));
return;
}
const decisionPromise = manager.awaitDecision(id);
if (!decisionPromise) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "approval expired or not found"),
);
return;
}
// Capture snapshot before await (entry may be deleted after grace period)
const snapshot = manager.getSnapshot(id);
const decision = await decisionPromise;
// Return decision (can be null on timeout) - let clients handle via askFallback
respond(
true,
{
id,
decision,
createdAtMs: snapshot?.createdAtMs,
expiresAtMs: snapshot?.expiresAtMs,
},
undefined,
);
},
2026-01-19 02:31:18 +00:00
"exec.approval.resolve": async ({ params, respond, client, context }) => {
if (!validateExecApprovalResolveParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approval.resolve params: ${formatValidationErrors(
validateExecApprovalResolveParams.errors,
)}`,
),
);
return;
}
const p = params as { id: string; decision: string };
const decision = p.decision as ExecApprovalDecision;
if (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
return;
}
const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id;
const ok = manager.resolve(p.id, decision, resolvedBy ?? null);
if (!ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id"));
return;
}
context.broadcast(
"exec.approval.resolved",
{ id: p.id, decision, resolvedBy, ts: Date.now() },
{ dropIfSlow: true },
);
void opts?.forwarder
?.handleResolved({ id: p.id, decision, resolvedBy, ts: Date.now() })
.catch((err) => {
context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);
});
2026-01-19 02:31:18 +00:00
respond(true, { ok: true }, undefined);
},
};
}