refactor: reuse operator approval gateway lifecycle

This commit is contained in:
Peter Steinberger
2026-04-21 00:55:50 +01:00
parent 6c67339798
commit 4ea8063203
3 changed files with 52 additions and 119 deletions

View File

@@ -106,4 +106,19 @@ describe("withOperatorApprovalsGatewayClient", () => {
),
).rejects.toThrow("gateway closed (1008): pairing required");
});
it("falls back to stop when stopAndWait rejects", async () => {
clientState.stopAndWaitSpy.mockRejectedValueOnce(new Error("close failed"));
await withOperatorApprovalsGatewayClient(
{
config: {} as never,
clientDisplayName: "Matrix approval (@owner:example.org)",
},
async () => undefined,
);
expect(clientState.stopAndWaitSpy).toHaveBeenCalledTimes(1);
expect(clientState.stopSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2,51 +2,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveApprovalOverGateway } from "./approval-gateway-resolver.js";
const hoisted = vi.hoisted(() => ({
createOperatorApprovalsGatewayClient: vi.fn(),
clientStart: vi.fn(),
clientStop: vi.fn(),
clientStopAndWait: vi.fn(),
withOperatorApprovalsGatewayClient: vi.fn(),
clientRequest: vi.fn(),
}));
vi.mock("../gateway/operator-approvals-client.js", () => ({
createOperatorApprovalsGatewayClient: hoisted.createOperatorApprovalsGatewayClient,
withOperatorApprovalsGatewayClient: hoisted.withOperatorApprovalsGatewayClient,
}));
function createGatewayClient(params: {
stopAndWaitRejects?: boolean;
requestImpl?: typeof hoisted.clientRequest;
}) {
const request = params.requestImpl ?? hoisted.clientRequest;
return {
start: () => {
hoisted.clientStart();
},
stop: hoisted.clientStop,
stopAndWait: params.stopAndWaitRejects
? vi.fn(async () => {
hoisted.clientStopAndWait();
throw new Error("close failed");
})
: vi.fn(async () => {
hoisted.clientStopAndWait();
}),
request,
};
}
describe("resolveApprovalOverGateway", () => {
beforeEach(() => {
hoisted.clientStart.mockReset();
hoisted.clientStop.mockReset();
hoisted.clientStopAndWait.mockReset();
hoisted.clientRequest.mockReset().mockResolvedValue({ ok: true });
hoisted.createOperatorApprovalsGatewayClient.mockReset().mockImplementation(async (params) => {
const client = createGatewayClient({});
queueMicrotask(() => {
params.onHelloOk?.({} as never);
});
return client;
hoisted.withOperatorApprovalsGatewayClient.mockReset().mockImplementation(async (_, run) => {
await run({ request: hoisted.clientRequest });
});
});
@@ -59,19 +27,18 @@ describe("resolveApprovalOverGateway", () => {
clientDisplayName: "Discord approval (default)",
});
expect(hoisted.createOperatorApprovalsGatewayClient).toHaveBeenCalledWith(
expect.objectContaining({
expect(hoisted.withOperatorApprovalsGatewayClient).toHaveBeenCalledWith(
{
config: { gateway: { auth: { token: "cfg-token" } } },
gatewayUrl: "ws://gateway.example.test",
clientDisplayName: "Discord approval (default)",
}),
},
expect.any(Function),
);
expect(hoisted.clientStart).toHaveBeenCalledTimes(1);
expect(hoisted.clientRequest).toHaveBeenCalledWith("exec.approval.resolve", {
id: "approval-1",
decision: "allow-once",
});
expect(hoisted.clientStopAndWait).toHaveBeenCalledTimes(1);
});
it("routes plugin approvals through plugin.approval.resolve", async () => {
@@ -121,23 +88,4 @@ describe("resolveApprovalOverGateway", () => {
expect(hoisted.clientRequest).toHaveBeenCalledTimes(1);
});
it("falls back to stop when stopAndWait rejects", async () => {
hoisted.createOperatorApprovalsGatewayClient.mockReset().mockImplementation(async (params) => {
const client = createGatewayClient({ stopAndWaitRejects: true });
queueMicrotask(() => {
params.onHelloOk?.({} as never);
});
return client;
});
await resolveApprovalOverGateway({
cfg: {} as never,
approvalId: "approval-1",
decision: "allow-once",
});
expect(hoisted.clientStopAndWait).toHaveBeenCalledTimes(1);
expect(hoisted.clientStop).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js";
import { withOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js";
import { isApprovalNotFoundError } from "./approval-errors.js";
import type { ExecApprovalDecision } from "./exec-approvals.js";
@@ -16,64 +16,34 @@ export type ResolveApprovalOverGatewayParams = {
export async function resolveApprovalOverGateway(
params: ResolveApprovalOverGatewayParams,
): Promise<void> {
let readySettled = false;
let resolveReady!: () => void;
let rejectReady!: (err: unknown) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const markReady = () => {
if (readySettled) {
return;
}
readySettled = true;
resolveReady();
};
const failReady = (err: unknown) => {
if (readySettled) {
return;
}
readySettled = true;
rejectReady(err);
};
const gatewayClient = await createOperatorApprovalsGatewayClient({
config: params.cfg,
gatewayUrl: params.gatewayUrl,
clientDisplayName:
params.clientDisplayName ?? `Approval (${params.senderId?.trim() || "unknown"})`,
onHelloOk: markReady,
onConnectError: failReady,
onClose: (code, reason) => {
failReady(new Error(`gateway closed (${code}): ${reason}`));
await withOperatorApprovalsGatewayClient(
{
config: params.cfg,
gatewayUrl: params.gatewayUrl,
clientDisplayName:
params.clientDisplayName ?? `Approval (${params.senderId?.trim() || "unknown"})`,
},
});
try {
gatewayClient.start();
await ready;
const requestResolve = async (method: "exec.approval.resolve" | "plugin.approval.resolve") => {
await gatewayClient.request(method, {
id: params.approvalId,
decision: params.decision,
});
};
if (params.approvalId.startsWith("plugin:")) {
await requestResolve("plugin.approval.resolve");
return;
}
try {
await requestResolve("exec.approval.resolve");
} catch (err) {
if (!params.allowPluginFallback || !isApprovalNotFoundError(err)) {
throw err;
async (gatewayClient) => {
const requestResolve = async (
method: "exec.approval.resolve" | "plugin.approval.resolve",
) => {
await gatewayClient.request(method, {
id: params.approvalId,
decision: params.decision,
});
};
if (params.approvalId.startsWith("plugin:")) {
await requestResolve("plugin.approval.resolve");
return;
}
await requestResolve("plugin.approval.resolve");
}
} finally {
await gatewayClient.stopAndWait().catch(() => {
gatewayClient.stop();
});
}
try {
await requestResolve("exec.approval.resolve");
} catch (err) {
if (!params.allowPluginFallback || !isApprovalNotFoundError(err)) {
throw err;
}
await requestResolve("plugin.approval.resolve");
}
},
);
}