mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
7a456e362d
* fix(gateway): approval registry hardening and protocol-surface follow-ups Follow-up delta to the merged #103579 head, rebased onto current main: - gateway-protocol wire types derive from owner-module schema consts (types.ts tombstone) and ProtocolSchemas leaves the package index so the public plugin-sdk d.ts graph tree-shakes the registry declaration - approval access authority follows the operator.approvals scope tier with reviewerDeviceIds as the opt-in restriction (cross-surface first-answer-wins; requester identity gates only legacy adapters) - plugin node.invoke approvals register directly so unrenderable presentations fail closed before request routing - exec-approval manager reconciliation with #103515 revocation hardening (resolution source attribution, one-shot ask-fallback consumption) - surface-report pins and plugin-sdk API baseline refreshed; Swift models regenerated * feat(channels): add typed operator approval actions Squash-rebased #103679 segment onto the durable-approval-registry tip on current main. Typed approval/command/select presentation actions replace raw-string inference across slack/telegram/discord/matrix/imessage/whatsapp, approval.resolve carries an explicit kind, and channel adapters map native callback envelopes through the typed action registry. Drift reconciliation: deprecated buildExecApprovalInteractiveReply assertions dropped (#104650 removed the shims); worker_environments bootstrap-column migration kept alongside the approval resolution_ref backfill; plugin-sdk API baseline regenerated. (cherry picked from commit 68765a5d39d2118c88a7a54d00387337912d4494) (cherry picked from commit 8642ac12af142e4b751f4f30d4b114615e7e5f66) (cherry picked from commit 036c4bc39499925fc03de16ec9302e346769350a) (cherry picked from commit 19dc350d6bc34e29a5169c6bc80971b0ad12adde) (cherry picked from commit fc978b0bad86aef421c79f6a211b25cc1b743c01) (cherry picked from commit 10de4d1ed5071f9be6ad1ee5d1e32c0fa8c9d11c) (cherry picked from commit 9a664ced1b1fa740172b258f355f1a82925ae41c) (cherry picked from commit c5ff69abbf444139e9e007bfa45beb0f00ffea54) (cherry picked from commit d466a80795f7bc04639f1538f4e412bca3ab96bf) (cherry picked from commit f5b4fe40dd5c961322f8553cc80b2fdfb3f6503e) (cherry picked from commit 7340b4749a4cc4c72f7a41cce1bc9cb550cae038) (cherry picked from commit a151f41808f23ae60b10305ccd2bc959b9169a86) * fix(approvals): preserve typed transport ownership * test(imessage): narrow chunked approval text * refactor(protocol): remove retired type tombstone * fix(plugin-sdk): align surface budgets after rebase * docs(changelog): note typed operator approvals * docs(changelog): defer typed approval release note
553 lines
15 KiB
TypeScript
553 lines
15 KiB
TypeScript
// Signal tests cover approval native plugin behavior.
|
|
import type {
|
|
ExecApprovalRequest,
|
|
PluginApprovalRequest,
|
|
} from "openclaw/plugin-sdk/approval-runtime";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
shouldSuppressLocalSignalExecApprovalPrompt,
|
|
signalApprovalCapability,
|
|
} from "./approval-native.js";
|
|
|
|
type SignalConfig = NonNullable<NonNullable<OpenClawConfig["channels"]>["signal"]>;
|
|
|
|
function buildConfig(
|
|
params: {
|
|
signal?: Partial<SignalConfig>;
|
|
approvals?: OpenClawConfig["approvals"];
|
|
} = {},
|
|
): OpenClawConfig {
|
|
return {
|
|
channels: {
|
|
signal: {
|
|
enabled: true,
|
|
...params.signal,
|
|
},
|
|
},
|
|
approvals: params.approvals,
|
|
} as OpenClawConfig;
|
|
}
|
|
|
|
function buildExecRequest(
|
|
turnSourceTo: string,
|
|
overrides: Partial<ExecApprovalRequest["request"]> = {},
|
|
): ExecApprovalRequest {
|
|
return {
|
|
id: "exec-1",
|
|
request: {
|
|
command: "echo hi",
|
|
agentId: "main",
|
|
turnSourceChannel: "signal",
|
|
turnSourceTo,
|
|
turnSourceAccountId: "default",
|
|
sessionKey: `agent:main:signal:${turnSourceTo}`,
|
|
...overrides,
|
|
},
|
|
createdAtMs: 0,
|
|
expiresAtMs: 1000,
|
|
};
|
|
}
|
|
|
|
function buildPluginRequest(
|
|
turnSourceTo: string,
|
|
overrides: Partial<PluginApprovalRequest["request"]> = {},
|
|
): PluginApprovalRequest {
|
|
return {
|
|
id: "plugin:approval-1",
|
|
request: {
|
|
title: "Plugin approval",
|
|
description: "Allow plugin action",
|
|
agentId: "main",
|
|
turnSourceChannel: "signal",
|
|
turnSourceTo,
|
|
turnSourceAccountId: "default",
|
|
sessionKey: `agent:main:signal:${turnSourceTo}`,
|
|
...overrides,
|
|
},
|
|
createdAtMs: 0,
|
|
expiresAtMs: 1000,
|
|
};
|
|
}
|
|
|
|
function nativeShouldHandle(params: {
|
|
cfg: OpenClawConfig;
|
|
approvalKind: "exec" | "plugin";
|
|
request: ExecApprovalRequest | PluginApprovalRequest;
|
|
accountId?: string | null;
|
|
}) {
|
|
return signalApprovalCapability.nativeRuntime?.availability.shouldHandle({
|
|
cfg: params.cfg,
|
|
accountId: params.accountId ?? "default",
|
|
context: {},
|
|
approvalKind: params.approvalKind,
|
|
request: params.request,
|
|
});
|
|
}
|
|
|
|
function buildLocalApprovalPayload(
|
|
params: {
|
|
approvalKind?: "exec" | "plugin";
|
|
agentId?: string | null;
|
|
sessionKey?: string | null;
|
|
} = {},
|
|
) {
|
|
return {
|
|
text: "Approval required.",
|
|
channelData: {
|
|
execApproval: {
|
|
approvalId: params.approvalKind === "plugin" ? "plugin:approval-1" : "exec-1",
|
|
approvalSlug: params.approvalKind === "plugin" ? "plugin:approval-1" : "exec-1",
|
|
approvalKind: params.approvalKind ?? "exec",
|
|
agentId: params.agentId,
|
|
sessionKey: params.sessionKey,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("signal approval capability", () => {
|
|
it("does not enable exec or plugin native approvals from Signal readiness alone", () => {
|
|
const cfg = buildConfig();
|
|
const execRequest = buildExecRequest("+15551230000");
|
|
const pluginRequest = buildPluginRequest("+15551230000");
|
|
|
|
expect(
|
|
signalApprovalCapability?.getActionAvailabilityState?.({
|
|
cfg,
|
|
accountId: "default",
|
|
action: "approve",
|
|
approvalKind: "exec",
|
|
}),
|
|
).toEqual({ kind: "disabled" });
|
|
expect(
|
|
signalApprovalCapability?.getActionAvailabilityState?.({
|
|
cfg,
|
|
accountId: "default",
|
|
action: "approve",
|
|
approvalKind: "plugin",
|
|
}),
|
|
).toEqual({ kind: "disabled" });
|
|
expect(
|
|
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
|
cfg,
|
|
accountId: "default",
|
|
approvalKind: "exec",
|
|
request: execRequest,
|
|
}).enabled,
|
|
).toBe(false);
|
|
expect(nativeShouldHandle({ cfg, approvalKind: "exec", request: execRequest })).toBe(false);
|
|
expect(nativeShouldHandle({ cfg, approvalKind: "plugin", request: pluginRequest })).toBe(false);
|
|
});
|
|
|
|
it("allows session-mode exec delivery for matching Signal origins", () => {
|
|
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
|
|
const request = buildExecRequest("+15551230000");
|
|
|
|
expect(
|
|
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
|
cfg,
|
|
accountId: "default",
|
|
approvalKind: "exec",
|
|
request,
|
|
}),
|
|
).toEqual({
|
|
enabled: true,
|
|
preferredSurface: "origin",
|
|
supportsOriginSurface: true,
|
|
supportsApproverDmSurface: false,
|
|
notifyOriginWhenDmOnly: true,
|
|
});
|
|
expect(nativeShouldHandle({ cfg, approvalKind: "exec", request })).toBe(true);
|
|
});
|
|
|
|
it("requires explicit approvers before delivering group-origin approvals", () => {
|
|
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
|
|
const request = buildExecRequest("group:g1");
|
|
|
|
expect(
|
|
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
|
cfg,
|
|
accountId: "default",
|
|
approvalKind: "exec",
|
|
request,
|
|
}).enabled,
|
|
).toBe(false);
|
|
|
|
const withApprover = buildConfig({
|
|
signal: { allowFrom: ["+15551230000"] },
|
|
approvals: { exec: { enabled: true } },
|
|
});
|
|
expect(
|
|
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
|
cfg: withApprover,
|
|
accountId: "default",
|
|
approvalKind: "exec",
|
|
request,
|
|
}).enabled,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("keeps exec and plugin forwarding gates independent", () => {
|
|
const execOnly = buildConfig({ approvals: { exec: { enabled: true } } });
|
|
const pluginOnly = buildConfig({ approvals: { plugin: { enabled: true } } });
|
|
|
|
expect(
|
|
nativeShouldHandle({
|
|
cfg: execOnly,
|
|
approvalKind: "plugin",
|
|
request: buildPluginRequest("+15551230000"),
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
nativeShouldHandle({
|
|
cfg: pluginOnly,
|
|
approvalKind: "exec",
|
|
request: buildExecRequest("+15551230000"),
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
nativeShouldHandle({
|
|
cfg: pluginOnly,
|
|
approvalKind: "plugin",
|
|
request: buildPluginRequest("+15551230000"),
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not use session mode for non-Signal-origin requests", () => {
|
|
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
|
|
const request = buildExecRequest("", {
|
|
turnSourceChannel: "slack",
|
|
turnSourceTo: "C123",
|
|
sessionKey: "agent:main:slack:channel:c123",
|
|
});
|
|
|
|
expect(nativeShouldHandle({ cfg, approvalKind: "exec", request })).toBe(false);
|
|
expect(
|
|
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
|
cfg,
|
|
accountId: "default",
|
|
approvalKind: "exec",
|
|
request,
|
|
}).enabled,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("uses target-mode config for requestless availability without native runtime handling", () => {
|
|
const cfg = buildConfig({
|
|
signal: { allowFrom: ["+15551230000"] },
|
|
approvals: {
|
|
exec: {
|
|
enabled: true,
|
|
mode: "targets",
|
|
targets: [{ channel: "signal", to: "+15551230000" }],
|
|
},
|
|
},
|
|
});
|
|
const request = buildExecRequest("+15551230000");
|
|
|
|
expect(
|
|
signalApprovalCapability?.getActionAvailabilityState?.({
|
|
cfg,
|
|
accountId: "default",
|
|
action: "approve",
|
|
approvalKind: "exec",
|
|
}),
|
|
).toEqual({ kind: "enabled" });
|
|
expect(
|
|
signalApprovalCapability.nativeRuntime?.availability.isConfigured({
|
|
cfg,
|
|
accountId: "default",
|
|
context: {},
|
|
}),
|
|
).toBe(false);
|
|
expect(nativeShouldHandle({ cfg, approvalKind: "exec", request })).toBe(false);
|
|
expect(
|
|
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
|
cfg,
|
|
accountId: "default",
|
|
approvalKind: "exec",
|
|
request,
|
|
}).enabled,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("renders target-mode exec prompts without unbound reaction choices", () => {
|
|
const cfg = buildConfig({
|
|
signal: { allowFrom: ["+15551230000"] },
|
|
approvals: {
|
|
exec: {
|
|
enabled: true,
|
|
mode: "targets",
|
|
targets: [{ channel: "signal", to: "+15551230000" }],
|
|
},
|
|
},
|
|
});
|
|
const request = buildExecRequest("+15551230000", {
|
|
ask: "always",
|
|
cwd: "/tmp/work",
|
|
host: "gateway",
|
|
});
|
|
|
|
const payload = signalApprovalCapability.render?.exec?.buildPendingPayload?.({
|
|
cfg,
|
|
request,
|
|
target: { channel: "signal", to: "+15551230000", source: "target" },
|
|
nowMs: 0,
|
|
});
|
|
const text = payload?.text ?? "";
|
|
|
|
expect(text).toContain("/approve exec-1 allow-once");
|
|
expect(text).not.toContain("React with:");
|
|
expect(text).not.toContain("👍 Allow Once");
|
|
expect(text).not.toContain("👎 Deny");
|
|
expect(text).not.toContain("<id>");
|
|
expect(text).not.toContain("1️⃣ Allow Once");
|
|
expect(text).not.toContain("2️⃣ Allow Always");
|
|
expect(text).not.toContain("3️⃣ Deny");
|
|
});
|
|
|
|
it("does not show reaction choices when Signal has no explicit approvers", () => {
|
|
const cfg = buildConfig({
|
|
approvals: {
|
|
exec: {
|
|
enabled: true,
|
|
mode: "targets",
|
|
targets: [{ channel: "signal", to: "+15551230000" }],
|
|
},
|
|
},
|
|
});
|
|
const request = buildExecRequest("+15551230000");
|
|
|
|
const payload = signalApprovalCapability.render?.exec?.buildPendingPayload?.({
|
|
cfg,
|
|
request,
|
|
target: { channel: "signal", to: "+15551230000", source: "target" },
|
|
nowMs: 0,
|
|
});
|
|
const text = payload?.text ?? "";
|
|
|
|
expect(text).toContain("/approve exec-1 allow-once");
|
|
expect(text).not.toContain("React with:");
|
|
expect(text).not.toContain("👍 Allow Once");
|
|
expect(text).not.toContain("👎 Deny");
|
|
});
|
|
|
|
it("normalizes equivalent Signal UUID target forms without suppressing generic target delivery", () => {
|
|
const cfg = buildConfig({
|
|
approvals: {
|
|
exec: {
|
|
enabled: true,
|
|
mode: "targets",
|
|
targets: [{ channel: "signal", to: "uuid:ABCDEF12-3456-7890-ABCD-EF1234567890" }],
|
|
},
|
|
},
|
|
});
|
|
const request = buildExecRequest("abcdef12-3456-7890-abcd-ef1234567890", {
|
|
turnSourceChannel: "slack",
|
|
turnSourceTo: "C123",
|
|
sessionKey: "agent:main:slack:channel:c123",
|
|
});
|
|
|
|
expect(
|
|
signalApprovalCapability.delivery?.shouldSuppressForwardingFallback?.({
|
|
cfg,
|
|
approvalKind: "exec",
|
|
request,
|
|
target: {
|
|
channel: "signal",
|
|
to: "abcdef12-3456-7890-abcd-ef1234567890",
|
|
source: "target",
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldSuppressLocalSignalExecApprovalPrompt", () => {
|
|
const activeExecHint = {
|
|
kind: "approval-pending",
|
|
approvalKind: "exec",
|
|
nativeRouteActive: true,
|
|
} as const;
|
|
|
|
it("suppresses eligible session-mode exec approval prompts", () => {
|
|
const cfg = buildConfig({
|
|
signal: { allowFrom: ["+15551230000"] },
|
|
approvals: {
|
|
exec: {
|
|
enabled: true,
|
|
agentFilter: ["main"],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg,
|
|
accountId: "default",
|
|
payload: buildLocalApprovalPayload({
|
|
agentId: null,
|
|
sessionKey: "agent:main:signal:+15551230000",
|
|
}),
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("keeps local prompts for disabled, ambiguous, or non-exec cases", () => {
|
|
const enabledConfig = buildConfig({
|
|
signal: { allowFrom: ["+15551230000"] },
|
|
approvals: { exec: { enabled: true } },
|
|
});
|
|
const payload = buildLocalApprovalPayload({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:signal:+15551230000",
|
|
});
|
|
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg: buildConfig(),
|
|
payload,
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg: buildConfig({
|
|
signal: { allowFrom: ["+15551230000"] },
|
|
approvals: { exec: { enabled: false } },
|
|
}),
|
|
payload,
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg: buildConfig({
|
|
signal: { allowFrom: ["+15551230000"] },
|
|
approvals: {
|
|
exec: {
|
|
enabled: true,
|
|
mode: "targets",
|
|
targets: [{ channel: "signal", to: "+15551230000" }],
|
|
},
|
|
},
|
|
}),
|
|
payload,
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg: enabledConfig,
|
|
payload,
|
|
hint: { ...activeExecHint, nativeRouteActive: false },
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg: enabledConfig,
|
|
payload: buildLocalApprovalPayload({ approvalKind: "plugin" }),
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg: enabledConfig,
|
|
payload: { text: "Approval required." },
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("suppresses direct same-chat Signal prompts without explicit approvers", () => {
|
|
const cfg = buildConfig({
|
|
approvals: { exec: { enabled: true } },
|
|
});
|
|
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg,
|
|
payload: buildLocalApprovalPayload({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:signal:+15551230000",
|
|
}),
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("keeps no-approver local prompts for ambiguous or group Signal sessions", () => {
|
|
const cfg = buildConfig({
|
|
approvals: { exec: { enabled: true } },
|
|
});
|
|
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg,
|
|
payload: buildLocalApprovalPayload({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:signal:group:test-group",
|
|
}),
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg,
|
|
payload: buildLocalApprovalPayload({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:slack:C123",
|
|
}),
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("applies top-level approval filters with agent fallback from session key", () => {
|
|
const cfg = buildConfig({
|
|
signal: { allowFrom: ["+15551230000"] },
|
|
approvals: {
|
|
exec: {
|
|
enabled: true,
|
|
agentFilter: ["ops"],
|
|
sessionFilter: ["signal"],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg,
|
|
payload: buildLocalApprovalPayload({
|
|
agentId: null,
|
|
sessionKey: "agent:ops:signal:+15551230000",
|
|
}),
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg,
|
|
payload: buildLocalApprovalPayload({
|
|
agentId: null,
|
|
sessionKey: "agent:main:signal:+15551230000",
|
|
}),
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldSuppressLocalSignalExecApprovalPrompt({
|
|
cfg,
|
|
payload: buildLocalApprovalPayload({
|
|
agentId: null,
|
|
sessionKey: "agent:ops:slack:C123",
|
|
}),
|
|
hint: activeExecHint,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|