Download src/gateway/server-methods/approval-shared.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 60.6 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/gateway/server-methods/approval-shared.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/gateway/server-methods/approval-shared.test.ts
-
curl -L -o approval-shared.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/gateway/server-methods/approval-shared.test.ts
60.6 kB
| /** | |
| * Tests shared approval helpers used by gateway method handlers. | |
| */ | |
| import fs from "node:fs"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { afterEach, describe, expect, it, vi } from "vitest"; | |
| import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js"; | |
| import { createDeferredCore } from "../../shared/deferred.js"; | |
| import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; | |
| import { ExecApprovalManager } from "../exec-approval-manager.js"; | |
| import { createTestApprovalManager } from "../exec-approval-manager.test-support.js"; | |
| import { | |
| bindApprovalReviewerDeviceIds, | |
| handleApprovalResolve, | |
| handleApprovalWaitDecision, | |
| handlePendingApprovalRequest, | |
| isApprovalRecordVisibleToClient, | |
| registerPendingApprovalRecord, | |
| } from "./approval-shared.js"; | |
| import type { GatewayClient, GatewayRequestContext } from "./types.js"; | |
| const hasApprovalTurnSourceRouteMock = vi.hoisted(() => vi.fn(() => true)); | |
| const prepareApprovalChannelCustodyMock = vi.hoisted(() => vi.fn()); | |
| vi.mock("../../infra/approval-turn-source.js", () => ({ | |
| hasApprovalTurnSourceRoute: hasApprovalTurnSourceRouteMock, | |
| })); | |
| vi.mock("../approval-channel-custody.js", () => ({ | |
| prepareApprovalChannelCustody: prepareApprovalChannelCustodyMock, | |
| })); | |
| type ApprovalClientLookup = NonNullable<GatewayRequestContext["getApprovalClientConnIds"]>; | |
| function createApprovalClient(params: { | |
| connId: string; | |
| clientId: string; | |
| deviceId?: string; | |
| scopes?: string[]; | |
| approvalRuntime?: boolean; | |
| }): GatewayClient { | |
| return { | |
| connId: params.connId, | |
| connect: { | |
| client: { id: params.clientId }, | |
| device: params.deviceId ? { id: params.deviceId } : undefined, | |
| scopes: params.scopes ?? ["operator.approvals"], | |
| }, | |
| ...(params.approvalRuntime ? { internal: { approvalRuntime: true } } : {}), | |
| } as GatewayClient; | |
| } | |
| function createApprovalClientLookup(clients: GatewayClient[]): ApprovalClientLookup { | |
| return (opts = {}) => | |
| new Set( | |
| clients | |
| .filter((client) => { | |
| if (opts.excludeConnId && client.connId === opts.excludeConnId) { | |
| return false; | |
| } | |
| return opts.filter?.(client, opts.record) ?? true; | |
| }) | |
| .map((client) => client.connId) | |
| .filter((connId): connId is string => typeof connId === "string" && connId.length > 0), | |
| ); | |
| } | |
| describe("handlePendingApprovalRequest", () => { | |
| afterEach(() => { | |
| hasApprovalTurnSourceRouteMock.mockClear(); | |
| }); | |
| it.for([ | |
| { | |
| name: "allows operator.admin clients to see requester-bound approvals", | |
| recordId: "approval-admin-visible", | |
| requestedBy: { | |
| requestedByDeviceId: "device-owner", | |
| requestedByConnId: "conn-owner", | |
| requestedByClientId: "client-owner", | |
| }, | |
| client: { | |
| connId: "conn-admin", | |
| clientId: "client-admin", | |
| deviceId: "device-admin", | |
| scopes: ["operator.admin"], | |
| }, | |
| expected: true, | |
| }, | |
| { | |
| name: "does not allow approval-scoped clients to see no-device gateway-client approvals from another connection", | |
| recordId: "approval-gateway-client-visible", | |
| requestedBy: { | |
| requestedByConnId: "conn-gateway", | |
| requestedByClientId: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, | |
| }, | |
| client: { | |
| connId: "conn-mobile", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| scopes: ["operator.approvals"], | |
| }, | |
| expected: false, | |
| }, | |
| ...[ | |
| ["Control UI", GATEWAY_CLIENT_IDS.CONTROL_UI], | |
| ["WebChat UI", GATEWAY_CLIENT_IDS.WEBCHAT_UI], | |
| ["WebChat", GATEWAY_CLIENT_IDS.WEBCHAT], | |
| ].map(([label, clientId]) => ({ | |
| name: `does not allow approval-scoped clients to see no-device ${label} approvals from another connection`, | |
| recordId: `approval-${clientId}-visible`, | |
| requestedBy: { | |
| requestedByConnId: "conn-browser-ui", | |
| requestedByClientId: clientId, | |
| }, | |
| client: { | |
| connId: "conn-mobile", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| scopes: ["operator.approvals"], | |
| }, | |
| expected: false, | |
| })), | |
| { | |
| name: "does not allow approval-scoped clients to see device-bound gateway-client approvals from another device", | |
| recordId: "approval-gateway-device-visible", | |
| requestedBy: { | |
| requestedByDeviceId: "device-gateway", | |
| requestedByConnId: "conn-gateway", | |
| requestedByClientId: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, | |
| }, | |
| client: { | |
| connId: "conn-mobile", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| deviceId: "device-mobile", | |
| scopes: ["operator.approvals"], | |
| }, | |
| expected: false, | |
| }, | |
| { | |
| name: "allows gateway-client approval runtimes to see requester-bound approvals", | |
| recordId: "approval-delivery-runtime-visible", | |
| requestedBy: { | |
| requestedByDeviceId: "device-owner", | |
| requestedByConnId: "conn-owner", | |
| requestedByClientId: "client-owner", | |
| }, | |
| client: { | |
| connId: "conn-delivery-runtime", | |
| clientId: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, | |
| scopes: ["operator.approvals"], | |
| approvalRuntime: true, | |
| }, | |
| expected: true, | |
| }, | |
| { | |
| name: "does not trust gateway-client ids without the approval runtime marker", | |
| recordId: "approval-delivery-runtime-spoof-hidden", | |
| requestedBy: { | |
| requestedByDeviceId: "device-owner", | |
| requestedByConnId: "conn-owner", | |
| requestedByClientId: "client-owner", | |
| }, | |
| client: { | |
| connId: "conn-spoofed-runtime", | |
| clientId: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, | |
| scopes: ["operator.approvals"], | |
| }, | |
| expected: false, | |
| }, | |
| { | |
| name: "does not widen non-gateway no-device approvals to matching client ids", | |
| recordId: "approval-other-client-hidden", | |
| requestedBy: { | |
| requestedByConnId: "conn-requester", | |
| requestedByClientId: "client-owner", | |
| }, | |
| client: { | |
| connId: "conn-mobile", | |
| clientId: "client-owner", | |
| scopes: ["operator.approvals"], | |
| }, | |
| expected: false, | |
| }, | |
| ])("$name", ({ recordId, requestedBy, client, expected }, testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create({ command: "echo ok" }, 60_000, recordId); | |
| Object.assign(record, requestedBy); | |
| expect( | |
| isApprovalRecordVisibleToClient({ | |
| record, | |
| client: createApprovalClient(client), | |
| }), | |
| ).toBe(expected); | |
| }); | |
| it("allows approval-scoped reviewer devices to see approvals requested by the backend runtime", (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-reviewer-device-visible", | |
| ); | |
| record.requestedByDeviceId = "device-gateway-runtime"; | |
| record.requestedByConnId = "conn-gateway-runtime"; | |
| record.requestedByClientId = GATEWAY_CLIENT_IDS.GATEWAY_CLIENT; | |
| bindApprovalReviewerDeviceIds({ | |
| record, | |
| deviceIds: [" device-mobile ", "device-mobile"], | |
| }); | |
| expect(record.approvalReviewerDeviceIds).toEqual(["device-mobile"]); | |
| expect( | |
| isApprovalRecordVisibleToClient({ | |
| record, | |
| client: createApprovalClient({ | |
| connId: "conn-mobile", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| deviceId: "device-mobile", | |
| scopes: ["operator.approvals"], | |
| }), | |
| }), | |
| ).toBe(true); | |
| }); | |
| it("does not allow reviewer devices without approval scope to see approvals", (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-reviewer-device-scope-hidden", | |
| ); | |
| record.requestedByDeviceId = "device-gateway-runtime"; | |
| record.requestedByConnId = "conn-gateway-runtime"; | |
| record.requestedByClientId = GATEWAY_CLIENT_IDS.GATEWAY_CLIENT; | |
| bindApprovalReviewerDeviceIds({ record, deviceIds: ["device-mobile"] }); | |
| expect( | |
| isApprovalRecordVisibleToClient({ | |
| record, | |
| client: createApprovalClient({ | |
| connId: "conn-mobile", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| deviceId: "device-mobile", | |
| scopes: ["operator.read"], | |
| }), | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("does not widen explicitly reviewer-bound approvals to another device", (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-reviewer-device-isolated", | |
| ); | |
| bindApprovalReviewerDeviceIds({ record, deviceIds: ["device-mobile"] }); | |
| expect( | |
| isApprovalRecordVisibleToClient({ | |
| record, | |
| client: createApprovalClient({ | |
| connId: "conn-other", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| deviceId: "device-other", | |
| scopes: ["operator.approvals"], | |
| }), | |
| }), | |
| ).toBe(false); | |
| }); | |
| it.for([ | |
| { | |
| name: "reports an active approval client instead of the manual turn-source route", | |
| route: "client", | |
| id: "approval-with-client", | |
| request: { command: "echo ok", turnSourceChannel: "feishu", turnSourceAccountId: "work" }, | |
| }, | |
| { | |
| name: "counts an instance-local approval subscriber as a delivery route", | |
| route: "subscriber", | |
| id: "approval-internal-route", | |
| request: { command: "echo ok" }, | |
| }, | |
| { | |
| name: "checks plugin turn-source routes with plugin approval kind", | |
| route: "plugin", | |
| id: "plugin-turn-source-kind", | |
| request: { | |
| command: "plugin approval", | |
| title: "Plugin approval", | |
| description: "Review the plugin action", | |
| turnSourceChannel: "whatsapp", | |
| turnSourceAccountId: "default", | |
| }, | |
| }, | |
| { | |
| name: "keeps register-only approval requests pending without a delivery route", | |
| route: "register-only", | |
| id: "approval-register-only", | |
| request: { command: "echo ok" }, | |
| }, | |
| ] as const)("$name", async ({ route, id, request }, testContext) => { | |
| if (route === "register-only") { | |
| hasApprovalTurnSourceRouteMock.mockReturnValueOnce(false); | |
| } | |
| const manager = createTestApprovalManager<typeof request>(testContext, { | |
| approvalKind: route === "plugin" ? "plugin" : "exec", | |
| }); | |
| const record = manager.create(request, 60_000, id); | |
| void manager.register(record, 60_000); | |
| const responseSent = createDeferredCore(); | |
| const respond = vi.fn(() => responseSent.resolve()); | |
| const publishRequested = vi.fn(() => 1); | |
| const getApprovalClientConnIds = vi.fn(() => new Set<string>()); | |
| const requestPromise = handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast: vi.fn(), | |
| hasExecApprovalClients: () => route === "client", | |
| ...(route === "subscriber" | |
| ? { approvalEvents: { publishRequested, publishResolved: vi.fn() } } | |
| : {}), | |
| ...(route === "plugin" ? { broadcastToConnIds: vi.fn(), getApprovalClientConnIds } : {}), | |
| } as unknown as GatewayRequestContext, | |
| requestEventName: | |
| route === "plugin" ? "plugin.approval.requested" : "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| approvalKind: route === "plugin" ? "plugin" : undefined, | |
| requireDeliveryRoute: route === "register-only" ? false : undefined, | |
| deliverRequest: () => false, | |
| }); | |
| try { | |
| await Promise.race([responseSent.promise, requestPromise]); | |
| if (route === "client") { | |
| expect(hasApprovalTurnSourceRouteMock).not.toHaveBeenCalled(); | |
| } else if (route === "subscriber") { | |
| expect(publishRequested).toHaveBeenCalledWith( | |
| "exec", | |
| expect.objectContaining({ id: record.id }), | |
| ); | |
| } else if (route === "plugin") { | |
| expect(getApprovalClientConnIds).toHaveBeenCalledWith( | |
| expect.objectContaining({ approvalKind: "plugin" }), | |
| ); | |
| expect(hasApprovalTurnSourceRouteMock).toHaveBeenCalledWith({ | |
| turnSourceChannel: "whatsapp", | |
| turnSourceAccountId: "default", | |
| approvalKind: "plugin", | |
| }); | |
| } else { | |
| expect(manager.getSnapshot(record.id)?.resolvedAtMs).toBeUndefined(); | |
| } | |
| expect(respond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ | |
| id, | |
| status: "accepted", | |
| ...(route === "register-only" | |
| ? {} | |
| : { deliveryRoute: route === "plugin" ? "turn-source" : "approval-client" }), | |
| }), | |
| undefined, | |
| ); | |
| expect(manager.resolve(record.id, "allow-once")).toBe(true); | |
| await requestPromise; | |
| if (route === "register-only") { | |
| expect(manager.getSnapshot(record.id)?.resolvedBy).not.toBe("no-approval-route"); | |
| expect(respond).toHaveBeenLastCalledWith( | |
| true, | |
| expect.objectContaining({ id, decision: "allow-once" }), | |
| undefined, | |
| ); | |
| } | |
| } finally { | |
| manager.resolve(record.id, "deny"); | |
| await Promise.allSettled([requestPromise, manager.drain()]); | |
| } | |
| }); | |
| it("targets requested approval events to visible approval clients when available", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-visible", | |
| ); | |
| record.requestedByDeviceId = "device-owner"; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| const visibleConnIds = new Set(["conn-owner-approval"]); | |
| const requestPromise = handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-owner-approval", | |
| clientId: "client-owner", | |
| deviceId: "device-owner", | |
| }), | |
| createApprovalClient({ | |
| connId: "conn-other-approval", | |
| clientId: "client-other", | |
| deviceId: "device-other", | |
| }), | |
| ]), | |
| ), | |
| hasExecApprovalClients: vi.fn(() => { | |
| throw new Error("expected visibility-filtered approval client lookup"); | |
| }), | |
| } as unknown as GatewayRequestContext, | |
| clientConnId: "conn-requester", | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverRequest: () => false, | |
| }); | |
| await Promise.resolve(); | |
| expect(broadcast).not.toHaveBeenCalled(); | |
| expect(broadcastToConnIds).toHaveBeenCalledWith( | |
| "exec.approval.requested", | |
| expect.objectContaining({ id: "approval-visible" }), | |
| visibleConnIds, | |
| { dropIfSlow: true }, | |
| ); | |
| expect(manager.resolve(record.id, "allow-once")).toBe(true); | |
| await requestPromise; | |
| }); | |
| it("routes backend-runtime approval events to the authorized approval reviewer device", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-reviewer-device-event", | |
| ); | |
| record.requestedByDeviceId = "device-gateway-runtime"; | |
| record.requestedByConnId = "conn-gateway-runtime"; | |
| record.requestedByClientId = GATEWAY_CLIENT_IDS.GATEWAY_CLIENT; | |
| bindApprovalReviewerDeviceIds({ record, deviceIds: ["device-mobile"] }); | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| const visibleConnIds = new Set(["conn-mobile-approval"]); | |
| const requestPromise = handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-mobile-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| deviceId: "device-mobile", | |
| }), | |
| createApprovalClient({ | |
| connId: "conn-other-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| deviceId: "device-other", | |
| }), | |
| ]), | |
| ), | |
| hasExecApprovalClients: vi.fn(() => { | |
| throw new Error("expected visibility-filtered approval client lookup"); | |
| }), | |
| } as unknown as GatewayRequestContext, | |
| clientConnId: "conn-gateway-runtime", | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverRequest: () => false, | |
| }); | |
| await Promise.resolve(); | |
| expect(broadcast).not.toHaveBeenCalled(); | |
| expect(broadcastToConnIds).toHaveBeenCalledWith( | |
| "exec.approval.requested", | |
| expect.objectContaining({ id: "approval-reviewer-device-event" }), | |
| visibleConnIds, | |
| { dropIfSlow: true }, | |
| ); | |
| expect(manager.resolve(record.id, "allow-once")).toBe(true); | |
| await requestPromise; | |
| }); | |
| it("targets requester-bound approval events to gateway-client approval runtimes", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-delivery-runtime", | |
| ); | |
| record.requestedByDeviceId = "device-owner"; | |
| record.requestedByConnId = "conn-owner"; | |
| record.requestedByClientId = "client-owner"; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| const visibleConnIds = new Set(["conn-delivery-runtime"]); | |
| const requestPromise = handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-delivery-runtime", | |
| clientId: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, | |
| approvalRuntime: true, | |
| }), | |
| createApprovalClient({ | |
| connId: "conn-other-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| deviceId: "device-other", | |
| }), | |
| ]), | |
| ), | |
| hasExecApprovalClients: vi.fn(() => { | |
| throw new Error("expected visibility-filtered approval client lookup"); | |
| }), | |
| } as unknown as GatewayRequestContext, | |
| clientConnId: "conn-owner", | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverRequest: () => false, | |
| }); | |
| await Promise.resolve(); | |
| expect(broadcast).not.toHaveBeenCalled(); | |
| expect(broadcastToConnIds).toHaveBeenCalledWith( | |
| "exec.approval.requested", | |
| expect.objectContaining({ id: "approval-delivery-runtime" }), | |
| visibleConnIds, | |
| { dropIfSlow: true }, | |
| ); | |
| expect(manager.resolve(record.id, "allow-once")).toBe(true); | |
| await requestPromise; | |
| }); | |
| it("does not target no-device gateway-client approvals to unrelated approval-scoped clients", async (testContext) => { | |
| hasApprovalTurnSourceRouteMock.mockReturnValueOnce(false); | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-gateway-mobile", | |
| ); | |
| record.requestedByConnId = "conn-gateway"; | |
| record.requestedByClientId = GATEWAY_CLIENT_IDS.GATEWAY_CLIENT; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| const visibleConnIds = new Set<string>(); | |
| const requestPromise = handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-gateway", | |
| clientId: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, | |
| }), | |
| createApprovalClient({ | |
| connId: "conn-mobile-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| scopes: ["operator.approvals"], | |
| }), | |
| ]), | |
| ), | |
| hasExecApprovalClients: vi.fn(() => { | |
| throw new Error("expected visibility-filtered approval client lookup"); | |
| }), | |
| } as unknown as GatewayRequestContext, | |
| clientConnId: "conn-gateway", | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverRequest: () => false, | |
| }); | |
| await Promise.resolve(); | |
| expect(broadcast).not.toHaveBeenCalled(); | |
| expect(broadcastToConnIds).toHaveBeenCalledWith( | |
| "exec.approval.requested", | |
| expect.objectContaining({ id: "approval-gateway-mobile" }), | |
| visibleConnIds, | |
| { dropIfSlow: true }, | |
| ); | |
| await requestPromise; | |
| expect(manager.getSnapshot(record.id)?.resolvedBy).toBe("no-approval-route"); | |
| expect(respond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ id: "approval-gateway-mobile", decision: null }), | |
| undefined, | |
| ); | |
| }); | |
| it("returns a concurrent first answer when no-route denial loses after delivery", async (testContext) => { | |
| hasApprovalTurnSourceRouteMock.mockReturnValueOnce(false); | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-no-route-race", | |
| ); | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const { promise: delivery, resolve: finishDelivery } = createDeferredCore<boolean>(); | |
| const requestPromise = handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast: vi.fn(), | |
| hasExecApprovalClients: () => false, | |
| } as unknown as GatewayRequestContext, | |
| clientConnId: "conn-requester", | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverRequest: () => delivery, | |
| }); | |
| await Promise.resolve(); | |
| expect(manager.resolve(record.id, "allow-once", "control-ui")).toBe(true); | |
| finishDelivery(false); | |
| await requestPromise; | |
| expect(manager.getSnapshot(record.id)).toMatchObject({ | |
| decision: "allow-once", | |
| resolvedBy: "control-ui", | |
| }); | |
| expect(respond).toHaveBeenCalledTimes(1); | |
| expect(respond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ id: record.id, decision: "allow-once" }), | |
| undefined, | |
| ); | |
| }); | |
| it("retains a concurrent approval binding through a delayed requester handoff", async (testContext) => { | |
| vi.useFakeTimers(); | |
| try { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-delayed-handoff", | |
| ); | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const afterDecision = vi.fn(); | |
| const { promise: delivery, resolve: finishDelivery } = createDeferredCore<boolean>(); | |
| const requestPromise = handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast: vi.fn(), | |
| hasExecApprovalClients: () => false, | |
| } as unknown as GatewayRequestContext, | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverRequest: () => delivery, | |
| afterDecision, | |
| }); | |
| await Promise.resolve(); | |
| expect(manager.resolve(record.id, "allow-once", "control-ui")).toBe(true); | |
| await vi.advanceTimersByTimeAsync(16_000); | |
| expect(manager.getLiveSnapshot(record.id)).toMatchObject({ | |
| decision: "allow-once", | |
| resolvedBy: "control-ui", | |
| }); | |
| expect(afterDecision).not.toHaveBeenCalled(); | |
| finishDelivery(true); | |
| await requestPromise; | |
| expect(afterDecision).toHaveBeenCalledWith( | |
| "allow-once", | |
| expect.objectContaining({ id: record.id }), | |
| ); | |
| expect(respond).toHaveBeenLastCalledWith( | |
| true, | |
| expect.objectContaining({ id: record.id, decision: "allow-once" }), | |
| undefined, | |
| ); | |
| expect(manager.getLiveSnapshot(record.id)).not.toBeNull(); | |
| await vi.advanceTimersByTimeAsync(14_999); | |
| expect(manager.getLiveSnapshot(record.id)).not.toBeNull(); | |
| await vi.advanceTimersByTimeAsync(1); | |
| expect(manager.getLiveSnapshot(record.id)).toBeNull(); | |
| } finally { | |
| vi.useRealTimers(); | |
| } | |
| }); | |
| it("expires suppressed requests instead of retaining a hidden turn-source route", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo cron", | |
| turnSourceChannel: "discord", | |
| turnSourceAccountId: "default", | |
| }, | |
| 60_000, | |
| "approval-suppressed-turn-source", | |
| ); | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const deliverRequest = vi.fn(() => true); | |
| await handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast, | |
| hasExecApprovalClients: () => true, | |
| } as unknown as GatewayRequestContext, | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| suppressDelivery: true, | |
| deliverRequest, | |
| }); | |
| expect(broadcast).not.toHaveBeenCalled(); | |
| expect(deliverRequest).not.toHaveBeenCalled(); | |
| expect(hasApprovalTurnSourceRouteMock).not.toHaveBeenCalled(); | |
| expect(manager.getSnapshot(record.id)).toMatchObject({ | |
| resolvedBy: "no-approval-route", | |
| terminalReason: "no-route", | |
| }); | |
| expect(respond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ id: record.id, decision: null }), | |
| undefined, | |
| ); | |
| }); | |
| it("keeps clients-only requests pending for approval clients without chat delivery", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo cron", | |
| turnSourceChannel: "discord", | |
| turnSourceAccountId: "default", | |
| }, | |
| 60_000, | |
| "approval-clients-only-pending", | |
| ); | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const deliverRequest = vi.fn(() => true); | |
| const publishRequested = vi.fn(() => 1); | |
| const requestPromise = handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast, | |
| hasExecApprovalClients: () => true, | |
| approvalEvents: { publishRequested, publishResolved: vi.fn() }, | |
| } as unknown as GatewayRequestContext, | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverToApprovalClientsOnly: true, | |
| deliverRequest, | |
| }); | |
| await Promise.resolve(); | |
| expect(broadcast).toHaveBeenCalledWith( | |
| "exec.approval.requested", | |
| expect.objectContaining({ id: record.id }), | |
| expect.objectContaining({ dropIfSlow: true }), | |
| ); | |
| expect(deliverRequest).not.toHaveBeenCalled(); | |
| expect(publishRequested).not.toHaveBeenCalled(); | |
| expect(hasApprovalTurnSourceRouteMock).not.toHaveBeenCalled(); | |
| expect(manager.getSnapshot(record.id)?.resolvedAtMs).toBeUndefined(); | |
| expect(manager.resolve(record.id, "allow-once")).toBe(true); | |
| await requestPromise; | |
| expect(respond).toHaveBeenLastCalledWith( | |
| true, | |
| expect.objectContaining({ id: record.id, decision: "allow-once" }), | |
| undefined, | |
| ); | |
| }); | |
| it("expires clients-only requests as no-route when no approval client is connected", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo cron", | |
| turnSourceChannel: "discord", | |
| turnSourceAccountId: "default", | |
| }, | |
| 60_000, | |
| "approval-clients-only-no-route", | |
| ); | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const afterDecision = vi.fn(); | |
| const deliverRequest = vi.fn(() => true); | |
| const publishRequested = vi.fn(() => 1); | |
| await handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast: vi.fn(), | |
| hasExecApprovalClients: () => false, | |
| approvalEvents: { publishRequested, publishResolved: vi.fn() }, | |
| } as unknown as GatewayRequestContext, | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverToApprovalClientsOnly: true, | |
| deliverRequest, | |
| afterDecision, | |
| }); | |
| expect(afterDecision).not.toHaveBeenCalled(); | |
| expect(deliverRequest).not.toHaveBeenCalled(); | |
| expect(publishRequested).not.toHaveBeenCalled(); | |
| expect(hasApprovalTurnSourceRouteMock).not.toHaveBeenCalled(); | |
| expect(manager.getSnapshot(record.id)).toMatchObject({ | |
| resolvedBy: "no-approval-route", | |
| terminalReason: "no-route", | |
| }); | |
| expect(respond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ id: record.id, decision: null }), | |
| undefined, | |
| ); | |
| }); | |
| it("does not target no-device browser UI approvals to unrelated approval-scoped clients", async (testContext) => { | |
| hasApprovalTurnSourceRouteMock.mockReturnValueOnce(false); | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-control-ui-mobile", | |
| ); | |
| record.requestedByConnId = "conn-control-ui"; | |
| record.requestedByClientId = GATEWAY_CLIENT_IDS.CONTROL_UI; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| const visibleConnIds = new Set<string>(); | |
| const requestPromise = handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-control-ui", | |
| clientId: GATEWAY_CLIENT_IDS.CONTROL_UI, | |
| }), | |
| createApprovalClient({ | |
| connId: "conn-mobile-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| scopes: ["operator.approvals"], | |
| }), | |
| ]), | |
| ), | |
| hasExecApprovalClients: vi.fn(() => { | |
| throw new Error("expected visibility-filtered approval client lookup"); | |
| }), | |
| } as unknown as GatewayRequestContext, | |
| clientConnId: "conn-control-ui", | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverRequest: () => false, | |
| }); | |
| await Promise.resolve(); | |
| expect(broadcast).not.toHaveBeenCalled(); | |
| expect(broadcastToConnIds).toHaveBeenCalledWith( | |
| "exec.approval.requested", | |
| expect.objectContaining({ id: "approval-control-ui-mobile" }), | |
| visibleConnIds, | |
| { dropIfSlow: true }, | |
| ); | |
| await requestPromise; | |
| expect(manager.getSnapshot(record.id)?.resolvedBy).toBe("no-approval-route"); | |
| expect(respond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ id: "approval-control-ui-mobile", decision: null }), | |
| undefined, | |
| ); | |
| }); | |
| it("does not target device-bound gateway-client approvals to unrelated approval-scoped clients", async (testContext) => { | |
| hasApprovalTurnSourceRouteMock.mockReturnValueOnce(false); | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-gateway-device-mobile", | |
| ); | |
| record.requestedByDeviceId = "device-gateway"; | |
| record.requestedByConnId = "conn-gateway"; | |
| record.requestedByClientId = GATEWAY_CLIENT_IDS.GATEWAY_CLIENT; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| const visibleConnIds = new Set<string>(); | |
| const requestPromise = handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-gateway", | |
| clientId: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, | |
| deviceId: "device-gateway", | |
| }), | |
| createApprovalClient({ | |
| connId: "conn-mobile-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| deviceId: "device-mobile", | |
| scopes: ["operator.approvals"], | |
| }), | |
| ]), | |
| ), | |
| hasExecApprovalClients: vi.fn(() => { | |
| throw new Error("expected visibility-filtered approval client lookup"); | |
| }), | |
| } as unknown as GatewayRequestContext, | |
| clientConnId: "conn-gateway", | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverRequest: () => false, | |
| }); | |
| await Promise.resolve(); | |
| expect(broadcast).not.toHaveBeenCalled(); | |
| expect(broadcastToConnIds).toHaveBeenCalledWith( | |
| "exec.approval.requested", | |
| expect.objectContaining({ id: "approval-gateway-device-mobile" }), | |
| visibleConnIds, | |
| { dropIfSlow: true }, | |
| ); | |
| await requestPromise; | |
| expect(manager.getSnapshot(record.id)?.resolvedBy).toBe("no-approval-route"); | |
| expect(respond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ id: "approval-gateway-device-mobile", decision: null }), | |
| undefined, | |
| ); | |
| }); | |
| it("does not target no-device approvals by self-declared client id", async (testContext) => { | |
| hasApprovalTurnSourceRouteMock.mockReturnValueOnce(false); | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-no-device", | |
| ); | |
| record.requestedByConnId = "conn-requester"; | |
| record.requestedByClientId = "client-owner"; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| const visibleConnIds = new Set<string>(); | |
| const requestPromise = handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-requester", | |
| clientId: "client-owner", | |
| }), | |
| createApprovalClient({ | |
| connId: "conn-owner-approval", | |
| clientId: "client-owner", | |
| }), | |
| createApprovalClient({ | |
| connId: "conn-other-approval", | |
| clientId: "client-other", | |
| }), | |
| ]), | |
| ), | |
| hasExecApprovalClients: vi.fn(() => { | |
| throw new Error("expected visibility-filtered approval client lookup"); | |
| }), | |
| } as unknown as GatewayRequestContext, | |
| clientConnId: "conn-requester", | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverRequest: () => false, | |
| }); | |
| await Promise.resolve(); | |
| expect(broadcast).not.toHaveBeenCalled(); | |
| expect(broadcastToConnIds).toHaveBeenCalledWith( | |
| "exec.approval.requested", | |
| expect.objectContaining({ id: "approval-no-device" }), | |
| visibleConnIds, | |
| { dropIfSlow: true }, | |
| ); | |
| await requestPromise; | |
| expect(manager.getSnapshot(record.id)?.resolvedBy).toBe("no-approval-route"); | |
| expect(respond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ id: "approval-no-device", decision: null }), | |
| undefined, | |
| ); | |
| }); | |
| it("does not resolve no-device approvals by self-declared client id", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-no-device-resolve", | |
| ); | |
| record.requestedByConnId = "conn-requester"; | |
| record.requestedByClientId = "client-owner"; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| await handleApprovalResolve({ | |
| approvalKind: "exec", | |
| manager, | |
| inputId: record.id, | |
| decision: "allow-once", | |
| respond, | |
| context: { | |
| broadcast: vi.fn(), | |
| broadcastToConnIds: vi.fn(), | |
| } as unknown as GatewayRequestContext, | |
| client: createApprovalClient({ | |
| connId: "conn-other", | |
| clientId: "client-owner", | |
| scopes: ["operator.approvals"], | |
| }), | |
| }); | |
| expect(respond).toHaveBeenCalledWith( | |
| false, | |
| undefined, | |
| expect.objectContaining({ | |
| code: "INVALID_REQUEST", | |
| message: "unknown or expired approval id", | |
| }), | |
| ); | |
| expect(manager.getSnapshot(record.id)?.decision).toBeUndefined(); | |
| }); | |
| it("does not wait on decisions for approvals hidden from the caller", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-wait-hidden", | |
| ); | |
| record.requestedByDeviceId = "device-owner"; | |
| record.requestedByConnId = "conn-owner"; | |
| record.requestedByClientId = "client-owner"; | |
| void manager.register(record, 60_000); | |
| expect(manager.resolve(record.id, "allow-once")).toBe(true); | |
| const respond = vi.fn(); | |
| await handleApprovalWaitDecision({ | |
| manager, | |
| inputId: record.id, | |
| respond, | |
| client: createApprovalClient({ | |
| connId: "conn-other", | |
| clientId: "client-other", | |
| deviceId: "device-other", | |
| scopes: ["operator.approvals"], | |
| }), | |
| }); | |
| expect(respond).toHaveBeenCalledWith( | |
| false, | |
| undefined, | |
| expect.objectContaining({ | |
| code: "INVALID_REQUEST", | |
| message: "approval expired or not found", | |
| }), | |
| ); | |
| }); | |
| it("allows visible callers to wait for approval decisions", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-wait-visible", | |
| ); | |
| record.requestedByDeviceId = "device-owner"; | |
| record.requestedByConnId = "conn-owner"; | |
| record.requestedByClientId = "client-owner"; | |
| void manager.register(record, 60_000); | |
| expect(manager.resolve(record.id, "deny")).toBe(true); | |
| const respond = vi.fn(); | |
| await handleApprovalWaitDecision({ | |
| manager, | |
| inputId: record.id, | |
| respond, | |
| client: createApprovalClient({ | |
| connId: "conn-owner-approval", | |
| clientId: "client-owner", | |
| deviceId: "device-owner", | |
| scopes: ["operator.approvals"], | |
| }), | |
| }); | |
| expect(respond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ | |
| id: "approval-wait-visible", | |
| decision: "deny", | |
| }), | |
| undefined, | |
| ); | |
| }); | |
| it("releases run-aborted waiters without changing timeout terminal state", async () => { | |
| const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-wait-terminal-")); | |
| const manager = new ExecApprovalManager({ | |
| approvalKind: "exec", | |
| persistence: { | |
| runtimeEpoch: "approval-shared-wait-terminal", | |
| databaseOptions: { path: path.join(tempDir, "state.sqlite") }, | |
| }, | |
| }); | |
| const record = manager.create( | |
| { command: "echo ok", runId: "run-aborted" }, | |
| 60_000, | |
| "approval-wait-run-aborted", | |
| ); | |
| record.requestedByDeviceId = "device-owner"; | |
| record.requestedByConnId = "conn-owner"; | |
| record.requestedByClientId = "client-owner"; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const waiting = handleApprovalWaitDecision({ | |
| manager, | |
| inputId: record.id, | |
| respond, | |
| client: createApprovalClient({ | |
| connId: "conn-owner-approval", | |
| clientId: "client-owner", | |
| deviceId: "device-owner", | |
| scopes: ["operator.approvals"], | |
| }), | |
| }); | |
| expect( | |
| manager.forceDenyDetailed( | |
| record.id, | |
| "run-aborted", | |
| { kind: "system", id: null }, | |
| "cancelled", | |
| ), | |
| ).toMatchObject({ outcome: "denied" }); | |
| await waiting; | |
| expect(respond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ | |
| id: record.id, | |
| decision: null, | |
| terminalReason: "run-aborted", | |
| }), | |
| undefined, | |
| ); | |
| const timeoutRecord = manager.create( | |
| { command: "echo later", runId: "run-timeout" }, | |
| 60_000, | |
| "approval-wait-timeout", | |
| ); | |
| timeoutRecord.requestedByDeviceId = "device-owner"; | |
| timeoutRecord.requestedByConnId = "conn-owner"; | |
| timeoutRecord.requestedByClientId = "client-owner"; | |
| void manager.register(timeoutRecord, 60_000); | |
| const timeoutRespond = vi.fn(); | |
| const timeoutWaiting = handleApprovalWaitDecision({ | |
| manager, | |
| inputId: timeoutRecord.id, | |
| respond: timeoutRespond, | |
| client: createApprovalClient({ | |
| connId: "conn-owner-approval", | |
| clientId: "client-owner", | |
| deviceId: "device-owner", | |
| }), | |
| }); | |
| expect(manager.expire(timeoutRecord.id)).toBe(true); | |
| await timeoutWaiting; | |
| expect(timeoutRespond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ | |
| id: timeoutRecord.id, | |
| decision: null, | |
| terminalReason: "timeout", | |
| }), | |
| undefined, | |
| ); | |
| const allowedRecord = manager.create( | |
| { command: "echo allowed", runId: "run-allowed-before-abort" }, | |
| 60_000, | |
| "approval-wait-allowed-before-abort", | |
| ); | |
| allowedRecord.requestedByDeviceId = "device-owner"; | |
| allowedRecord.requestedByConnId = "conn-owner"; | |
| allowedRecord.requestedByClientId = "client-owner"; | |
| void manager.register(allowedRecord, 60_000); | |
| expect(manager.resolve(allowedRecord.id, "allow-once")).toBe(true); | |
| const allowedRespond = vi.fn(); | |
| await handleApprovalWaitDecision({ | |
| manager, | |
| inputId: allowedRecord.id, | |
| respond: allowedRespond, | |
| client: createApprovalClient({ | |
| connId: "conn-owner-approval", | |
| clientId: "client-owner", | |
| deviceId: "device-owner", | |
| }), | |
| resolveTerminalReason: () => "run-aborted", | |
| }); | |
| expect(allowedRespond).toHaveBeenCalledWith( | |
| true, | |
| expect.objectContaining({ | |
| id: allowedRecord.id, | |
| decision: "allow-once", | |
| terminalReason: "run-aborted", | |
| }), | |
| undefined, | |
| ); | |
| closeOpenClawStateDatabaseForTest(); | |
| fs.rmSync(tempDir, { force: true, recursive: true }); | |
| }); | |
| it("does not allow approval-scoped clients to resolve no-device gateway-client approvals from another connection", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-gateway-resolve", | |
| ); | |
| record.requestedByConnId = "conn-gateway"; | |
| record.requestedByClientId = GATEWAY_CLIENT_IDS.GATEWAY_CLIENT; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| await handleApprovalResolve({ | |
| approvalKind: "exec", | |
| manager, | |
| inputId: record.id, | |
| decision: "allow-once", | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-mobile-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| scopes: ["operator.approvals"], | |
| }), | |
| ]), | |
| ), | |
| } as unknown as GatewayRequestContext, | |
| client: createApprovalClient({ | |
| connId: "conn-mobile-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| scopes: ["operator.approvals"], | |
| }), | |
| }); | |
| expect(respond).toHaveBeenCalledWith( | |
| false, | |
| undefined, | |
| expect.objectContaining({ | |
| code: "INVALID_REQUEST", | |
| message: "unknown or expired approval id", | |
| }), | |
| ); | |
| expect(manager.getSnapshot(record.id)?.decision).toBeUndefined(); | |
| }); | |
| it("does not allow approval-scoped clients to resolve no-device browser UI approvals from another connection", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-control-ui-resolve", | |
| ); | |
| record.requestedByConnId = "conn-control-ui"; | |
| record.requestedByClientId = GATEWAY_CLIENT_IDS.CONTROL_UI; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| await handleApprovalResolve({ | |
| approvalKind: "exec", | |
| manager, | |
| inputId: record.id, | |
| decision: "allow-once", | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-mobile-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| scopes: ["operator.approvals"], | |
| }), | |
| ]), | |
| ), | |
| } as unknown as GatewayRequestContext, | |
| client: createApprovalClient({ | |
| connId: "conn-mobile-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| scopes: ["operator.approvals"], | |
| }), | |
| }); | |
| expect(respond).toHaveBeenCalledWith( | |
| false, | |
| undefined, | |
| expect.objectContaining({ | |
| code: "INVALID_REQUEST", | |
| message: "unknown or expired approval id", | |
| }), | |
| ); | |
| expect(manager.getSnapshot(record.id)?.decision).toBeUndefined(); | |
| }); | |
| it("does not allow approval-scoped clients to resolve device-bound gateway-client approvals from another device", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-gateway-device-resolve", | |
| ); | |
| record.requestedByDeviceId = "device-gateway"; | |
| record.requestedByConnId = "conn-gateway"; | |
| record.requestedByClientId = GATEWAY_CLIENT_IDS.GATEWAY_CLIENT; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| await handleApprovalResolve({ | |
| approvalKind: "exec", | |
| manager, | |
| inputId: record.id, | |
| decision: "allow-once", | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-mobile-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| deviceId: "device-mobile", | |
| scopes: ["operator.approvals"], | |
| }), | |
| ]), | |
| ), | |
| } as unknown as GatewayRequestContext, | |
| client: createApprovalClient({ | |
| connId: "conn-mobile-approval", | |
| clientId: GATEWAY_CLIENT_IDS.IOS_APP, | |
| deviceId: "device-mobile", | |
| scopes: ["operator.approvals"], | |
| }), | |
| }); | |
| expect(respond).toHaveBeenCalledWith( | |
| false, | |
| undefined, | |
| expect.objectContaining({ | |
| code: "INVALID_REQUEST", | |
| message: "unknown or expired approval id", | |
| }), | |
| ); | |
| expect(manager.getSnapshot(record.id)?.decision).toBeUndefined(); | |
| }); | |
| it("allows gateway-client approval runtimes to resolve requester-bound approvals", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-delivery-runtime-resolve", | |
| ); | |
| record.requestedByDeviceId = "device-owner"; | |
| record.requestedByConnId = "conn-owner"; | |
| record.requestedByClientId = "client-owner"; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| await handleApprovalResolve({ | |
| approvalKind: "exec", | |
| manager, | |
| inputId: record.id, | |
| decision: "allow-once", | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-delivery-runtime", | |
| clientId: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, | |
| scopes: ["operator.approvals"], | |
| approvalRuntime: true, | |
| }), | |
| ]), | |
| ), | |
| } as unknown as GatewayRequestContext, | |
| client: createApprovalClient({ | |
| connId: "conn-delivery-runtime", | |
| clientId: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, | |
| scopes: ["operator.approvals"], | |
| approvalRuntime: true, | |
| }), | |
| }); | |
| expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); | |
| expect(manager.getSnapshot(record.id)?.decision).toBe("allow-once"); | |
| }); | |
| it("filters legacy prefix candidates by channel custody before resolving", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const owned = manager.create({ command: "owned" }, 60_000, "approval-prefix-owned"); | |
| const foreign = manager.create({ command: "foreign" }, 60_000, "approval-prefix-foreign"); | |
| void manager.register(owned, 60_000); | |
| void manager.register(foreign, 60_000); | |
| prepareApprovalChannelCustodyMock.mockReturnValueOnce({ | |
| resolverId: "telegram:ops", | |
| authorizes: (request: { request: { command: string } }) => | |
| request.request.command === "owned", | |
| }); | |
| const respond = vi.fn(); | |
| await handleApprovalResolve({ | |
| approvalKind: "exec", | |
| manager, | |
| inputId: "approval-prefix", | |
| decision: "deny", | |
| reviewer: { channel: "telegram", accountId: "ops", senderId: "owner" }, | |
| respond, | |
| context: { | |
| broadcast: vi.fn(), | |
| broadcastToConnIds: vi.fn(), | |
| getRuntimeConfig: () => ({}), | |
| } as unknown as GatewayRequestContext, | |
| client: null, | |
| exposeAmbiguousPrefixError: true, | |
| }); | |
| expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); | |
| expect(manager.getSnapshot(owned.id)?.decision).toBe("deny"); | |
| expect(manager.getSnapshot(foreign.id)?.decision).toBeUndefined(); | |
| }); | |
| it("targets resolved approval events to visible approval clients when available", async (testContext) => { | |
| const manager = createTestApprovalManager(testContext); | |
| const record = manager.create( | |
| { | |
| command: "echo ok", | |
| }, | |
| 60_000, | |
| "approval-resolved-visible", | |
| ); | |
| record.requestedByDeviceId = "device-owner"; | |
| void manager.register(record, 60_000); | |
| const respond = vi.fn(); | |
| const broadcast = vi.fn(); | |
| const broadcastToConnIds = vi.fn(); | |
| const visibleConnIds = new Set(["conn-owner-approval"]); | |
| await handleApprovalResolve({ | |
| approvalKind: "exec", | |
| manager, | |
| inputId: record.id, | |
| decision: "allow-once", | |
| respond, | |
| context: { | |
| broadcast, | |
| broadcastToConnIds, | |
| getApprovalClientConnIds: vi.fn( | |
| createApprovalClientLookup([ | |
| createApprovalClient({ | |
| connId: "conn-owner-approval", | |
| clientId: "client-owner", | |
| deviceId: "device-owner", | |
| }), | |
| createApprovalClient({ | |
| connId: "conn-other-approval", | |
| clientId: "client-other", | |
| deviceId: "device-other", | |
| }), | |
| ]), | |
| ), | |
| } as unknown as GatewayRequestContext, | |
| client: createApprovalClient({ | |
| connId: "conn-owner-approval", | |
| clientId: "client-owner", | |
| deviceId: "device-owner", | |
| }), | |
| }); | |
| expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); | |
| expect(broadcast).not.toHaveBeenCalled(); | |
| expect(broadcastToConnIds).toHaveBeenCalledWith( | |
| "exec.approval.resolved", | |
| expect.objectContaining({ id: "approval-resolved-visible" }), | |
| visibleConnIds, | |
| { dropIfSlow: true }, | |
| ); | |
| }); | |
| it("sanitizes durable registration failures while retaining server diagnostics", () => { | |
| const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-register-failure-")); | |
| const databasePath = path.join(tempDir, "state.sqlite"); | |
| fs.mkdirSync(databasePath); | |
| const manager = new ExecApprovalManager({ | |
| approvalKind: "exec", | |
| persistence: { | |
| runtimeEpoch: "approval-shared-register-failure", | |
| databaseOptions: { path: databasePath }, | |
| }, | |
| }); | |
| const record = manager.create({ command: "echo safe" }, 60_000, "registration-failure"); | |
| const respond = vi.fn(); | |
| const logError = vi.fn(); | |
| try { | |
| expect( | |
| registerPendingApprovalRecord({ | |
| manager, | |
| record, | |
| timeoutMs: 60_000, | |
| respond, | |
| context: { logGateway: { error: logError } } as unknown as GatewayRequestContext, | |
| }), | |
| ).toBeUndefined(); | |
| expect(respond).toHaveBeenCalledWith( | |
| false, | |
| undefined, | |
| expect.objectContaining({ code: "UNAVAILABLE", message: "approval request unavailable" }), | |
| ); | |
| expect(JSON.stringify(respond.mock.calls)).not.toContain(databasePath); | |
| expect(logError).toHaveBeenCalledTimes(1); | |
| } finally { | |
| closeOpenClawStateDatabaseForTest(); | |
| fs.rmSync(tempDir, { force: true, recursive: true }); | |
| } | |
| }); | |
| it("sanitizes a no-route storage failure while failing the waiter closed", async () => { | |
| hasApprovalTurnSourceRouteMock.mockReturnValueOnce(false); | |
| const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-route-failure-")); | |
| const databasePath = path.join(tempDir, "state.sqlite"); | |
| const manager = new ExecApprovalManager({ | |
| approvalKind: "exec", | |
| persistence: { | |
| runtimeEpoch: "approval-shared-route-failure", | |
| databaseOptions: { path: databasePath }, | |
| }, | |
| }); | |
| const record = manager.create({ command: "echo safe" }, 60_000, "route-failure"); | |
| const decisionPromise = manager.register(record, 60_000); | |
| const afterDecision = vi.fn(); | |
| closeOpenClawStateDatabaseForTest(); | |
| fs.rmSync(databasePath, { force: true }); | |
| fs.mkdirSync(databasePath); | |
| const respond = vi.fn(); | |
| const logError = vi.fn(); | |
| try { | |
| await handlePendingApprovalRequest({ | |
| manager, | |
| record, | |
| respond, | |
| context: { | |
| broadcast: vi.fn(), | |
| hasExecApprovalClients: () => false, | |
| logGateway: { error: logError }, | |
| } as unknown as GatewayRequestContext, | |
| requestEventName: "exec.approval.requested", | |
| requestEvent: { | |
| id: record.id, | |
| request: record.request, | |
| createdAtMs: record.createdAtMs, | |
| expiresAtMs: record.expiresAtMs, | |
| }, | |
| twoPhase: true, | |
| deliverRequest: () => false, | |
| afterDecision, | |
| }); | |
| await manager.drain(); | |
| expect(respond).toHaveBeenCalledWith( | |
| false, | |
| undefined, | |
| expect.objectContaining({ code: "UNAVAILABLE", message: "approval request unavailable" }), | |
| ); | |
| expect(respond).toHaveBeenCalledOnce(); | |
| expect(afterDecision).not.toHaveBeenCalled(); | |
| expect(JSON.stringify(respond.mock.calls)).not.toContain(databasePath); | |
| expect(logError).toHaveBeenCalledTimes(1); | |
| await expect(decisionPromise).resolves.toBe("deny"); | |
| } finally { | |
| await manager.drain(); | |
| closeOpenClawStateDatabaseForTest(); | |
| fs.rmSync(tempDir, { force: true, recursive: true }); | |
| } | |
| }); | |
| it("sanitizes durable resolve failures while failing the waiter closed", async () => { | |
| const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-resolve-failure-")); | |
| const databasePath = path.join(tempDir, "state.sqlite"); | |
| const manager = new ExecApprovalManager({ | |
| approvalKind: "exec", | |
| persistence: { | |
| runtimeEpoch: "approval-shared-resolve-failure", | |
| databaseOptions: { path: databasePath }, | |
| }, | |
| }); | |
| const record = manager.create({ command: "echo safe" }, 60_000, "resolve-failure"); | |
| const decisionPromise = manager.register(record, 60_000); | |
| closeOpenClawStateDatabaseForTest(); | |
| fs.rmSync(databasePath, { force: true }); | |
| fs.mkdirSync(databasePath); | |
| const respond = vi.fn(); | |
| const logError = vi.fn(); | |
| try { | |
| await handleApprovalResolve({ | |
| approvalKind: "exec", | |
| manager, | |
| inputId: record.id, | |
| decision: "deny", | |
| respond, | |
| context: { | |
| broadcast: vi.fn(), | |
| broadcastToConnIds: vi.fn(), | |
| logGateway: { error: logError }, | |
| } as unknown as GatewayRequestContext, | |
| client: null, | |
| }); | |
| expect(respond).toHaveBeenCalledWith( | |
| false, | |
| undefined, | |
| expect.objectContaining({ code: "UNAVAILABLE", message: "approval resolve unavailable" }), | |
| ); | |
| expect(JSON.stringify(respond.mock.calls)).not.toContain(databasePath); | |
| expect(logError).toHaveBeenCalledTimes(1); | |
| await expect(decisionPromise).resolves.toBe("deny"); | |
| } finally { | |
| closeOpenClawStateDatabaseForTest(); | |
| fs.rmSync(tempDir, { force: true, recursive: true }); | |
| } | |
| }); | |
| }); | |
| /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ | |