openclaw / src /gateway /server-methods /approval-shared.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
78d0c37 verified
Raw History Blame Contribute Delete
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. */