fix(gateway): fail fast exec approvals when no approvers are reachable

Co-authored-by: fanxian831-netizen <262880470+fanxian831-netizen@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-02-22 22:13:40 +01:00
parent 73fab7e445
commit d24f5c1e3a
8 changed files with 168 additions and 47 deletions

View File

@@ -17,6 +17,14 @@ export function createExecApprovalHandlers(
manager: ExecApprovalManager,
opts?: { forwarder?: ExecApprovalForwarder },
): GatewayRequestHandlers {
const hasApprovalClients = (context: { hasExecApprovalClients?: () => boolean }) => {
if (typeof context.hasExecApprovalClients === "function") {
return context.hasExecApprovalClients();
}
// Fail closed when no operator-scope probe is available.
return false;
};
return {
"exec.approval.request": async ({ params, respond, context, client }) => {
if (!validateExecApprovalRequestParams(params)) {
@@ -96,16 +104,23 @@ export function createExecApprovalHandlers(
},
{ dropIfSlow: true },
);
void opts?.forwarder
?.handleRequested({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
})
.catch((err) => {
let forwardedToTargets = false;
if (opts?.forwarder) {
try {
forwardedToTargets = await 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)}`);
});
}
}
if (!hasApprovalClients(context) && !forwardedToTargets) {
manager.expire(record.id, "auto-expire:no-approver-clients");
}
// Only send immediate "accepted" response when twoPhase is requested.
// This preserves single-response semantics for existing callers.

View File

@@ -254,6 +254,7 @@ describe("exec approval handlers", () => {
function toExecApprovalRequestContext(context: {
broadcast: (event: string, payload: unknown) => void;
hasExecApprovalClients?: () => boolean;
}): ExecApprovalRequestArgs["context"] {
return context as unknown as ExecApprovalRequestArgs["context"];
}
@@ -277,7 +278,10 @@ describe("exec approval handlers", () => {
return params.handlers["exec.approval.request"]({
params: requestParams,
respond: params.respond as unknown as ExecApprovalRequestArgs["respond"],
context: toExecApprovalRequestContext(params.context),
context: toExecApprovalRequestContext({
hasExecApprovalClients: () => true,
...params.context,
}),
client: null,
req: { id: "req-1", type: "req", method: "exec.approval.request" },
isWebchatConnect: execApprovalNoop,
@@ -309,6 +313,7 @@ describe("exec approval handlers", () => {
broadcast: (event: string, payload: unknown) => {
broadcasts.push({ event, payload });
},
hasExecApprovalClients: () => true,
};
return { handlers, broadcasts, respond, context };
}
@@ -463,6 +468,46 @@ describe("exec approval handlers", () => {
);
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
});
it("expires immediately when no approver clients and no forwarding targets", async () => {
vi.useFakeTimers();
try {
const manager = new ExecApprovalManager();
const forwarder = {
handleRequested: vi.fn(async () => false),
handleResolved: vi.fn(async () => {}),
stop: vi.fn(),
};
const handlers = createExecApprovalHandlers(manager, { forwarder });
const respond = vi.fn();
const context = {
broadcast: (_event: string, _payload: unknown) => {},
hasExecApprovalClients: () => false,
};
const expireSpy = vi.spyOn(manager, "expire");
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: { timeoutMs: 60_000 },
});
for (let idx = 0; idx < 20; idx += 1) {
await Promise.resolve();
}
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
expect(expireSpy).toHaveBeenCalledTimes(1);
await vi.runOnlyPendingTimersAsync();
await requestPromise;
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ decision: null }),
undefined,
);
} finally {
vi.useRealTimers();
}
});
});
describe("gateway healthHandlers.status scope handling", () => {

View File

@@ -47,6 +47,7 @@ export type GatewayRequestContext = {
nodeUnsubscribe: (nodeId: string, sessionKey: string) => void;
nodeUnsubscribeAll: (nodeId: string) => void;
hasConnectedMobileNode: () => boolean;
hasExecApprovalClients?: () => boolean;
nodeRegistry: NodeRegistry;
agentRunSeq: Map<string, number>;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;