mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-15 03:01:02 +00:00
Merged via squash.
Prepared head SHA: cb5d3c249c
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
496 lines
16 KiB
TypeScript
496 lines
16 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
|
|
|
vi.mock("./exec-approval-surface.js", () => ({
|
|
describeNativeExecApprovalClientSetup: vi.fn(
|
|
(params: {
|
|
channel?: string | null;
|
|
channelLabel?: string | null;
|
|
accountId?: string | null;
|
|
}) => {
|
|
const channel = (params.channel ?? "").trim().toLowerCase();
|
|
const label = params.channelLabel ?? channel;
|
|
const accountId = params.accountId?.trim();
|
|
const accountPrefix =
|
|
accountId && accountId !== "default"
|
|
? `channels.${channel}.accounts.${accountId}`
|
|
: `channels.${channel}`;
|
|
if (channel === "matrix") {
|
|
return `Approve it from the Web UI or terminal UI for now. ${label} supports native exec approvals for this account. Configure \`${accountPrefix}.execApprovals.approvers\` or \`${accountPrefix}.dm.allowFrom\`; leave \`${accountPrefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
|
}
|
|
if (channel === "discord") {
|
|
return `Approve it from the Web UI or terminal UI for now. ${label} supports native exec approvals for this account. Configure \`${accountPrefix}.execApprovals.approvers\` or \`commands.ownerAllowFrom\`; leave \`${accountPrefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
|
}
|
|
if (channel === "slack") {
|
|
return `Approve it from the Web UI or terminal UI for now. ${label} supports native exec approvals for this account. Configure \`${accountPrefix}.execApprovals.approvers\` or \`commands.ownerAllowFrom\`; leave \`${accountPrefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
|
}
|
|
if (channel === "telegram") {
|
|
return `Approve it from the Web UI or terminal UI for now. ${label} supports native exec approvals for this account. Configure \`${accountPrefix}.execApprovals.approvers\`; if you leave it unset, OpenClaw can infer numeric owner IDs from \`${accountPrefix}.allowFrom\` or direct-message \`${accountPrefix}.defaultTo\` when possible. Leave \`${accountPrefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
listNativeExecApprovalClientLabels: vi.fn(() => ["Discord", "Matrix", "Slack", "Telegram"]),
|
|
supportsNativeExecApprovalClient: vi.fn((channel?: string | null) =>
|
|
["discord", "matrix", "slack", "telegram"].includes((channel ?? "").trim().toLowerCase()),
|
|
),
|
|
}));
|
|
|
|
import {
|
|
buildExecApprovalActionDescriptors,
|
|
buildExecApprovalCommandText,
|
|
buildExecApprovalInteractiveReply,
|
|
buildExecApprovalPendingReplyPayload,
|
|
buildExecApprovalUnavailableReplyPayload,
|
|
getExecApprovalApproverDmNoticeText,
|
|
getExecApprovalReplyMetadata,
|
|
parseExecApprovalCommandText,
|
|
} from "./exec-approval-reply.js";
|
|
|
|
describe("exec approval reply helpers", () => {
|
|
const invalidReplyMetadataCases = [
|
|
{ name: "empty object", payload: {} },
|
|
{ name: "null channelData", payload: { channelData: null } },
|
|
{ name: "array channelData", payload: { channelData: [] } },
|
|
{ name: "null execApproval", payload: { channelData: { execApproval: null } } },
|
|
{ name: "array execApproval", payload: { channelData: { execApproval: [] } } },
|
|
{
|
|
name: "blank approval slug",
|
|
payload: { channelData: { execApproval: { approvalId: "req-1", approvalSlug: " " } } },
|
|
},
|
|
{
|
|
name: "blank approval id",
|
|
payload: { channelData: { execApproval: { approvalId: " ", approvalSlug: "slug-1" } } },
|
|
},
|
|
] as const;
|
|
|
|
const unavailableReasonCases = [
|
|
{
|
|
reason: "initiating-platform-disabled" as const,
|
|
channelLabel: "Slack",
|
|
expected:
|
|
"Exec approval is required, but native chat exec approvals are not configured on Slack.",
|
|
},
|
|
{
|
|
reason: "initiating-platform-unsupported" as const,
|
|
channelLabel: undefined,
|
|
expected:
|
|
"Exec approval is required, but this platform does not support chat exec approvals.",
|
|
},
|
|
{
|
|
reason: "no-approval-route" as const,
|
|
channelLabel: undefined,
|
|
expected:
|
|
"Exec approval is required, but no interactive approval client is currently available.",
|
|
},
|
|
] as const;
|
|
|
|
it("returns the approver DM notice text", () => {
|
|
expect(getExecApprovalApproverDmNoticeText()).toBe(
|
|
"Approval required. I sent approval DMs to the approvers for this account.",
|
|
);
|
|
});
|
|
|
|
it("mentions Matrix in the fallback native approval guidance", () => {
|
|
const text = buildExecApprovalUnavailableReplyPayload({
|
|
reason: "no-approval-route",
|
|
}).text;
|
|
expect(text).toContain("native chat approval client such as");
|
|
expect(text).toContain("Discord");
|
|
expect(text).toContain("Matrix");
|
|
expect(text).toContain("Slack");
|
|
expect(text).toContain("Telegram");
|
|
});
|
|
|
|
it("avoids repeating allowFrom guidance in the no-route fallback", () => {
|
|
const text = buildExecApprovalUnavailableReplyPayload({
|
|
reason: "no-approval-route",
|
|
}).text;
|
|
|
|
expect(text).not.toContain(
|
|
"Then retry the command. If those accounts already know your owner ID via allowFrom or owner config",
|
|
);
|
|
expect(text).toContain(
|
|
"You can usually leave execApprovals.approvers unset when owner config already identifies the approvers.",
|
|
);
|
|
});
|
|
|
|
it("explains how to enable Matrix native approvals when Matrix is the initiating platform", () => {
|
|
const text = buildExecApprovalUnavailableReplyPayload({
|
|
reason: "initiating-platform-disabled",
|
|
channel: "matrix",
|
|
channelLabel: "Matrix",
|
|
}).text;
|
|
|
|
expect(text).toContain("native chat exec approvals are not configured on Matrix");
|
|
expect(text).toContain("Matrix supports native exec approvals for this account");
|
|
expect(text).toContain("`channels.matrix.execApprovals.approvers`");
|
|
expect(text).toContain("`channels.matrix.dm.allowFrom`");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
channel: "discord",
|
|
channelLabel: "Discord",
|
|
expected: "`commands.ownerAllowFrom`",
|
|
unexpected: "`channels.discord.dm.allowFrom`",
|
|
},
|
|
{
|
|
channel: "slack",
|
|
channelLabel: "Slack",
|
|
expected: "`commands.ownerAllowFrom`",
|
|
unexpected: "`channels.slack.dm.allowFrom`",
|
|
},
|
|
{
|
|
channel: "telegram",
|
|
channelLabel: "Telegram",
|
|
expected: "`channels.telegram.allowFrom`",
|
|
unexpected: "`channels.telegram.dm.allowFrom`",
|
|
},
|
|
])(
|
|
"uses channel-specific disabled setup guidance for $channelLabel",
|
|
({ channel, channelLabel, expected, unexpected }) => {
|
|
const text = buildExecApprovalUnavailableReplyPayload({
|
|
reason: "initiating-platform-disabled",
|
|
channel,
|
|
channelLabel,
|
|
}).text;
|
|
|
|
expect(text).toContain(expected);
|
|
expect(text).not.toContain(unexpected);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
channel: "discord",
|
|
channelLabel: "Discord",
|
|
accountId: "work",
|
|
expected: "`channels.discord.accounts.work.execApprovals.approvers`",
|
|
unexpected: "`channels.discord.execApprovals.approvers`",
|
|
},
|
|
{
|
|
channel: "slack",
|
|
channelLabel: "Slack",
|
|
accountId: "work",
|
|
expected: "`channels.slack.accounts.work.execApprovals.approvers`",
|
|
unexpected: "`channels.slack.execApprovals.approvers`",
|
|
},
|
|
{
|
|
channel: "telegram",
|
|
channelLabel: "Telegram",
|
|
accountId: "work",
|
|
expected: "`channels.telegram.accounts.work.allowFrom`",
|
|
unexpected: "`channels.telegram.allowFrom`",
|
|
},
|
|
{
|
|
channel: "matrix",
|
|
channelLabel: "Matrix",
|
|
accountId: "work",
|
|
expected: "`channels.matrix.accounts.work.dm.allowFrom`",
|
|
unexpected: "`channels.matrix.dm.allowFrom`",
|
|
},
|
|
])(
|
|
"uses account-scoped disabled setup guidance for $channelLabel named account",
|
|
({ channel, channelLabel, accountId, expected, unexpected }) => {
|
|
const text = buildExecApprovalUnavailableReplyPayload({
|
|
reason: "initiating-platform-disabled",
|
|
channel,
|
|
channelLabel,
|
|
accountId,
|
|
}).text;
|
|
|
|
expect(text).toContain(expected);
|
|
expect(text).not.toContain(unexpected);
|
|
},
|
|
);
|
|
|
|
it.each(invalidReplyMetadataCases)(
|
|
"returns null for invalid reply metadata payload: $name",
|
|
({ payload }) => {
|
|
expect(getExecApprovalReplyMetadata(payload as ReplyPayload)).toBeNull();
|
|
},
|
|
);
|
|
|
|
it("normalizes reply metadata and filters invalid decisions", () => {
|
|
expect(
|
|
getExecApprovalReplyMetadata({
|
|
channelData: {
|
|
execApproval: {
|
|
approvalId: " req-1 ",
|
|
approvalSlug: " slug-1 ",
|
|
agentId: " agent-1 ",
|
|
allowedDecisions: ["allow-once", "bad", "deny", "allow-always", 3],
|
|
sessionKey: " session-1 ",
|
|
},
|
|
},
|
|
}),
|
|
).toEqual({
|
|
approvalId: "req-1",
|
|
approvalSlug: "slug-1",
|
|
approvalKind: "exec",
|
|
agentId: "agent-1",
|
|
allowedDecisions: ["allow-once", "deny", "allow-always"],
|
|
sessionKey: "session-1",
|
|
});
|
|
});
|
|
|
|
it("builds pending reply payloads with trimmed warning text and slug fallback", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
warningText: " Heads up. ",
|
|
approvalId: "req-1",
|
|
approvalSlug: "slug-1",
|
|
command: "echo ok",
|
|
cwd: "/tmp/work",
|
|
host: "gateway",
|
|
nodeId: "node-1",
|
|
expiresAtMs: 2500,
|
|
nowMs: 1000,
|
|
});
|
|
|
|
expect(payload.channelData).toEqual({
|
|
execApproval: {
|
|
approvalId: "req-1",
|
|
approvalSlug: "slug-1",
|
|
approvalKind: "exec",
|
|
agentId: undefined,
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
sessionKey: undefined,
|
|
},
|
|
});
|
|
expect(payload.interactive).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Allow Once",
|
|
value: "/approve req-1 allow-once",
|
|
style: "success",
|
|
},
|
|
{
|
|
label: "Allow Always",
|
|
value: "/approve req-1 allow-always",
|
|
style: "primary",
|
|
},
|
|
{
|
|
label: "Deny",
|
|
value: "/approve req-1 deny",
|
|
style: "danger",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(payload.text).toContain("Heads up.");
|
|
expect(payload.text).toContain("```txt\n/approve slug-1 allow-once\n```");
|
|
expect(payload.text).toContain("```sh\necho ok\n```");
|
|
expect(payload.text).toContain("Host: gateway\nNode: node-1\nCWD: /tmp/work\nExpires in: 2s");
|
|
expect(payload.text).toContain("Full id: `req-1`");
|
|
});
|
|
|
|
it("omits allow-always actions when the effective policy requires approval every time", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: "req-ask-always",
|
|
approvalSlug: "slug-always",
|
|
ask: "always",
|
|
command: "echo ok",
|
|
host: "gateway",
|
|
});
|
|
|
|
expect(payload.channelData).toEqual({
|
|
execApproval: {
|
|
approvalId: "req-ask-always",
|
|
approvalSlug: "slug-always",
|
|
approvalKind: "exec",
|
|
allowedDecisions: ["allow-once", "deny"],
|
|
},
|
|
});
|
|
expect(payload.text).toContain("```txt\n/approve slug-always allow-once\n```");
|
|
expect(payload.text).not.toContain("allow-always");
|
|
expect(payload.text).toContain(
|
|
"The effective approval policy requires approval every time, so Allow Always is unavailable.",
|
|
);
|
|
expect(payload.interactive).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Allow Once",
|
|
value: "/approve req-ask-always allow-once",
|
|
style: "success",
|
|
},
|
|
{
|
|
label: "Deny",
|
|
value: "/approve req-ask-always deny",
|
|
style: "danger",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("stores agent and session metadata for downstream suppression checks", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: "req-meta",
|
|
approvalSlug: "slug-meta",
|
|
agentId: "ops-agent",
|
|
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org",
|
|
command: "echo ok",
|
|
host: "gateway",
|
|
});
|
|
|
|
expect(payload.channelData).toEqual({
|
|
execApproval: {
|
|
approvalId: "req-meta",
|
|
approvalSlug: "slug-meta",
|
|
approvalKind: "exec",
|
|
agentId: "ops-agent",
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("uses a longer fence for commands containing triple backticks", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: "req-2",
|
|
approvalSlug: "slug-2",
|
|
approvalCommandId: " req-cmd-2 ",
|
|
command: "echo ```danger```",
|
|
host: "sandbox",
|
|
});
|
|
|
|
expect(payload.text).toContain("```txt\n/approve req-cmd-2 allow-once\n```");
|
|
expect(payload.text).toContain("````sh\necho ```danger```\n````");
|
|
expect(payload.text).not.toContain("Expires in:");
|
|
});
|
|
|
|
it("clamps pending reply expiration to zero seconds", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: "req-3",
|
|
approvalSlug: "slug-3",
|
|
command: "echo later",
|
|
host: "gateway",
|
|
expiresAtMs: 1000,
|
|
nowMs: 3000,
|
|
});
|
|
|
|
expect(payload.text).toContain("Expires in: 0s");
|
|
});
|
|
|
|
it("formats longer approval windows in minutes", () => {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: "req-30m",
|
|
approvalSlug: "slug-30m",
|
|
command: "echo later",
|
|
host: "gateway",
|
|
expiresAtMs: 1_801_000,
|
|
nowMs: 1_000,
|
|
});
|
|
|
|
expect(payload.text).toContain("Expires in: 30m");
|
|
});
|
|
|
|
it("builds shared exec approval action descriptors and interactive replies", () => {
|
|
expect(
|
|
buildExecApprovalActionDescriptors({
|
|
approvalCommandId: "req-1",
|
|
}),
|
|
).toEqual([
|
|
{
|
|
decision: "allow-once",
|
|
label: "Allow Once",
|
|
style: "success",
|
|
command: "/approve req-1 allow-once",
|
|
},
|
|
{
|
|
decision: "allow-always",
|
|
label: "Allow Always",
|
|
style: "primary",
|
|
command: "/approve req-1 allow-always",
|
|
},
|
|
{
|
|
decision: "deny",
|
|
label: "Deny",
|
|
style: "danger",
|
|
command: "/approve req-1 deny",
|
|
},
|
|
]);
|
|
|
|
expect(
|
|
buildExecApprovalInteractiveReply({
|
|
approvalCommandId: "req-1",
|
|
}),
|
|
).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{ label: "Allow Once", value: "/approve req-1 allow-once", style: "success" },
|
|
{ label: "Allow Always", value: "/approve req-1 allow-always", style: "primary" },
|
|
{ label: "Deny", value: "/approve req-1 deny", style: "danger" },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("builds and parses shared exec approval command text", () => {
|
|
expect(
|
|
buildExecApprovalCommandText({
|
|
approvalCommandId: "req-1",
|
|
decision: "allow-always",
|
|
}),
|
|
).toBe("/approve req-1 allow-always");
|
|
|
|
expect(parseExecApprovalCommandText("/approve req-1 deny")).toEqual({
|
|
approvalId: "req-1",
|
|
decision: "deny",
|
|
});
|
|
expect(parseExecApprovalCommandText("approve req-1 allow-once")).toEqual({
|
|
approvalId: "req-1",
|
|
decision: "allow-once",
|
|
});
|
|
expect(parseExecApprovalCommandText("/approve@clover req-1 allow-once")).toEqual({
|
|
approvalId: "req-1",
|
|
decision: "allow-once",
|
|
});
|
|
expect(parseExecApprovalCommandText(" /approve req-1 always")).toEqual({
|
|
approvalId: "req-1",
|
|
decision: "allow-always",
|
|
});
|
|
expect(parseExecApprovalCommandText("/approve req-1 allow-always")).toEqual({
|
|
approvalId: "req-1",
|
|
decision: "allow-always",
|
|
});
|
|
expect(parseExecApprovalCommandText("/approve req-1 maybe")).toBeNull();
|
|
});
|
|
|
|
it("builds unavailable payloads for approver DMs", () => {
|
|
expect(
|
|
buildExecApprovalUnavailableReplyPayload({
|
|
warningText: " Careful. ",
|
|
reason: "no-approval-route",
|
|
sentApproverDms: true,
|
|
}),
|
|
).toEqual({
|
|
text: "Careful.\n\nApproval required. I sent approval DMs to the approvers for this account.",
|
|
});
|
|
});
|
|
|
|
it.each(unavailableReasonCases)(
|
|
"builds unavailable payload for reason $reason",
|
|
({ reason, channelLabel, expected }) => {
|
|
expect(
|
|
buildExecApprovalUnavailableReplyPayload({
|
|
reason,
|
|
channelLabel,
|
|
}).text,
|
|
).toContain(expected);
|
|
},
|
|
);
|
|
});
|