openclaw / src /gateway /server-methods /approval.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
78d0c37 verified
Raw History Blame Contribute Delete
69.4 kB
// Unified approval handlers test safe projections, authorization, and one-shot resolution.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
type ApprovalHistoryResult,
validateApprovalGetResult,
validateApprovalHistoryResult,
validateApprovalResolveResult,
} from "../../../packages/gateway-protocol/src/index.js";
import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
import {
resolveExecApprovalRequestAllowedDecisions,
type ExecApprovalRequestPayload,
} from "../../infra/exec-approvals.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
import {
resolvePluginApprovalRequestAllowedDecisions,
type PluginApprovalRequestPayload,
} from "../../infra/plugin-approvals.js";
import type { SystemAgentApprovalRequestPayload } from "../../infra/system-agent-approvals.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseByPath } from "../../state/openclaw-state-db-cache.js";
import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
type OpenClawStateDatabaseOptions,
} from "../../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import { ensureProfileForEmail, setUserProfileRole } from "../../state/user-profiles.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { ExecApprovalManager } from "../exec-approval-manager.js";
import { createTestApprovalManager } from "../exec-approval-manager.test-support.js";
import type { ExecApprovalManagerOptions } from "../exec-approval-manager.types.js";
import { getOperatorApprovalDetailed, insertOperatorApproval } from "../operator-approval-store.js";
function getOperatorApproval(params: Parameters<typeof getOperatorApprovalDetailed>[0]) {
const result = getOperatorApprovalDetailed(params);
return result.outcome === "found" ? result.record : null;
}
import { createDeferred } from "../../../test/helpers/promise.js";
import {
cancelAgentRuntimeBoundApprovals,
cancelUnboundRunApprovals,
cancelWorkerTurnClaimBoundApprovals,
} from "./approval-run-cancellation.js";
import { createApprovalHandlers } from "./approval.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
const prepareApprovalChannelCustodyMock = vi.hoisted(() => vi.fn());
vi.mock("../approval-channel-custody.js", () => ({
prepareApprovalChannelCustody: prepareApprovalChannelCustodyMock,
}));
const tempDirs: string[] = [];
type OperatorApprovalDatabase = Pick<OpenClawStateKyselyDatabase, "operator_approvals">;
const managersForCleanup: Array<{
listPendingRecords(): Array<{ id: string }>;
expire(id: string, resolvedBy?: string | null): boolean;
}> = [];
function createDatabaseOptions(): OpenClawStateDatabaseOptions {
const stateDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-handler-")),
);
tempDirs.push(stateDir);
return { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } };
}
function createManagers(databaseOptions: OpenClawStateDatabaseOptions) {
const persistence = { runtimeEpoch: "approval-handler-test", databaseOptions };
const execOptions: ExecApprovalManagerOptions<ExecApprovalRequestPayload> = {
approvalKind: "exec",
persistence,
resolveAllowedDecisions: resolveExecApprovalRequestAllowedDecisions,
resolveAudienceSessionKeys: (source) => [source, "agent:main:parent"],
};
const managers = {
exec: new ExecApprovalManager(execOptions),
plugin: new ExecApprovalManager<PluginApprovalRequestPayload>({
approvalKind: "plugin",
persistence,
resolveAllowedDecisions: resolvePluginApprovalRequestAllowedDecisions,
resolveAudienceSessionKeys: (source) => [source, "agent:main:parent"],
}),
systemAgent: new ExecApprovalManager<SystemAgentApprovalRequestPayload>({
approvalKind: "system-agent",
persistence,
resolveAllowedDecisions: (request) => request.allowedDecisions,
resolveAudienceSessionKeys: (source) => [source, "agent:main:parent"],
}),
};
managersForCleanup.push(managers.exec, managers.plugin, managers.systemAgent);
return managers;
}
function deleteDurableApproval(databaseOptions: OpenClawStateDatabaseOptions, id: string): void {
const database = openOpenClawStateDatabase(databaseOptions);
const stateDb = getNodeSqliteKysely<OperatorApprovalDatabase>(database.db);
executeSqliteQuerySync(
database.db,
stateDb.deleteFrom("operator_approvals").where("approval_id", "=", id),
);
}
function corruptDurableApprovalPresentation(
databaseOptions: OpenClawStateDatabaseOptions,
id: string,
): void {
const database = openOpenClawStateDatabase(databaseOptions);
const stateDb = getNodeSqliteKysely<OperatorApprovalDatabase>(database.db);
executeSqliteQuerySync(
database.db,
stateDb
.updateTable("operator_approvals")
.set({ presentation_json: "{}" })
.where("approval_id", "=", id),
);
}
function registerExec(
manager: ExecApprovalManager,
params: {
id: string;
request?: Partial<ExecApprovalRequestPayload>;
expiresAtMs?: number;
requester?: {
connId?: string | null;
deviceId?: string | null;
clientId?: string | null;
};
reviewerDeviceIds?: string[];
},
) {
const record = manager.create(
{
command: "printf approval-handler",
host: "gateway",
agentId: "main",
sessionKey: "agent:main:child",
...params.request,
},
600_000,
params.id,
);
record.requestedByConnId = params.requester?.connId ?? null;
record.requestedByDeviceId =
params.requester && "deviceId" in params.requester
? params.requester.deviceId
: "requester-device";
record.requestedByClientId =
params.requester && "clientId" in params.requester
? params.requester.clientId
: "requester-client";
record.requestedByDeviceTokenAuth = true;
record.approvalReviewerDeviceIds = params.reviewerDeviceIds ?? ["reviewer"];
if (params.expiresAtMs !== undefined) {
record.expiresAtMs = params.expiresAtMs;
}
const decision = manager.register(record, 600_000);
return { record, decision };
}
function registerPlugin(
manager: ExecApprovalManager<PluginApprovalRequestPayload>,
params: {
id: string;
request?: Partial<PluginApprovalRequestPayload>;
reviewerDeviceIds?: string[];
},
) {
const record = manager.create(
{
title: "Plugin permission",
description: "Allow one guarded plugin operation",
severity: "warning",
pluginId: "example-plugin",
toolName: "example-tool",
agentId: "main",
sessionKey: "agent:main:child",
...params.request,
},
600_000,
params.id,
);
record.requestedByDeviceId = "requester-device";
record.requestedByClientId = "requester-client";
record.requestedByDeviceTokenAuth = true;
record.approvalReviewerDeviceIds = params.reviewerDeviceIds ?? ["reviewer"];
const decision = manager.register(record, 600_000);
return { record, decision };
}
function registerSystemAgent(
manager: ExecApprovalManager<SystemAgentApprovalRequestPayload>,
id: string,
) {
const record = manager.create(
{
title: "OpenClaw change",
description: "Set gateway.port to 19001",
command: "Set gateway.port to 19001",
proposalHash: "a".repeat(64),
allowedDecisions: ["allow-once", "deny"],
agentId: "main",
sessionKey: "agent:main:child",
sessionId: "delegation-1",
},
600_000,
id,
);
const decision = manager.register(record, 600_000);
return { record, decision };
}
function createClient(params: {
scopes?: string[];
deviceId?: string;
internal?: boolean;
connId?: string;
}): GatewayRequestHandlerOptions["client"] {
return {
connId: params.connId ?? (params.deviceId ? `conn-${params.deviceId}` : "conn-no-device"),
connect: {
client: { id: "approval-test", displayName: "Approval Test" },
scopes: params.scopes ?? ["operator.approvals"],
...(params.deviceId ? { device: { id: params.deviceId } } : {}),
},
...(params.internal ? { internal: { approvalRuntime: true } } : {}),
} as unknown as GatewayRequestHandlerOptions["client"];
}
function createContext(
controlUiBasePath?: string,
approvalWebPushDelivery?: GatewayRequestHandlerOptions["context"]["approvalWebPushDelivery"],
) {
return {
broadcast: vi.fn(),
broadcastToConnIds: vi.fn(),
approvalEvents: {
publishRequested: vi.fn(() => 0),
publishResolved: vi.fn(),
},
getApprovalClientConnIds: vi.fn(() => new Set(["approval-client"])),
getRuntimeConfig: () => ({ gateway: { controlUi: { basePath: controlUiBasePath } } }),
approvalWebPushDelivery,
logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
} as unknown as GatewayRequestHandlerOptions["context"];
}
async function invoke(params: {
handlers: ReturnType<typeof createApprovalHandlers>;
method: "approval.get" | "approval.history" | "approval.resolve";
body: Record<string, unknown>;
client: GatewayRequestHandlerOptions["client"];
context?: GatewayRequestHandlerOptions["context"];
}) {
const respond = vi.fn();
const context = params.context ?? createContext();
await expectDefined(
params.handlers[params.method],
"params.handlers[params.method] test invariant",
)({
req: { id: "req-1", type: "req", method: params.method, params: params.body },
params: params.body,
client: params.client,
context,
isWebchatConnect: () => false,
respond,
});
const response = respond.mock.calls[0];
if (!response) {
throw new Error("approval handler did not respond");
}
return { ok: response[0], result: response[1], error: response[2], context };
}
function approvalFromResult(result: unknown) {
if (!result || typeof result !== "object" || !("approval" in result)) {
throw new Error("missing approval response");
}
return (result as { approval: Record<string, unknown> }).approval;
}
describe("unified approval handlers", () => {
afterEach(() => {
vi.restoreAllMocks();
for (const manager of managersForCleanup.splice(0)) {
for (const record of manager.listPendingRecords()) {
manager.expire(record.id, "test-cleanup");
}
}
closeOpenClawAgentDatabasesForTest();
for (const dir of tempDirs.splice(0)) {
closeOpenClawStateDatabaseByPath(resolveOpenClawStateSqlitePath({ OPENCLAW_STATE_DIR: dir }));
closeOpenClawStateDatabaseByPath(path.join(dir, "state.sqlite"));
fs.rmSync(dir, { force: true, recursive: true });
}
});
it("resolves a system-agent proposal only through unified operator approval", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerSystemAgent(managers.systemAgent, "system-agent:proposal-1");
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
systemAgentApprovalManager: managers.systemAgent,
databaseOptions,
});
const context = createContext();
const response = await invoke({
handlers,
method: "approval.resolve",
body: { id: pending.record.id, kind: "system-agent", decision: "allow-once" },
client: createClient({ deviceId: "reviewer" }),
context,
});
expect(response.result).toMatchObject({
applied: true,
approval: {
status: "allowed",
decision: "allow-once",
presentation: {
kind: "system-agent",
proposalHash: "a".repeat(64),
allowedDecisions: ["allow-once", "deny"],
},
},
});
await expect(pending.decision).resolves.toBe("allow-once");
expect(context.getApprovalClientConnIds).toHaveBeenCalledWith(
expect.objectContaining({ approvalKind: "system-agent" }),
);
});
it("resolves a system-agent proposal through its channel reviewer custody", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerSystemAgent(managers.systemAgent, "system-agent:channel-reviewer");
prepareApprovalChannelCustodyMock.mockImplementation(
({ approvalKind }: { approvalKind: string }) =>
approvalKind === "system-agent"
? {
resolverId: "telegram:ops",
authorizes: (record: { request: SystemAgentApprovalRequestPayload }) =>
record.request.sessionId === "delegation-1",
}
: null,
);
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
systemAgentApprovalManager: managers.systemAgent,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.resolve",
body: {
id: pending.record.id,
kind: "system-agent",
decision: "allow-once",
reviewer: { channel: "telegram", accountId: "ops", senderId: "owner" },
},
client: createClient({ internal: true }),
});
expect(response.result).toMatchObject({
applied: true,
approval: { status: "allowed", decision: "allow-once" },
});
await expect(pending.decision).resolves.toBe("allow-once");
});
it("checks live channel custody before the canonical resolution CAS", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, {
id: "channel-custody-cas",
request: { turnSourceChannel: "telegram", turnSourceAccountId: "ops" },
reviewerDeviceIds: [],
});
prepareApprovalChannelCustodyMock.mockReturnValue({
resolverId: "telegram:ops",
authorizes: (request: { request: ExecApprovalRequestPayload }) =>
request.request.turnSourceAccountId === "ops",
});
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.resolve",
body: {
id: pending.record.id,
kind: "exec",
decision: "deny",
reviewer: { channel: "telegram", accountId: "ops", senderId: "owner" },
},
client: createClient({ internal: true }),
});
expect(response.result).toMatchObject({
applied: true,
approval: { status: "denied", decision: "deny" },
});
expect(getOperatorApproval({ id: pending.record.id, databaseOptions })?.resolver).toEqual({
kind: "channel",
id: "telegram:ops",
});
});
it("returns mapped terminal history with attribution and a next cursor", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const first = registerExec(managers.exec, { id: "history:first" });
const second = registerPlugin(managers.plugin, { id: "history:second" });
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
for (const [id, kind] of [
[first.record.id, "exec"],
[second.record.id, "plugin"],
] as const) {
const response = await invoke({
handlers,
method: "approval.resolve",
body: { id, kind, decision: "deny" },
client: createClient({ deviceId: "reviewer" }),
});
expect(response.ok).toBe(true);
}
const firstPage = await invoke({
handlers,
method: "approval.history",
body: { limit: 1 },
client: createClient({ deviceId: "reviewer" }),
context: createContext("/operator/"),
});
expect(firstPage.ok).toBe(true);
expect(validateApprovalHistoryResult(firstPage.result)).toBe(true);
const firstResult = firstPage.result as ApprovalHistoryResult;
expect(firstResult.items).toHaveLength(1);
expect(firstResult.items[0]).toMatchObject({
status: "denied",
decision: "deny",
source: { agentId: "main", sessionKey: "agent:main:child" },
resolver: { kind: "device", id: "reviewer" },
});
expect(firstResult.nextCursor).toEqual(expect.any(String));
const secondPage = await invoke({
handlers,
method: "approval.history",
body: { cursor: firstResult.nextCursor, limit: 1 },
client: createClient({ deviceId: "reviewer" }),
});
expect(secondPage.ok).toBe(true);
expect((secondPage.result as ApprovalHistoryResult).items).toHaveLength(1);
expect((secondPage.result as ApprovalHistoryResult).nextCursor).toBeUndefined();
});
it("hides foreign pending and terminal approvals from roles without foreign-session access", async () => {
const databaseOptions = createDatabaseOptions();
const stateDir = databaseOptions.env?.OPENCLAW_STATE_DIR;
if (!stateDir) {
throw new Error("expected isolated approval state directory");
}
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const profile = ensureProfileForEmail("approval-guest@example.test", databaseOptions);
setUserProfileRole(profile.id, "guest", databaseOptions);
const ownerKey = "agent:main:approval-owned";
const foreignKey = "agent:main:approval-foreign";
for (const [sessionKey, creatorId] of [
[ownerKey, profile.id],
[foreignKey, "foreign-owner"],
] as const) {
await upsertSessionEntryCore(
{ agentId: "main", sessionKey },
{
sessionId: `session-${sessionKey}`,
updatedAt: 1,
createdActor: { type: "human", source: "profile", id: creatorId },
},
);
}
const managers = createManagers(databaseOptions);
const own = registerExec(managers.exec, {
id: "approval:owned",
request: { sessionKey: ownerKey },
});
const foreign = registerExec(managers.exec, {
id: "approval:foreign",
request: { sessionKey: foreignKey },
});
const cfg: OpenClawConfig = {
gateway: {
roles: {
default: "guest",
definitions: {
guest: {
sessions: { others: "none" },
agents: "*",
scopes: ["operator.approvals"],
},
},
},
},
};
const context = {
...createContext(),
getRuntimeConfig: () => cfg,
} as GatewayRequestHandlerOptions["context"];
const roleClient = {
...createClient({ deviceId: "reviewer" }),
authenticatedUserProfile: {
profileId: profile.id,
displayName: null,
hasAvatar: false,
updatedAt: 1,
},
} as GatewayRequestHandlerOptions["client"];
const ownerClient = {
...createClient({ deviceId: "reviewer" }),
internal: { operatorRoleActor: { kind: "system" } },
} as GatewayRequestHandlerOptions["client"];
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const hidden = await invoke({
handlers,
method: "approval.get",
body: { id: foreign.record.id },
client: roleClient,
context,
});
expect(hidden).toMatchObject({
ok: false,
error: { details: { reason: "APPROVAL_NOT_FOUND" } },
});
expect(
await invoke({
handlers,
method: "approval.get",
body: { id: own.record.id },
client: roleClient,
context,
}),
).toMatchObject({ ok: true, result: { approval: { id: own.record.id } } });
expect(
await invoke({
handlers,
method: "approval.get",
body: { id: foreign.record.id },
client: createClient({ deviceId: "reviewer" }),
context,
}),
).toMatchObject({
ok: false,
error: { details: { reason: "APPROVAL_NOT_FOUND" } },
});
expect(
await invoke({
handlers,
method: "approval.get",
body: { id: foreign.record.id },
client: ownerClient,
context,
}),
).toMatchObject({ ok: true, result: { approval: { id: foreign.record.id } } });
for (const id of [own.record.id, foreign.record.id]) {
expect(
await invoke({
handlers,
method: "approval.resolve",
body: { id, kind: "exec", decision: "deny" },
client: ownerClient,
context,
}),
).toMatchObject({ ok: true });
}
const history = await invoke({
handlers,
method: "approval.history",
body: {},
client: roleClient,
context,
});
expect(history).toMatchObject({
ok: true,
result: { items: [{ id: own.record.id, source: { sessionKey: ownerKey } }] },
});
await Promise.all([own.decision, foreign.decision]);
});
});
it("returns an exact-id, deep-linkable exec projection without execution bindings", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const id = "exec:approval.with_safe-punctuation";
registerExec(managers.exec, {
id,
reviewerDeviceIds: ["reviewer-a"],
request: {
commandPreview: "printf approval-handler",
warningText: "Review carefully",
cwd: "/private/workspace",
systemRunBinding: {
argv: ["printf", "approval-handler"],
cwd: "/private/workspace",
agentId: "main",
sessionKey: "agent:main:child",
envHash: "private-env-binding",
},
},
});
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.get",
body: { id },
client: createClient({ deviceId: "reviewer-a" }),
context: createContext("/operator/"),
});
expect(response.ok).toBe(true);
expect(validateApprovalGetResult(response.result)).toBe(true);
expect(approvalFromResult(response.result)).toMatchObject({
id,
status: "pending",
urlPath: "/operator/approve/exec%3Aapproval.with_safe-punctuation",
presentation: {
kind: "exec",
commandText: "printf approval-handler",
warningText: "Review carefully",
host: "gateway",
agentId: "main",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
});
const serialized = JSON.stringify(response.result);
expect(serialized).not.toContain("/private/workspace");
expect(serialized).not.toContain("private-env-binding");
const prefix = await invoke({
handlers,
method: "approval.get",
body: { id: "exec:approval" },
client: createClient({ deviceId: "reviewer-a" }),
});
expect(prefix.ok).toBe(false);
expect(prefix.error).toMatchObject({
code: "INVALID_REQUEST",
details: { reason: "APPROVAL_NOT_FOUND" },
});
});
it("makes missing and unauthorized approval lookups indistinguishable", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
registerExec(managers.exec, { id: "authorization" });
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const unauthorized = await invoke({
handlers,
method: "approval.get",
body: { id: "authorization" },
client: createClient({ scopes: ["operator.approvals"] }),
});
const missing = await invoke({
handlers,
method: "approval.get",
body: { id: "missing" },
client: createClient({ deviceId: "reviewer" }),
});
expect(unauthorized).toMatchObject({ ok: false, error: missing.error });
const internal = await invoke({
handlers,
method: "approval.get",
body: { id: "authorization" },
client: createClient({ internal: true }),
});
expect(internal).toMatchObject({ ok: false, error: missing.error });
const underscopedInternal = await invoke({
handlers,
method: "approval.get",
body: { id: "authorization" },
client: createClient({ internal: true, scopes: ["operator.read"] }),
});
expect(underscopedInternal).toMatchObject({ ok: false, error: missing.error });
});
it("enforces explicit reviewer bindings over requester ownership", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, {
id: "reviewer-bound-unified-approval",
reviewerDeviceIds: ["reviewer-a"],
});
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
for (const deviceId of ["reviewer-b", "requester-device"]) {
for (const method of ["approval.get", "approval.resolve"] as const) {
const response = await invoke({
handlers,
method,
body:
method === "approval.get"
? { id: pending.record.id }
: { id: pending.record.id, kind: "exec", decision: "deny" },
client: createClient({ deviceId }),
});
expect(response).toMatchObject({
ok: false,
error: { code: "INVALID_REQUEST", details: { reason: "APPROVAL_NOT_FOUND" } },
});
}
}
expect(managers.exec.getLiveSnapshot(pending.record.id)).toBe(pending.record);
const winner = await invoke({
handlers,
method: "approval.resolve",
body: { id: pending.record.id, kind: "exec", decision: "deny" },
client: createClient({ deviceId: "reviewer-a" }),
});
expect(winner.result).toMatchObject({
applied: true,
approval: { status: "denied", decision: "deny" },
});
const hiddenTerminal = await invoke({
handlers,
method: "approval.get",
body: { id: pending.record.id },
client: createClient({ deviceId: "reviewer-b" }),
});
expect(hiddenTerminal).toMatchObject({
ok: false,
error: { code: "INVALID_REQUEST", details: { reason: "APPROVAL_NOT_FOUND" } },
});
});
it("lets only the server-authenticated device-less runtime resolve", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, { id: "trusted-runtime-resolve" });
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const body = { id: pending.record.id, kind: "exec", decision: "deny" };
const untrusted = await invoke({
handlers,
method: "approval.resolve",
body,
client: createClient({ scopes: ["operator.approvals"] }),
});
expect(untrusted).toMatchObject({
ok: false,
error: { code: "INVALID_REQUEST", details: { reason: "APPROVAL_NOT_FOUND" } },
});
expect(managers.exec.getLiveSnapshot(pending.record.id)).toBe(pending.record);
const underscopedInternal = await invoke({
handlers,
method: "approval.resolve",
body,
client: createClient({ internal: true, scopes: ["operator.read"] }),
});
expect(underscopedInternal).toMatchObject({
ok: false,
error: { code: "INVALID_REQUEST", details: { reason: "APPROVAL_NOT_FOUND" } },
});
expect(managers.exec.getLiveSnapshot(pending.record.id)).toBe(pending.record);
const trusted = await invoke({
handlers,
method: "approval.resolve",
body,
client: createClient({ internal: true }),
});
expect(trusted.result).toMatchObject({
applied: true,
approval: { status: "denied", decision: "deny", reason: "user" },
});
expect(getOperatorApproval({ id: pending.record.id, databaseOptions })?.resolver).toEqual({
kind: "runtime",
id: "approval-test",
});
await expect(pending.decision).resolves.toBe("deny");
});
it.for([
["approval.get", String.fromCharCode(0xd800)],
["approval.resolve", String.fromCharCode(0xd800)],
["approval.get", "."],
["approval.resolve", ".."],
] as const)("rejects unsafe approval id through %s: %s", async ([method, id], testContext) => {
const databasePath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-unsafe-approval-id-"));
tempDirs.push(databasePath);
const handlers = createApprovalHandlers({
execApprovalManager: createTestApprovalManager(testContext),
pluginApprovalManager: createTestApprovalManager<PluginApprovalRequestPayload>(testContext, {
approvalKind: "plugin",
}),
databaseOptions: { path: databasePath },
});
const response = await invoke({
handlers,
method,
body: {
id,
...(method === "approval.resolve" ? { kind: "exec", decision: "deny" } : {}),
},
client: createClient({ deviceId: "reviewer" }),
});
expect(response.ok).toBe(false);
expect(response.error).toMatchObject({ code: "INVALID_REQUEST" });
});
it.for(["approval.get", "approval.resolve"] as const)(
"returns sanitized UNAVAILABLE when %s cannot read durable state",
async (method, testContext) => {
const databasePath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-broken-db-"));
tempDirs.push(databasePath);
const context = createContext();
const handlers = createApprovalHandlers({
execApprovalManager: createTestApprovalManager(testContext),
pluginApprovalManager: createTestApprovalManager<PluginApprovalRequestPayload>(
testContext,
{
approvalKind: "plugin",
},
),
databaseOptions: { path: databasePath },
});
const response = await invoke({
handlers,
method,
body:
method === "approval.get"
? { id: "lookup" }
: { id: "lookup", kind: "exec", decision: "deny" },
client: createClient({ deviceId: "reviewer" }),
context,
});
expect(response).toMatchObject({
ok: false,
error: { code: "UNAVAILABLE", message: "approval lookup unavailable" },
});
expect(JSON.stringify(response.error)).not.toContain(databasePath);
expect(context.logGateway.error).toHaveBeenCalledTimes(1);
},
);
it("cancels only approvals owned by the aborted active run", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const aborted = registerExec(managers.exec, {
id: "aborted-run-approval",
request: { runId: "run-active", toolCallId: "tool-active" },
reviewerDeviceIds: ["later-surface"],
});
const completedRun = registerExec(managers.exec, {
id: "completed-run-approval",
request: { runId: "run-completed", toolCallId: "tool-completed" },
});
const context = createContext();
const publish = vi.fn();
expect(
cancelUnboundRunApprovals({
runId: "run-active",
manager: managers.exec,
publish,
}),
).toBe(1);
await expect(aborted.decision).resolves.toBeNull();
expect(managers.exec.getSnapshot(aborted.record.id)).toMatchObject({
status: "cancelled",
terminalReason: "run-aborted",
});
const completedRunSnapshot = managers.exec.getSnapshot(completedRun.record.id);
expect(completedRunSnapshot).toMatchObject({
request: { runId: "run-completed" },
});
expect(completedRunSnapshot?.resolvedAtMs).toBeUndefined();
expect(publish).toHaveBeenCalledWith(
expect.objectContaining({ id: aborted.record.id, decision: "deny" }),
expect.objectContaining({ id: aborted.record.id }),
);
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const replay = await invoke({
handlers,
method: "approval.resolve",
body: { id: aborted.record.id, kind: "exec", decision: "allow-once" },
client: createClient({ deviceId: "later-surface" }),
context,
});
expect(replay.result).toMatchObject({
applied: false,
approval: {
id: aborted.record.id,
status: "cancelled",
reason: "run-aborted",
},
});
});
it("cancels only approvals bound to the exact fenced worker claim", async (testContext) => {
const manager = createTestApprovalManager(testContext, {
validateAgentRuntimeDelegatedAuthority: () => true,
});
const claim = {
sessionId: "session-worker",
claimId: "claim-worker",
runId: "run-worker",
placementGeneration: 4,
owner: { kind: "worker" as const, environmentId: "environment-1", ownerEpoch: 7 },
};
const bind = (id: string, ownerEpoch: number) => {
const record = manager.create({ command: "echo ok", runId: claim.runId }, 60_000, id);
record.agentRuntimeDelegatedAuthority = {
kind: "worker",
operationalRunInstance: { instanceId: `instance-${id}`, runId: claim.runId },
lifecycleGeneration: "lifecycle-1",
claimId: `run-claim-${id}`,
turnClaim: { ...claim, owner: { ...claim.owner, ownerEpoch } },
};
const decision = manager.register(record, 60_000);
return { record, decision };
};
const fenced = bind("worker-fenced", 7);
const successor = bind("worker-successor", 8);
const publish = vi.fn();
expect(cancelWorkerTurnClaimBoundApprovals({ claim, manager, publish })).toBe(1);
await expect(fenced.decision).resolves.toBeNull();
expect(successor.record.resolvedAtMs).toBeUndefined();
expect(publish).toHaveBeenCalledOnce();
manager.forceDenyDetailed(
successor.record.id,
"run-aborted",
{ kind: "system", id: null },
"cancelled",
);
await expect(successor.decision).resolves.toBeNull();
});
it("cancels exact exec and plugin authority without touching a same-run successor", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const oldAuthority = {
kind: "local" as const,
operationalRunInstance: { instanceId: "instance-old", runId: "run-reused" },
lifecycleGeneration: "generation-1",
claimId: "claim-old",
};
const successorAuthority = {
kind: "local" as const,
operationalRunInstance: { instanceId: "instance-new", runId: "run-reused" },
lifecycleGeneration: "generation-1",
claimId: "claim-new",
};
const oldExec = registerExec(managers.exec, {
id: "old-exec-authority",
request: { runId: "run-reused" },
});
const successorExec = registerExec(managers.exec, {
id: "successor-exec-authority",
request: { runId: "run-reused" },
});
const oldPlugin = registerPlugin(managers.plugin, {
id: "old-plugin-authority",
request: { runId: "run-reused" },
});
const successorPlugin = registerPlugin(managers.plugin, {
id: "successor-plugin-authority",
request: { runId: "run-reused" },
});
oldExec.record.agentRuntimeDelegatedAuthority = oldAuthority;
oldPlugin.record.agentRuntimeDelegatedAuthority = oldAuthority;
successorExec.record.agentRuntimeDelegatedAuthority = successorAuthority;
successorPlugin.record.agentRuntimeDelegatedAuthority = successorAuthority;
expect(
cancelAgentRuntimeBoundApprovals({
authority: oldAuthority,
reason: "permission-change",
manager: managers.exec,
publish: () => {},
}),
).toBe(1);
expect(
cancelAgentRuntimeBoundApprovals({
authority: oldAuthority,
reason: "permission-change",
manager: managers.plugin,
publish: () => {},
}),
).toBe(1);
await expect(oldExec.decision).resolves.toBeNull();
await expect(oldPlugin.decision).resolves.toBeNull();
expect(oldExec.record.resolvedBy).toBe("permission-change");
expect(oldPlugin.record.resolvedBy).toBe("permission-change");
expect(managers.exec.getSnapshot(successorExec.record.id)?.resolvedAtMs).toBeUndefined();
expect(managers.plugin.getSnapshot(successorPlugin.record.id)?.resolvedAtMs).toBeUndefined();
managers.exec.resolve(successorExec.record.id, "deny");
managers.plugin.resolve(successorPlugin.record.id, "deny");
await successorExec.decision;
await successorPlugin.decision;
});
it("keeps JSON Schema-sized astral plugin text visible", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const title = String.fromCodePoint(0x1f680).repeat(80);
const description = String.fromCodePoint(0x1f6e1).repeat(512);
const pending = registerPlugin(managers.plugin, {
id: "plugin:unicode-boundaries",
request: { title, description },
});
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.get",
body: { id: pending.record.id },
client: createClient({ deviceId: "reviewer" }),
});
expect(response.ok).toBe(true);
expect(validateApprovalGetResult(response.result)).toBe(true);
expect(approvalFromResult(response.result)).toMatchObject({
presentation: { kind: "plugin", title, description },
});
});
it("resolves a durable deny without reconstructing a raw legacy request", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const id = "durable-deny-without-live-request";
const nowMs = Date.now();
insertOperatorApproval({
approval: {
id,
kind: "exec",
presentation: {
kind: "exec",
commandText: "durable safe preview",
allowedDecisions: ["allow-once", "deny"],
},
runtimeEpoch: "approval-handler-test",
createdAtMs: nowMs,
expiresAtMs: nowMs + 60_000,
},
databaseOptions,
});
const context = createContext();
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.resolve",
body: { id, kind: "exec", decision: "deny" },
client: createClient({ deviceId: "reviewer" }),
context,
});
expect(response.ok).toBe(true);
expect(response.result).toMatchObject({
applied: true,
approval: {
status: "denied",
decision: "deny",
presentation: { kind: "exec", commandText: "durable safe preview" },
},
});
expect(validateApprovalResolveResult(response.result)).toBe(true);
expect(context.broadcast).not.toHaveBeenCalled();
expect(context.broadcastToConnIds).not.toHaveBeenCalled();
});
it("expires durable state on a forward-clock lookup and settles the live waiter", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, {
id: "forward-clock-expiry",
expiresAtMs: 2_000,
});
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
now.mockReturnValue(2_000);
const response = await invoke({
handlers,
method: "approval.get",
body: { id: pending.record.id },
client: createClient({ deviceId: "reviewer" }),
});
expect(response.result).toMatchObject({
approval: { status: "expired", reason: "timeout", resolvedAtMs: 2_000 },
});
expect(validateApprovalGetResult(response.result)).toBe(true);
await expect(pending.decision).resolves.toBeNull();
});
it.each([
["missing", deleteDurableApproval],
["corrupt", corruptDurableApprovalPresentation],
] as const)("fails a live waiter closed when durable state is %s", async (_label, mutate) => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, { id: `durable-${_label}` });
mutate(databaseOptions, pending.record.id);
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.get",
body: { id: pending.record.id },
client: createClient({ deviceId: "reviewer" }),
});
expect(response).toMatchObject({
ok: false,
error: { code: "INVALID_REQUEST", details: { reason: "APPROVAL_NOT_FOUND" } },
});
await expect(pending.decision).resolves.toBe("deny");
expect(managers.exec.getLiveSnapshot(pending.record.id)).toMatchObject({
status: "denied",
terminalReason: "storage-corrupt",
});
});
it("settles the canonical live waiter when a transport-ref lookup finds corrupt state", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, { id: "corrupt-through-transport-ref" });
const durable = getOperatorApproval({ id: pending.record.id, databaseOptions });
if (!durable) {
throw new Error("expected durable approval");
}
corruptDurableApprovalPresentation(databaseOptions, pending.record.id);
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.resolve",
body: { id: durable.resolutionRef, kind: "exec", decision: "allow-once" },
client: createClient({ deviceId: "telegram" }),
});
expect(response).toMatchObject({
ok: false,
error: { code: "INVALID_REQUEST", details: { reason: "APPROVAL_NOT_FOUND" } },
});
await expect(pending.decision).resolves.toBe("deny");
expect(managers.exec.getLiveSnapshot(pending.record.id)).toMatchObject({
status: "denied",
terminalReason: "storage-corrupt",
});
});
it("repairs durable pending state after a transient local storage failure", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-reconcile-"));
tempDirs.push(stateDir);
const databasePath = path.join(stateDir, "state.sqlite");
const backupPath = path.join(stateDir, "state.backup.sqlite");
const databaseOptions = { path: databasePath } satisfies OpenClawStateDatabaseOptions;
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, { id: "transient-storage-repair" });
closeOpenClawStateDatabaseForTest();
fs.renameSync(databasePath, backupPath);
fs.mkdirSync(databasePath);
expect(() =>
managers.exec.resolveDetailed(
pending.record.id,
"deny",
{ kind: "device", id: "reviewer-device" },
"Reviewer",
),
).toThrow();
await expect(pending.decision).resolves.toBe("deny");
fs.rmSync(databasePath, { recursive: true });
fs.renameSync(backupPath, databasePath);
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.get",
body: { id: pending.record.id },
client: createClient({ deviceId: "reviewer" }),
});
expect(response.result).toMatchObject({
approval: {
status: "denied",
decision: "deny",
reason: "storage-corrupt",
},
});
expect(getOperatorApproval({ id: pending.record.id, databaseOptions })).toMatchObject({
status: "denied",
terminalReason: "storage-corrupt",
});
});
it("does not mutate a live waiter for an unauthorized durable lookup", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, { id: "unauthorized-missing-durable-row" });
deleteDurableApproval(databaseOptions, pending.record.id);
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.get",
body: { id: pending.record.id },
client: createClient({ deviceId: "unrelated-device", scopes: ["operator.approvals"] }),
});
expect(response.ok).toBe(false);
expect(managers.exec.getLiveSnapshot(pending.record.id)?.resolvedAtMs).toBeUndefined();
});
it("resolves plugin approvals through the durable CAS and publishes once", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerPlugin(managers.plugin, {
id: "plugin:deny-is-always-valid",
request: { allowedDecisions: ["allow-once"] },
reviewerDeviceIds: ["phone-device"],
});
const handleWebPushResolved = vi.fn(async () => {});
const context = createContext(undefined, {
handleRequested: vi.fn(() => false),
handleResolved: handleWebPushResolved,
handleExpired: vi.fn(async () => {}),
});
const handlePluginApprovalResolved = vi.fn(async () => {});
const handlePluginIosPushResolved = vi.fn(async () => {});
const forwarder = {
handleRequested: vi.fn(async () => false),
handleResolved: vi.fn(async () => {}),
handlePluginApprovalRequested: vi.fn(async () => false),
handlePluginApprovalResolved,
stop: vi.fn(),
} satisfies ExecApprovalForwarder;
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
forwarder,
pluginIosPushDelivery: { handleResolved: handlePluginIosPushResolved },
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.resolve",
body: { id: pending.record.id, kind: "plugin", decision: "deny" },
client: createClient({ deviceId: "phone-device" }),
context,
});
expect(response.ok).toBe(true);
expect(validateApprovalResolveResult(response.result)).toBe(true);
expect(response.result).toMatchObject({
applied: true,
approval: {
status: "denied",
decision: "deny",
reason: "user",
presentation: {
kind: "plugin",
title: "Plugin permission",
allowedDecisions: ["allow-once", "deny"],
},
},
});
await expect(pending.decision).resolves.toBe("deny");
expect(context.broadcastToConnIds).toHaveBeenCalledTimes(1);
expect(context.broadcastToConnIds).toHaveBeenCalledWith(
"plugin.approval.resolved",
expect.objectContaining({
id: pending.record.id,
decision: "deny",
resolvedBy: "Approval Test",
}),
new Set(["approval-client"]),
{ dropIfSlow: true },
);
expect(context.approvalEvents!.publishResolved).toHaveBeenCalledWith(
"plugin",
expect.objectContaining({
id: pending.record.id,
decision: "deny",
resolvedBy: "Approval Test",
}),
);
expect(getOperatorApproval({ id: pending.record.id, databaseOptions })?.resolver).toEqual({
kind: "device",
id: "phone-device",
});
expect(handlePluginApprovalResolved).toHaveBeenCalledTimes(1);
expect(handlePluginIosPushResolved).toHaveBeenCalledTimes(1);
expect(handlePluginIosPushResolved).toHaveBeenCalledWith(
expect.objectContaining({ id: pending.record.id, decision: "deny" }),
);
expect(handleWebPushResolved).toHaveBeenCalledWith(
expect.objectContaining({ id: pending.record.id, decision: "deny" }),
);
const recipientLookup = context.getApprovalClientConnIds as ReturnType<typeof vi.fn>;
const recipientOptions = recipientLookup.mock.calls[0]?.[0] as
| {
approvalKind?: string;
filter?: (
client: GatewayRequestHandlerOptions["client"],
record?: { id: string },
) => boolean;
record?: { id: string };
}
| undefined;
expect(recipientOptions?.approvalKind).toBe("plugin");
expect(
recipientOptions?.filter?.(createClient({ deviceId: "unrelated" }), recipientOptions.record),
).toBe(false);
expect(
recipientOptions?.filter?.(
createClient({ deviceId: "requester-device" }),
recipientOptions.record,
),
).toBe(true);
const terminal = await invoke({
handlers,
method: "approval.get",
body: { id: pending.record.id },
client: createClient({ scopes: ["operator.admin"] }),
});
expect(validateApprovalGetResult(terminal.result)).toBe(true);
expect(approvalFromResult(terminal.result)).toMatchObject({
status: "denied",
decision: "deny",
});
});
it("returns durable exec truth and continues follow-ups after publication failures", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, { id: "exec-publication-failures" });
const context = createContext();
const broadcastToConnIds = context.broadcastToConnIds as ReturnType<typeof vi.fn>;
broadcastToConnIds.mockImplementation(() => {
throw new Error("broadcast unavailable");
});
const handleResolved = vi.fn(async () => {
throw new Error("forwarder unavailable");
});
const handleIosResolved = vi.fn(async () => {});
const forwarder = {
handleRequested: vi.fn(async () => false),
handleResolved,
handlePluginApprovalRequested: vi.fn(async () => false),
handlePluginApprovalResolved: vi.fn(async () => {}),
stop: vi.fn(),
} satisfies ExecApprovalForwarder;
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
forwarder,
iosPushDelivery: { handleResolved: handleIosResolved },
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.resolve",
body: { id: pending.record.id, kind: "exec", decision: "deny" },
client: createClient({ deviceId: "reviewer" }),
context,
});
expect(response.result).toMatchObject({
applied: true,
approval: { status: "denied", decision: "deny", reason: "user" },
});
expect(validateApprovalResolveResult(response.result)).toBe(true);
await expect(pending.decision).resolves.toBe("deny");
expect(getOperatorApproval({ id: pending.record.id, databaseOptions })).toMatchObject({
status: "denied",
decision: "deny",
});
expect(handleResolved).toHaveBeenCalledTimes(1);
expect(handleIosResolved).toHaveBeenCalledTimes(1);
expect(context.logGateway.error).toHaveBeenCalledWith(
expect.stringContaining("exec approvals: unified resolve broadcast failed"),
);
expect(context.logGateway.error).toHaveBeenCalledWith(
expect.stringContaining("exec approvals: unified resolve forwarder failed"),
);
expect(context.approvalEvents!.publishResolved).toHaveBeenCalledWith(
"exec",
expect.objectContaining({ id: pending.record.id, decision: "deny" }),
);
});
it("responds with committed truth before a slow resolution forwarder finishes", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, { id: "exec-slow-resolution-forwarder" });
const { promise: forwarderPending, resolve: releaseForwarder } = createDeferred();
const handleResolved = vi.fn(() => forwarderPending);
const forwarder = {
handleRequested: vi.fn(async () => false),
handleResolved,
handlePluginApprovalRequested: vi.fn(async () => false),
handlePluginApprovalResolved: vi.fn(async () => {}),
stop: vi.fn(),
} satisfies ExecApprovalForwarder;
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
forwarder,
databaseOptions,
});
const respond = vi.fn();
const handler = expectDefined(
handlers["approval.resolve"],
'handlers["approval.resolve"] test invariant',
)({
req: {
id: "req-slow-forwarder",
type: "req",
method: "approval.resolve",
params: { id: pending.record.id, kind: "exec", decision: "deny" },
},
params: { id: pending.record.id, kind: "exec", decision: "deny" },
client: createClient({ deviceId: "reviewer" }),
context: createContext(),
isWebchatConnect: () => false,
respond,
});
let handlerFinished = false;
const handlerCompletion = Promise.resolve(handler).then(() => {
handlerFinished = true;
});
try {
await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1), { timeout: 500 });
expect(respond.mock.calls[0]?.[1]).toMatchObject({
applied: true,
approval: { status: "denied", decision: "deny" },
});
await vi.waitFor(() => expect(handleResolved).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(handlerFinished).toBe(true), { timeout: 500 });
} finally {
releaseForwarder();
}
await handlerCompletion;
});
it("continues plugin forwarding when the resolved-event broadcast fails", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerPlugin(managers.plugin, { id: "plugin-publication-failure" });
const context = createContext();
const broadcastToConnIds = context.broadcastToConnIds as ReturnType<typeof vi.fn>;
broadcastToConnIds.mockImplementation(() => {
throw new Error("broadcast unavailable");
});
const handlePluginApprovalResolved = vi.fn(async () => {});
const forwarder = {
handleRequested: vi.fn(async () => false),
handleResolved: vi.fn(async () => {}),
handlePluginApprovalRequested: vi.fn(async () => false),
handlePluginApprovalResolved,
stop: vi.fn(),
} satisfies ExecApprovalForwarder;
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
forwarder,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.resolve",
body: { id: pending.record.id, kind: "plugin", decision: "deny" },
client: createClient({ deviceId: "reviewer" }),
context,
});
expect(response.result).toMatchObject({
applied: true,
approval: { status: "denied", decision: "deny" },
});
expect(handlePluginApprovalResolved).toHaveBeenCalledTimes(1);
expect(context.logGateway.error).toHaveBeenCalledWith(
expect.stringContaining("plugin approvals: unified resolve broadcast failed"),
);
});
it("uses the live requester connection when filtering legacy resolved events", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, {
id: "device-less-requester",
reviewerDeviceIds: ["reviewer-device"],
requester: {
connId: "requester-connection",
deviceId: null,
clientId: "requester-client",
},
});
const context = createContext();
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.resolve",
body: { id: pending.record.id, kind: "exec", decision: "deny" },
client: createClient({ deviceId: "reviewer-device" }),
context,
});
expect(response.result).toMatchObject({ applied: true, approval: { status: "denied" } });
const recipientLookup = context.getApprovalClientConnIds as ReturnType<typeof vi.fn>;
const recipientOptions = recipientLookup.mock.calls[0]?.[0] as
| {
approvalKind?: string;
filter?: (
client: GatewayRequestHandlerOptions["client"],
record?: { requestedByConnId?: string | null },
) => boolean;
record?: { requestedByConnId?: string | null };
}
| undefined;
expect(recipientOptions?.approvalKind).toBe("exec");
expect(recipientOptions?.record?.requestedByConnId).toBe("requester-connection");
expect(
recipientOptions?.filter?.(
createClient({ connId: "requester-connection" }),
recipientOptions.record,
),
).toBe(true);
expect(
recipientOptions?.filter?.(
createClient({ connId: "unrelated-connection" }),
recipientOptions.record,
),
).toBe(false);
expect(context.broadcastToConnIds).toHaveBeenCalledTimes(1);
});
it("returns the recorded winner to a competing surface without rebroadcasting", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, {
id: "first-answer-wins",
reviewerDeviceIds: ["control-ui", "telegram"],
});
const context = createContext();
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const [first, second] = await Promise.all([
invoke({
handlers,
method: "approval.resolve",
body: { id: pending.record.id, kind: "exec", decision: "allow-once" },
client: createClient({ deviceId: "control-ui" }),
context,
}),
invoke({
handlers,
method: "approval.resolve",
body: { id: pending.record.id, kind: "exec", decision: "deny" },
client: createClient({ deviceId: "telegram" }),
context,
}),
]);
expect([first.result, second.result]).toEqual([
expect.objectContaining({
applied: true,
approval: expect.objectContaining({ status: "allowed", decision: "allow-once" }),
}),
expect.objectContaining({
applied: false,
approval: expect.objectContaining({ status: "allowed", decision: "allow-once" }),
}),
]);
expect(validateApprovalResolveResult(first.result)).toBe(true);
expect(validateApprovalResolveResult(second.result)).toBe(true);
await expect(pending.decision).resolves.toBe("allow-once");
expect(context.broadcastToConnIds).toHaveBeenCalledTimes(1);
});
it("resolves a maximum-length canonical id through its fixed-size transport reference", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, {
id: `approval-${"a".repeat(119)}`,
reviewerDeviceIds: ["telegram"],
});
const durable = getOperatorApproval({ id: pending.record.id, databaseOptions });
expect(durable?.resolutionRef).toHaveLength(43);
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.resolve",
body: { id: durable?.resolutionRef, kind: "exec", decision: "deny" },
client: createClient({ deviceId: "telegram" }),
});
expect(response.result).toMatchObject({
applied: true,
approval: { id: pending.record.id, status: "denied", decision: "deny" },
});
expect(validateApprovalResolveResult(response.result)).toBe(true);
await expect(pending.decision).resolves.toBe("deny");
});
it("resolves through the durable transport ref exactly like the canonical id", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
// No explicit reviewer binding: any authorized reviewer device may resolve,
// and the opaque transport ref must behave exactly like the canonical id.
const pending = registerExec(managers.exec, {
id: "ref-parity-approval",
requester: { connId: "conn-owner", deviceId: null, clientId: null },
reviewerDeviceIds: [],
});
const durable = getOperatorApproval({ id: pending.record.id, databaseOptions });
expect(durable?.resolutionRef).toHaveLength(43);
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const winner = await invoke({
handlers,
method: "approval.resolve",
body: { id: durable?.resolutionRef, kind: "exec", decision: "deny" },
client: createClient({ deviceId: "reviewer-surface" }),
});
expect(winner.result).toMatchObject({
applied: true,
approval: { id: pending.record.id, status: "denied", decision: "deny" },
});
await expect(pending.decision).resolves.toBe("deny");
const replayByCanonicalId = await invoke({
handlers,
method: "approval.resolve",
body: { id: pending.record.id, kind: "exec", decision: "deny" },
client: createClient({ deviceId: "another-surface" }),
});
expect(replayByCanonicalId.result).toMatchObject({
applied: false,
approval: { id: pending.record.id, status: "denied", decision: "deny" },
});
});
it.each([
{ status: "allowed", decision: "allow-once", terminalDecision: "allow-once" },
{ status: "denied", decision: "deny", terminalDecision: "deny" },
{ status: "expired", decision: "allow-once", terminalDecision: null },
{ status: "cancelled", decision: "allow-once", terminalDecision: null },
] as const)(
"returns retained $status truth after the process-local waiter is gone",
async ({ status, decision, terminalDecision }) => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, {
id: `terminal-after-restart-${status}`,
reviewerDeviceIds: ["later-surface"],
});
if (status === "allowed" || status === "denied") {
managers.exec.resolveDetailed(pending.record.id, terminalDecision, {
kind: "device",
id: "first-surface",
});
} else {
managers.exec.forceDenyDetailed(
pending.record.id,
status === "expired" ? "timeout" : "run-aborted",
{ kind: "system", id: null },
status,
null,
);
}
await expect(pending.decision).resolves.toBe(terminalDecision);
const restartedManagers = createManagers(databaseOptions);
const context = createContext();
const handlers = createApprovalHandlers({
execApprovalManager: restartedManagers.exec,
pluginApprovalManager: restartedManagers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.resolve",
body: { id: pending.record.id, kind: "exec", decision },
client: createClient({ deviceId: "later-surface" }),
context,
});
expect(response.result).toMatchObject({
applied: false,
approval: {
status,
},
});
expect(validateApprovalResolveResult(response.result)).toBe(true);
expect(context.broadcast).not.toHaveBeenCalled();
expect(context.broadcastToConnIds).not.toHaveBeenCalled();
},
);
it("atomically denies malformed, mismatched-kind, and disallowed approving verdicts", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const disallowed = registerExec(managers.exec, {
id: "disallowed-allow-always",
request: { unavailableDecisions: ["allow-always"] },
});
const malformed = registerPlugin(managers.plugin, {
id: "plugin:malformed",
request: { allowedDecisions: ["allow-once"] },
});
const mismatchedKind = registerPlugin(managers.plugin, {
id: "opaque-plugin-id",
request: { allowedDecisions: ["allow-once"] },
});
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const client = createClient({ deviceId: "reviewer" });
const disallowedResponse = await invoke({
handlers,
method: "approval.resolve",
body: { id: disallowed.record.id, kind: "exec", decision: "allow-always" },
client,
});
expect(disallowedResponse.result).toMatchObject({
applied: true,
approval: { status: "denied", decision: "deny", reason: "malformed-verdict" },
});
await expect(disallowed.decision).resolves.toBe("deny");
const malformedResponse = await invoke({
handlers,
method: "approval.resolve",
body: { id: malformed.record.id, kind: "plugin", decision: "ACCEPT" },
client,
});
expect(malformedResponse.result).toMatchObject({
applied: true,
approval: { status: "denied", decision: "deny", reason: "malformed-verdict" },
});
await expect(malformed.decision).resolves.toBe("deny");
const mismatchedKindResponse = await invoke({
handlers,
method: "approval.resolve",
body: { id: mismatchedKind.record.id, kind: "exec", decision: "allow-once" },
client,
});
expect(mismatchedKindResponse.result).toMatchObject({
applied: true,
approval: {
status: "denied",
decision: "deny",
reason: "malformed-verdict",
presentation: { kind: "plugin" },
},
});
await expect(mismatchedKind.decision).resolves.toBe("deny");
});
it("lets the exact deadline beat a malformed verdict", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, {
id: "malformed-at-deadline",
expiresAtMs: 2_000,
});
const context = createContext();
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
// The lookup observes pending immediately before the deadline; force-deny's
// store transaction reaches the exact deadline and must reconcile expiry.
now.mockReturnValueOnce(1_999).mockReturnValue(2_000);
const response = await invoke({
handlers,
method: "approval.resolve",
body: { id: pending.record.id, kind: "exec", decision: "ACCEPT" },
client: createClient({ deviceId: "reviewer" }),
context,
});
expect(response.result).toMatchObject({
applied: false,
approval: {
status: "expired",
reason: "timeout",
resolvedAtMs: 2_000,
},
});
expect(validateApprovalResolveResult(response.result)).toBe(true);
await expect(pending.decision).resolves.toBeNull();
expect(context.broadcastToConnIds).not.toHaveBeenCalled();
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */