openclaw / src /infra /approval-handler-runtime.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
9299241 verified
Raw History Blame Contribute Delete
21.8 kB
// Covers approval handler runtime adapter creation and lazy wiring.
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { createDeferred, withTestTimeout } from "../../test/helpers/promise.js";
import type { ChannelApprovalNativeRuntimeAdapter } from "./approval-handler-runtime-types.js";
import {
createChannelApprovalNativeRuntimeAdapter,
createChannelApprovalHandlerFromCapability,
createLazyChannelApprovalNativeRuntimeAdapter,
} from "./approval-handler-runtime.js";
import {
createApprovalNativeRuntimeAdapterStubs,
type ApprovalNativeRuntimeAdapterStubParams,
} from "./approval-handler.test-helpers.js";
import type { NormalizedApprovalRequest } from "./approval-types.js";
import type { ExecApprovalRequest } from "./exec-approvals.js";
import type { PluginApprovalRequest } from "./plugin-approvals.js";
type ApprovalCapability = NonNullable<
Parameters<typeof createChannelApprovalHandlerFromCapability>[0]["capability"]
>;
type ApprovalNativeAdapter = NonNullable<ApprovalCapability["native"]>;
const TEST_HANDLER_PARAMS = {
label: "test/approval-handler",
clientDisplayName: "Test Approval Handler",
channel: "test",
channelLabel: "Test",
cfg: { channels: {} } as never,
} as const;
function makeSequentialPendingDeliveryMock() {
return vi
.fn()
.mockResolvedValueOnce({ messageId: "1" })
.mockResolvedValueOnce({ messageId: "2" });
}
function makeSequentialPendingBindingMock() {
return vi
.fn()
.mockResolvedValueOnce({ bindingId: "bound-1" })
.mockResolvedValueOnce({ bindingId: "bound-2" });
}
function makeExecApprovalRequest(id: string): NormalizedApprovalRequest<ExecApprovalRequest> {
return {
approvalKind: "exec",
id,
expiresAtMs: Date.now() + 60_000,
request: {
command: "echo hi",
turnSourceChannel: "test",
turnSourceTo: "origin-chat",
},
createdAtMs: Date.now(),
};
}
function makeNativeApprovalCapability(
params: {
preferredSurface?: ReturnType<
ApprovalNativeAdapter["describeDeliveryCapabilities"]
>["preferredSurface"];
supportsApproverDmSurface?: boolean;
resolveApproverDmTargets?: ApprovalNativeAdapter["resolveApproverDmTargets"];
} & ApprovalNativeRuntimeAdapterStubParams = {},
): ApprovalCapability {
const preferredSurface = params.preferredSurface ?? "origin";
return {
native: {
describeDeliveryCapabilities: vi.fn().mockReturnValue({
enabled: true,
preferredSurface,
supportsOriginSurface: true,
supportsApproverDmSurface: params.supportsApproverDmSurface ?? false,
notifyOriginWhenDmOnly: false,
}),
resolveOriginTarget: vi.fn().mockReturnValue({ to: "origin-chat" }),
...(params.resolveApproverDmTargets
? { resolveApproverDmTargets: params.resolveApproverDmTargets }
: {}),
},
nativeRuntime: createApprovalNativeRuntimeAdapterStubs(params),
};
}
function createTestApprovalHandler(capability: ApprovalCapability) {
return createChannelApprovalHandlerFromCapability({
capability,
...TEST_HANDLER_PARAMS,
});
}
type ApprovalHandlerRuntime = NonNullable<Awaited<ReturnType<typeof createTestApprovalHandler>>>;
function expectApprovalRuntime(
runtime: Awaited<ReturnType<typeof createTestApprovalHandler>>,
): ApprovalHandlerRuntime {
if (runtime === null) {
throw new Error("Expected approval handler runtime");
}
expect(typeof runtime.handleRequested).toBe("function");
return runtime;
}
function firstCallArg(mock: ReturnType<typeof vi.fn>): unknown {
return mock.mock.calls[0]?.[0];
}
describe("createChannelApprovalHandlerFromCapability", () => {
it("returns null when the capability does not expose a native runtime", async () => {
await expect(
createChannelApprovalHandlerFromCapability({
capability: {},
...TEST_HANDLER_PARAMS,
}),
).resolves.toBeNull();
});
it("returns a runtime when the capability exposes a native runtime", async () => {
const runtime = await createChannelApprovalHandlerFromCapability({
capability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
...TEST_HANDLER_PARAMS,
});
expectApprovalRuntime(runtime);
});
it("derives kind once before stop-time cleanup unbinds", async () => {
const unbindPending = vi.fn();
const shouldHandle = vi.fn().mockReturnValue(true);
const runtime = await createTestApprovalHandler(
makeNativeApprovalCapability({
eventKinds: ["plugin"],
shouldHandle,
unbindPending,
}),
);
const approvalRuntime = expectApprovalRuntime(runtime);
const request: PluginApprovalRequest = {
id: "custom:1",
createdAtMs: Date.now(),
expiresAtMs: Date.now() + 60_000,
request: {
title: "Plugin approval",
description: "Allow the plugin action",
turnSourceChannel: "test",
turnSourceTo: "origin-chat",
},
};
const normalizedRequest = { ...request, approvalKind: "plugin" as const };
await approvalRuntime.handleRequested(request);
expect(shouldHandle).toHaveBeenCalledWith(
expect.objectContaining({ request: normalizedRequest, approvalKind: "plugin" }),
);
await approvalRuntime.stop();
expect(unbindPending).toHaveBeenCalledOnce();
const stopUnbind = firstCallArg(unbindPending) as
| { request?: unknown; approvalKind?: string }
| undefined;
expect(stopUnbind?.request).toEqual(normalizedRequest);
expect(stopUnbind?.approvalKind).toBe("plugin");
});
it("normalizes and cleans up system-agent entries through the shared lifecycle", async () => {
const shouldHandle = vi.fn().mockReturnValue(true);
const unbindPending = vi.fn();
const onFinalized = vi.fn();
const buildResolvedResult = vi.fn().mockResolvedValue({ kind: "leave" });
const runtime = await createTestApprovalHandler(
makeNativeApprovalCapability({
eventKinds: ["system-agent"],
shouldHandle,
buildResolvedResult,
unbindPending,
onFinalized,
}),
);
const approvalRuntime = expectApprovalRuntime(runtime);
const request = {
id: "system-agent:1",
request: {
title: "OpenClaw change",
description: "restart the Gateway",
command: "restart the Gateway",
proposalHash: "a".repeat(64),
allowedDecisions: ["allow-once", "deny"] as const,
sessionId: "delegation-1",
},
createdAtMs: 0,
expiresAtMs: Date.now() + 60_000,
};
await approvalRuntime.handleRequested(request);
expect(shouldHandle).toHaveBeenCalledWith(
expect.objectContaining({ approvalKind: "system-agent" }),
);
await approvalRuntime.handleResolved({
id: request.id,
decision: "deny",
ts: 1,
} as never);
expect(unbindPending).toHaveBeenCalledWith(
expect.objectContaining({ approvalKind: "system-agent" }),
);
expect(buildResolvedResult).toHaveBeenCalledOnce();
expect(onFinalized).toHaveBeenCalledWith(
expect.objectContaining({ approvalKind: "system-agent", phase: "resolved" }),
);
});
it("honors the shipped approval kind override through the capability runtime", async () => {
const resolveApprovalKind = vi.fn().mockReturnValue("plugin");
const shouldHandle = vi.fn().mockReturnValue(true);
const runtime = await createTestApprovalHandler(
makeNativeApprovalCapability({
eventKinds: ["plugin"],
resolveApprovalKind,
shouldHandle,
}),
);
const approvalRuntime = expectApprovalRuntime(runtime);
const request: PluginApprovalRequest = {
id: "plugin:legacy-owned-id",
createdAtMs: Date.now(),
expiresAtMs: Date.now() + 60_000,
request: {
title: "Plugin approval",
description: "Allow the plugin action",
},
};
const normalizedRequest = { ...request, approvalKind: "plugin" as const };
await approvalRuntime.handleRequested(request);
expect(resolveApprovalKind).toHaveBeenCalledWith(normalizedRequest);
expect(shouldHandle).toHaveBeenCalledWith(
expect.objectContaining({ request: normalizedRequest, approvalKind: "plugin" }),
);
await approvalRuntime.stop();
});
it("ignores duplicate pending request ids before finalization", async () => {
const unbindPending = vi.fn();
const buildResolvedResult = vi.fn().mockResolvedValue({ kind: "leave" });
const runtime = await createTestApprovalHandler(
makeNativeApprovalCapability({
buildResolvedResult,
deliverPending: makeSequentialPendingDeliveryMock(),
bindPending: makeSequentialPendingBindingMock(),
unbindPending,
}),
);
const approvalRuntime = expectApprovalRuntime(runtime);
const request = makeExecApprovalRequest("exec:1");
await approvalRuntime.handleRequested(request);
await approvalRuntime.handleRequested(request);
await approvalRuntime.handleResolved({
id: "exec:1",
decision: "approved",
resolvedBy: "operator",
} as never);
expect(unbindPending).toHaveBeenCalledTimes(1);
const unbind = firstCallArg(unbindPending) as
| { entry?: unknown; binding?: unknown; request?: unknown }
| undefined;
expect(unbind?.entry).toEqual({ messageId: "1" });
expect(unbind?.binding).toEqual({ bindingId: "bound-1" });
expect(unbind?.request).toBe(request);
expect(buildResolvedResult).toHaveBeenCalledTimes(1);
});
it("continues finalization cleanup after one resolved entry unbind failure", async () => {
const unbindPending = vi
.fn()
.mockRejectedValueOnce(new Error("unbind failed"))
.mockResolvedValueOnce(undefined);
const buildResolvedResult = vi.fn().mockResolvedValue({ kind: "leave" });
const runtime = await createTestApprovalHandler(
makeNativeApprovalCapability({
preferredSurface: "both",
supportsApproverDmSurface: true,
resolveApproverDmTargets: vi.fn().mockResolvedValue([{ to: "approver-dm" }]),
buildResolvedResult,
prepareTarget: vi.fn().mockImplementation(async ({ plannedTarget }) => ({
dedupeKey: String(plannedTarget.target.to),
target: { to: plannedTarget.target.to },
})),
deliverPending: makeSequentialPendingDeliveryMock(),
bindPending: makeSequentialPendingBindingMock(),
unbindPending,
}),
);
const request = makeExecApprovalRequest("exec:2");
const approvalRuntime = expectApprovalRuntime(runtime);
await approvalRuntime.handleRequested(request);
await expect(
approvalRuntime.handleResolved({
id: "exec:2",
decision: "approved",
resolvedBy: "operator",
} as never),
).resolves.toBeUndefined();
expect(unbindPending).toHaveBeenCalledTimes(2);
expect(buildResolvedResult).toHaveBeenCalledTimes(1);
const resolvedPayload = firstCallArg(buildResolvedResult) as { entry?: unknown } | undefined;
expect(resolvedPayload?.entry).toEqual({ messageId: "2" });
});
it("continues stop-time unbind cleanup when one binding throws", async () => {
const unbindPending = vi
.fn()
.mockRejectedValueOnce(new Error("unbind failed"))
.mockResolvedValueOnce(undefined);
const runtime = await createTestApprovalHandler(
makeNativeApprovalCapability({
deliverPending: makeSequentialPendingDeliveryMock(),
bindPending: makeSequentialPendingBindingMock(),
unbindPending,
}),
);
const request = makeExecApprovalRequest("exec:stop-1");
const approvalRuntime = expectApprovalRuntime(runtime);
await approvalRuntime.handleRequested(request);
await approvalRuntime.handleRequested({
...request,
id: "exec:stop-2",
});
await expect(approvalRuntime.stop()).resolves.toBeUndefined();
expect(unbindPending).toHaveBeenCalledTimes(2);
await expect(approvalRuntime.stop()).resolves.toBeUndefined();
expect(unbindPending).toHaveBeenCalledTimes(2);
});
});
describe("createLazyChannelApprovalNativeRuntimeAdapter", () => {
it("preserves the deprecated kind callback through the typed adapter factory", () => {
const resolveApprovalKind = vi.fn().mockReturnValue("plugin");
const adapter = createChannelApprovalNativeRuntimeAdapter({
resolveApprovalKind,
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn().mockReturnValue({ text: "pending" }),
buildResolvedResult: vi.fn().mockReturnValue({ kind: "leave" }),
buildExpiredResult: vi.fn().mockReturnValue({ kind: "leave" }),
},
transport: {
prepareTarget: vi.fn().mockReturnValue(null),
deliverPending: vi.fn().mockReturnValue(null),
},
});
const request = { id: "opaque-plugin-id" } as never;
expect(adapter.resolveApprovalKind?.(request)).toBe("plugin");
expect(resolveApprovalKind).toHaveBeenCalledWith(request);
});
it("loads the runtime lazily and reuses the loaded adapter", async () => {
const explicitIsConfigured = vi.fn().mockReturnValue(true);
const explicitShouldHandle = vi.fn().mockReturnValue(false);
const resolveApprovalKind = vi.fn().mockReturnValue("exec");
const buildPendingPayload = vi.fn(async () => ({ text: "pending" }));
const load = vi.fn(
async (): Promise<ChannelApprovalNativeRuntimeAdapter<{ text: string }>> => ({
availability: {
isConfigured: vi.fn(),
shouldHandle: vi.fn(),
},
presentation: {
buildPendingPayload,
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
}),
);
const adapter = createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec"],
resolveApprovalKind,
isConfigured: explicitIsConfigured,
shouldHandle: explicitShouldHandle,
load,
});
const cfg = { channels: {} } as never;
const request = { id: "exec:1" } as never;
const view = {} as never;
expectTypeOf<
Awaited<ReturnType<typeof adapter.presentation.buildPendingPayload>>
>().toEqualTypeOf<{ text: string }>();
expect(adapter.eventKinds).toEqual(["exec"]);
expect(adapter.resolveApprovalKind?.(request)).toBe("exec");
expect(resolveApprovalKind).toHaveBeenCalledWith(request);
expect(adapter.availability.isConfigured({ cfg })).toBe(true);
expect(adapter.availability.shouldHandle({ cfg, request, approvalKind: "exec" })).toBe(false);
await expect(
adapter.presentation.buildPendingPayload({
cfg,
request,
approvalKind: "exec",
nowMs: 1,
view,
}),
).resolves.toEqual({ text: "pending" });
expect(load).toHaveBeenCalledTimes(1);
expect(explicitIsConfigured).toHaveBeenCalledWith({ cfg });
expect(explicitShouldHandle).toHaveBeenCalledWith({ cfg, request, approvalKind: "exec" });
expect(buildPendingPayload).toHaveBeenCalledWith({
cfg,
request,
approvalKind: "exec",
nowMs: 1,
view,
});
});
it("keeps observe hooks synchronous and only uses the already-loaded runtime", async () => {
const onDelivered = vi.fn();
const load = vi.fn().mockResolvedValue({
availability: {
isConfigured: vi.fn(),
shouldHandle: vi.fn(),
},
presentation: {
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
observe: {
onDelivered,
},
});
const adapter = createLazyChannelApprovalNativeRuntimeAdapter({
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
load,
});
adapter.observe?.onDelivered?.({ request: { id: "exec:1" } } as never);
expect(load).not.toHaveBeenCalled();
expect(onDelivered).not.toHaveBeenCalled();
await adapter.presentation.buildPendingPayload({
cfg: {} as never,
request: { id: "exec:1" } as never,
approvalKind: "exec",
nowMs: 1,
view: {} as never,
});
expect(load).toHaveBeenCalledTimes(1);
adapter.observe?.onDelivered?.({ request: { id: "exec:1" } } as never);
expect(onDelivered).toHaveBeenCalledWith({ request: { id: "exec:1" } });
expect(load).toHaveBeenCalledTimes(1);
});
it("unbinds in-flight wrapped entry when stop() fires between bindPending and activeEntries.set", async () => {
const bindEntered = createDeferred();
const bindGate = createDeferred();
const deliverPending = vi.fn().mockResolvedValue({ messageId: "in-flight" });
const bindPending = vi.fn(async () => {
bindEntered.resolve();
await bindGate.promise;
return { bindingId: "bound-in-flight" };
});
const unbindPending = vi.fn();
const runtime = await createTestApprovalHandler(
makeNativeApprovalCapability({
deliverPending,
bindPending,
unbindPending,
}),
);
const approvalRuntime = expectApprovalRuntime(runtime);
const request = makeExecApprovalRequest("exec:in-flight");
const inflight = approvalRuntime.handleRequested(request);
await withTestTimeout(bindEntered.promise, 1_000, "in-flight approval binding did not start");
// stop() flips the stopped flag while bindPending is parked.
await approvalRuntime.stop();
bindGate.resolve();
await inflight;
expect(unbindPending).toHaveBeenCalledTimes(1);
const unbind = firstCallArg(unbindPending) as
| { entry?: unknown; binding?: unknown; request?: unknown }
| undefined;
expect(unbind?.entry).toEqual({ messageId: "in-flight" });
expect(unbind?.binding).toEqual({ bindingId: "bound-in-flight" });
expect(unbind?.request).toBe(request);
});
it("invokes cancelDelivered when stop() fires between deliverPending and bindPending", async () => {
const deliverEntered = createDeferred();
const deliverGate = createDeferred();
const deliveredEntry = { messageId: "pre-bind" };
const deliverPending = vi.fn(async () => {
deliverEntered.resolve();
await deliverGate.promise;
return deliveredEntry;
});
const bindPending = vi.fn().mockResolvedValue({ bindingId: "should-not-bind" });
const unbindPending = vi.fn();
const cancelDelivered = vi.fn();
const runtime = await createTestApprovalHandler(
makeNativeApprovalCapability({
deliverPending,
bindPending,
unbindPending,
cancelDelivered,
}),
);
const approvalRuntime = expectApprovalRuntime(runtime);
const request = makeExecApprovalRequest("exec:pre-bind");
const inflight = approvalRuntime.handleRequested(request);
await withTestTimeout(
deliverEntered.promise,
1_000,
"pre-bind approval delivery did not start",
);
// stop() flips the stopped flag while deliverPending is still pending.
await approvalRuntime.stop();
deliverGate.resolve();
await inflight;
expect(bindPending).not.toHaveBeenCalled();
expect(unbindPending).not.toHaveBeenCalled();
expect(cancelDelivered).toHaveBeenCalledTimes(1);
const cancel = firstCallArg(cancelDelivered) as
| { entry?: unknown; request?: unknown; approvalKind?: string }
| undefined;
expect(cancel?.entry).toBe(deliveredEntry);
expect(cancel?.request).toBe(request);
expect(cancel?.approvalKind).toBe("exec");
});
it("invokes cancelDelivered when stop() fires after bindPending returned null", async () => {
const bindEntered = createDeferred();
const bindGate = createDeferred();
const deliveredEntry = { messageId: "post-bind-null" };
const deliverPending = vi.fn().mockResolvedValue(deliveredEntry);
const bindPending = vi.fn(async () => {
bindEntered.resolve();
await bindGate.promise;
return null;
});
const unbindPending = vi.fn();
const cancelDelivered = vi.fn();
const runtime = await createTestApprovalHandler(
makeNativeApprovalCapability({
deliverPending,
bindPending,
unbindPending,
cancelDelivered,
}),
);
const approvalRuntime = expectApprovalRuntime(runtime);
const request = makeExecApprovalRequest("exec:post-bind-null");
const inflight = approvalRuntime.handleRequested(request);
await withTestTimeout(bindEntered.promise, 1_000, "null approval binding did not start");
// stop() flips the stopped flag while bindPending is parked; it then resolves to null.
await approvalRuntime.stop();
bindGate.resolve();
await inflight;
expect(unbindPending).not.toHaveBeenCalled();
expect(cancelDelivered).toHaveBeenCalledTimes(1);
const cancel = firstCallArg(cancelDelivered) as
| { entry?: unknown; request?: unknown }
| undefined;
expect(cancel?.entry).toBe(deliveredEntry);
expect(cancel?.request).toBe(request);
});
});