mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 12:50:20 +00:00
Refactor: centralize native approval lifecycle assembly (#62135)
Merged via squash.
Prepared head SHA: b7c20a7398
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
4108901932
commit
d78512b09d
@@ -41,6 +41,7 @@ let sendExecApprovalFollowupResult: typeof import("./bash-tools.exec-host-shared
|
||||
let maxExecApprovalFollowupFailureLogKeys: typeof import("./bash-tools.exec-host-shared.js").MAX_EXEC_APPROVAL_FOLLOWUP_FAILURE_LOG_KEYS;
|
||||
let enforceStrictInlineEvalApprovalBoundary: typeof import("./bash-tools.exec-host-shared.js").enforceStrictInlineEvalApprovalBoundary;
|
||||
let resolveExecHostApprovalContext: typeof import("./bash-tools.exec-host-shared.js").resolveExecHostApprovalContext;
|
||||
let resolveExecApprovalUnavailableState: typeof import("./bash-tools.exec-host-shared.js").resolveExecApprovalUnavailableState;
|
||||
let buildExecApprovalPendingToolResult: typeof import("./bash-tools.exec-host-shared.js").buildExecApprovalPendingToolResult;
|
||||
let sendExecApprovalFollowup: typeof import("./bash-tools.exec-approval-followup.js").sendExecApprovalFollowup;
|
||||
let logWarn: typeof import("../logger.js").logWarn;
|
||||
@@ -51,6 +52,7 @@ beforeAll(async () => {
|
||||
MAX_EXEC_APPROVAL_FOLLOWUP_FAILURE_LOG_KEYS: maxExecApprovalFollowupFailureLogKeys,
|
||||
enforceStrictInlineEvalApprovalBoundary,
|
||||
resolveExecHostApprovalContext,
|
||||
resolveExecApprovalUnavailableState,
|
||||
buildExecApprovalPendingToolResult,
|
||||
} = await import("./bash-tools.exec-host-shared.js"));
|
||||
({ sendExecApprovalFollowup } = await import("./bash-tools.exec-approval-followup.js"));
|
||||
@@ -124,7 +126,7 @@ describe("sendExecApprovalFollowupResult", () => {
|
||||
});
|
||||
|
||||
describe("resolveExecHostApprovalContext", () => {
|
||||
it("uses exec-approvals.json agent security even when it is broader than the tool default", () => {
|
||||
it("does not let exec-approvals.json broaden security beyond the requested policy", () => {
|
||||
mocks.resolveExecApprovals.mockReturnValue({
|
||||
defaults: {
|
||||
security: "allowlist",
|
||||
@@ -149,7 +151,63 @@ describe("resolveExecHostApprovalContext", () => {
|
||||
host: "gateway",
|
||||
});
|
||||
|
||||
expect(result.hostSecurity).toBe("full");
|
||||
expect(result.hostSecurity).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("does not let host ask=off suppress a stricter requested ask mode", () => {
|
||||
mocks.resolveExecApprovals.mockReturnValue({
|
||||
defaults: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
askFallback: "full",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
agent: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
askFallback: "full",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
allowlist: [],
|
||||
file: { version: 1, agents: {} },
|
||||
});
|
||||
|
||||
const result = resolveExecHostApprovalContext({
|
||||
agentId: "agent-main",
|
||||
security: "full",
|
||||
ask: "always",
|
||||
host: "gateway",
|
||||
});
|
||||
|
||||
expect(result.hostAsk).toBe("always");
|
||||
});
|
||||
|
||||
it("clamps askFallback to the effective host security", () => {
|
||||
mocks.resolveExecApprovals.mockReturnValue({
|
||||
defaults: {
|
||||
security: "full",
|
||||
ask: "always",
|
||||
askFallback: "full",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
agent: {
|
||||
security: "full",
|
||||
ask: "always",
|
||||
askFallback: "full",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
allowlist: [],
|
||||
file: { version: 1, agents: {} },
|
||||
});
|
||||
|
||||
const result = resolveExecHostApprovalContext({
|
||||
agentId: "agent-main",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
host: "gateway",
|
||||
});
|
||||
|
||||
expect(result.askFallback).toBe("allowlist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +242,19 @@ describe("enforceStrictInlineEvalApprovalBoundary", () => {
|
||||
});
|
||||
|
||||
describe("buildExecApprovalPendingToolResult", () => {
|
||||
it("does not infer approver DM delivery from unavailable approval state", () => {
|
||||
expect(
|
||||
resolveExecApprovalUnavailableState({
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceAccountId: "default",
|
||||
preResolvedDecision: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
sentApproverDms: false,
|
||||
unavailableReason: "no-approval-route",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a local /approve prompt when the initiating Discord surface is disabled", () => {
|
||||
const result = buildExecApprovalPendingToolResult({
|
||||
host: "gateway",
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
|
||||
import {
|
||||
hasConfiguredExecApprovalDmRoute,
|
||||
type ExecApprovalInitiatingSurfaceState,
|
||||
resolveExecApprovalInitiatingSurfaceState,
|
||||
} from "../infra/exec-approval-surface.js";
|
||||
import {
|
||||
minSecurity,
|
||||
maxAsk,
|
||||
resolveExecApprovalAllowedDecisions,
|
||||
resolveExecApprovals,
|
||||
@@ -195,17 +194,11 @@ export function resolveExecHostApprovalContext(params: {
|
||||
security: params.security,
|
||||
ask: params.ask,
|
||||
});
|
||||
// exec-approvals.json is the authoritative security policy and must be able to grant
|
||||
// a less-restrictive level (e.g. "full") even when tool/runtime defaults are stricter
|
||||
// (e.g. "allowlist"). This matches node-host behavior and mirrors the ask=off special
|
||||
// case: exec-approvals.json can suppress prompts AND grant broader execution rights.
|
||||
// When exec-approvals.json has no explicit agent or defaults entry, approvals.agent.security
|
||||
// falls back to params.security, so this is backward-compatible.
|
||||
const hostSecurity = approvals.agent.security;
|
||||
// An explicit ask=off policy in exec-approvals.json must be able to suppress
|
||||
// prompts even when tool/runtime defaults are stricter (for example on-miss).
|
||||
const hostAsk = approvals.agent.ask === "off" ? "off" : maxAsk(params.ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
// Session/config tool policy is the caller's requested contract. The host file
|
||||
// may tighten that contract, but it must not silently broaden it.
|
||||
const hostSecurity = minSecurity(params.security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
|
||||
const askFallback = minSecurity(hostSecurity, approvals.agent.askFallback);
|
||||
if (hostSecurity === "deny") {
|
||||
throw new Error(`exec denied: host=${params.host} security=deny`);
|
||||
}
|
||||
@@ -241,9 +234,9 @@ export function resolveExecApprovalUnavailableState(params: {
|
||||
channel: params.turnSourceChannel,
|
||||
accountId: params.turnSourceAccountId,
|
||||
});
|
||||
const sentApproverDms =
|
||||
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
|
||||
hasConfiguredExecApprovalDmRoute(loadConfig());
|
||||
// Native approval runtimes emit routed-elsewhere notices after actual delivery.
|
||||
// Avoid claiming approver DMs were sent from config-only guesses here.
|
||||
const sentApproverDms = false;
|
||||
const unavailableReason =
|
||||
params.preResolvedDecision === null
|
||||
? "no-approval-route"
|
||||
|
||||
@@ -57,6 +57,7 @@ function createLifecycleContext(params: {
|
||||
pendingMessagingTargets: new Map(),
|
||||
successfulCronAdds: 0,
|
||||
pendingMessagingMediaUrls: new Map(),
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
} as never,
|
||||
log: {
|
||||
|
||||
@@ -153,6 +153,7 @@ describe("handleMessageUpdate", () => {
|
||||
onPartialReply,
|
||||
},
|
||||
state: {
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
reasoningStreamOpen: false,
|
||||
streamReasoning: false,
|
||||
@@ -211,6 +212,7 @@ describe("handleMessageUpdate", () => {
|
||||
onPartialReply,
|
||||
},
|
||||
state: {
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
reasoningStreamOpen: false,
|
||||
streamReasoning: false,
|
||||
@@ -263,6 +265,7 @@ describe("handleMessageUpdate", () => {
|
||||
onAgentEvent,
|
||||
},
|
||||
state: {
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
reasoningStreamOpen: false,
|
||||
streamReasoning: false,
|
||||
@@ -361,6 +364,7 @@ describe("handleMessageUpdate", () => {
|
||||
session: { id: "session-1" },
|
||||
},
|
||||
state: {
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
reasoningStreamOpen: false,
|
||||
streamReasoning: false,
|
||||
@@ -413,6 +417,7 @@ describe("handleMessageEnd", () => {
|
||||
assistantTexts: [],
|
||||
assistantTextBaseline: 0,
|
||||
emittedAssistantUpdate: false,
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
reasoningStreamOpen: false,
|
||||
includeReasoning: false,
|
||||
@@ -470,6 +475,7 @@ describe("handleMessageEnd", () => {
|
||||
assistantTexts: [],
|
||||
assistantTextBaseline: 0,
|
||||
emittedAssistantUpdate: false,
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
reasoningStreamOpen: false,
|
||||
includeReasoning: false,
|
||||
@@ -531,6 +537,7 @@ describe("handleMessageEnd", () => {
|
||||
onBlockReply,
|
||||
},
|
||||
state: {
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentTextsNormalized: [],
|
||||
@@ -592,6 +599,7 @@ describe("handleMessageEnd", () => {
|
||||
onBlockReply,
|
||||
},
|
||||
state: {
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentTextsNormalized: [],
|
||||
@@ -651,6 +659,7 @@ describe("handleMessageEnd", () => {
|
||||
onAgentEvent,
|
||||
},
|
||||
state: {
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentTextsNormalized: [],
|
||||
|
||||
@@ -204,9 +204,11 @@ export function handleMessageUpdate(
|
||||
|
||||
ctx.noteLastAssistant(msg);
|
||||
const suppressVisibleAssistantOutput = shouldSuppressAssistantVisibleOutput(msg);
|
||||
if (ctx.state.deterministicApprovalPromptSent) {
|
||||
if (suppressVisibleAssistantOutput) {
|
||||
return;
|
||||
}
|
||||
const suppressDeterministicApprovalOutput =
|
||||
ctx.state.deterministicApprovalPromptPending || ctx.state.deterministicApprovalPromptSent;
|
||||
|
||||
const assistantEvent = evt.assistantMessageEvent;
|
||||
const assistantRecord =
|
||||
@@ -262,10 +264,6 @@ export function handleMessageUpdate(
|
||||
content,
|
||||
});
|
||||
|
||||
if (suppressVisibleAssistantOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
let chunk = "";
|
||||
if (evtType === "text_delta") {
|
||||
chunk = delta;
|
||||
@@ -379,7 +377,7 @@ export function handleMessageUpdate(
|
||||
ctx.state.lastStreamedAssistant = next;
|
||||
ctx.state.lastStreamedAssistantCleaned = cleanedText;
|
||||
|
||||
if (ctx.params.silentExpected) {
|
||||
if (ctx.params.silentExpected || suppressDeterministicApprovalOutput) {
|
||||
shouldEmit = false;
|
||||
}
|
||||
|
||||
@@ -408,6 +406,7 @@ export function handleMessageUpdate(
|
||||
|
||||
if (
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressDeterministicApprovalOutput &&
|
||||
ctx.params.onBlockReply &&
|
||||
ctx.blockChunking &&
|
||||
ctx.state.blockReplyBreak === "text_end"
|
||||
@@ -417,6 +416,7 @@ export function handleMessageUpdate(
|
||||
|
||||
if (
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressDeterministicApprovalOutput &&
|
||||
evtType === "text_end" &&
|
||||
ctx.state.blockReplyBreak === "text_end"
|
||||
) {
|
||||
@@ -440,9 +440,11 @@ export function handleMessageEnd(
|
||||
|
||||
const assistantMessage = msg;
|
||||
const suppressVisibleAssistantOutput = shouldSuppressAssistantVisibleOutput(assistantMessage);
|
||||
const suppressDeterministicApprovalOutput =
|
||||
ctx.state.deterministicApprovalPromptPending || ctx.state.deterministicApprovalPromptSent;
|
||||
ctx.noteLastAssistant(assistantMessage);
|
||||
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
|
||||
if (ctx.state.deterministicApprovalPromptSent) {
|
||||
if (suppressVisibleAssistantOutput) {
|
||||
return;
|
||||
}
|
||||
promoteThinkingTagsToBlocks(assistantMessage);
|
||||
@@ -484,12 +486,6 @@ export function handleMessageEnd(
|
||||
ctx.state.reasoningStreamOpen = false;
|
||||
};
|
||||
|
||||
if (suppressVisibleAssistantOutput) {
|
||||
emitReasoningEnd(ctx);
|
||||
finalizeMessageEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const previousStreamedText = ctx.state.lastStreamedAssistantCleaned ?? "";
|
||||
const shouldReplaceFinalStream = Boolean(
|
||||
previousStreamedText && cleanedText && !cleanedText.startsWith(previousStreamedText),
|
||||
@@ -503,6 +499,7 @@ export function handleMessageEnd(
|
||||
|
||||
if (
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressDeterministicApprovalOutput &&
|
||||
(cleanedText || hasMedia) &&
|
||||
(!ctx.state.emittedAssistantUpdate ||
|
||||
shouldReplaceFinalStream ||
|
||||
@@ -542,6 +539,7 @@ export function handleMessageEnd(
|
||||
const onBlockReply = ctx.params.onBlockReply;
|
||||
const shouldEmitReasoning = Boolean(
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressDeterministicApprovalOutput &&
|
||||
ctx.state.includeReasoning &&
|
||||
formattedReasoning &&
|
||||
onBlockReply &&
|
||||
@@ -594,6 +592,7 @@ export function handleMessageEnd(
|
||||
|
||||
if (
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressDeterministicApprovalOutput &&
|
||||
text &&
|
||||
onBlockReply &&
|
||||
(ctx.state.blockReplyBreak === "message_end" ||
|
||||
|
||||
@@ -35,6 +35,7 @@ function createMockContext(overrides?: {
|
||||
messagingToolSentTextsNormalized: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
},
|
||||
log: { debug: vi.fn(), warn: vi.fn() },
|
||||
|
||||
@@ -47,6 +47,7 @@ function createTestContext(): {
|
||||
pendingMessagingMediaUrls: new Map<string, string[]>(),
|
||||
pendingToolMediaUrls: [],
|
||||
pendingToolAudioAsVoice: false,
|
||||
deterministicApprovalPromptPending: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentTextsNormalized: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
|
||||
@@ -429,6 +429,7 @@ async function emitToolResultOutput(params: {
|
||||
if (!ctx.params.onToolResult) {
|
||||
return;
|
||||
}
|
||||
ctx.state.deterministicApprovalPromptPending = true;
|
||||
try {
|
||||
await ctx.params.onToolResult(
|
||||
buildExecApprovalPendingReplyPayload({
|
||||
@@ -445,7 +446,9 @@ async function emitToolResultOutput(params: {
|
||||
);
|
||||
ctx.state.deterministicApprovalPromptSent = true;
|
||||
} catch {
|
||||
// ignore delivery failures
|
||||
ctx.state.deterministicApprovalPromptSent = false;
|
||||
} finally {
|
||||
ctx.state.deterministicApprovalPromptPending = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -455,6 +458,7 @@ async function emitToolResultOutput(params: {
|
||||
if (!ctx.params.onToolResult) {
|
||||
return;
|
||||
}
|
||||
ctx.state.deterministicApprovalPromptPending = true;
|
||||
try {
|
||||
await ctx.params.onToolResult?.(
|
||||
buildExecApprovalUnavailableReplyPayload({
|
||||
@@ -468,7 +472,9 @@ async function emitToolResultOutput(params: {
|
||||
);
|
||||
ctx.state.deterministicApprovalPromptSent = true;
|
||||
} catch {
|
||||
// ignore delivery failures
|
||||
ctx.state.deterministicApprovalPromptSent = false;
|
||||
} finally {
|
||||
ctx.state.deterministicApprovalPromptPending = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export type EmbeddedPiSubscribeState = {
|
||||
pendingMessagingMediaUrls: Map<string, string[]>;
|
||||
pendingToolMediaUrls: string[];
|
||||
pendingToolAudioAsVoice: boolean;
|
||||
deterministicApprovalPromptPending: boolean;
|
||||
deterministicApprovalPromptSent: boolean;
|
||||
lastAssistant?: AgentMessage;
|
||||
};
|
||||
@@ -157,6 +158,7 @@ export type ToolHandlerState = Pick<
|
||||
| "pendingMessagingMediaUrls"
|
||||
| "pendingToolMediaUrls"
|
||||
| "pendingToolAudioAsVoice"
|
||||
| "deterministicApprovalPromptPending"
|
||||
| "messagingToolSentTexts"
|
||||
| "messagingToolSentTextsNormalized"
|
||||
| "messagingToolSentMediaUrls"
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("does not let tool_execution_end delivery stall later assistant streaming", async () => {
|
||||
it("suppresses assistant streaming while deterministic exec approval delivery is pending", async () => {
|
||||
let resolveToolResult: (() => void) | undefined;
|
||||
const onToolResult = vi.fn(
|
||||
() =>
|
||||
@@ -200,13 +200,13 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||
expect(onPartialReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "After tool", delta: "After tool" }),
|
||||
);
|
||||
});
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
|
||||
expect(resolveToolResult).toBeTypeOf("function");
|
||||
resolveToolResult?.();
|
||||
await Promise.resolve();
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches media from internal completion events even when assistant omits MEDIA lines", async () => {
|
||||
|
||||
@@ -113,6 +113,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
pendingMessagingMediaUrls: new Map(),
|
||||
pendingToolMediaUrls: initialPendingToolMediaUrls,
|
||||
pendingToolAudioAsVoice: false,
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
};
|
||||
const usageTotals = {
|
||||
@@ -687,6 +688,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
state.pendingMessagingMediaUrls.clear();
|
||||
state.pendingToolMediaUrls = [];
|
||||
state.pendingToolAudioAsVoice = false;
|
||||
state.deterministicApprovalPromptPending = false;
|
||||
state.deterministicApprovalPromptSent = false;
|
||||
resetAssistantMessageState(0);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export function createBaseToolHandlerState() {
|
||||
pendingMessagingMediaUrls: new Map<string, string[]>(),
|
||||
pendingToolMediaUrls: [] as string[],
|
||||
pendingToolAudioAsVoice: false,
|
||||
deterministicApprovalPromptPending: false,
|
||||
messagingToolSentTexts: [] as string[],
|
||||
messagingToolSentTextsNormalized: [] as string[],
|
||||
messagingToolSentMediaUrls: [] as string[],
|
||||
|
||||
@@ -159,4 +159,55 @@ describe("directive behavior exec agent defaults", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces a prior deny override with newer exec settings on later turns", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
runEmbeddedPiAgentMock.mockResolvedValue(makeEmbeddedTextResult("done"));
|
||||
|
||||
await getReplyFromConfig(
|
||||
{
|
||||
Body: "/exec host=gateway security=deny ask=off",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
makeAgentExecConfig(home),
|
||||
);
|
||||
|
||||
await getReplyFromConfig(
|
||||
{
|
||||
Body: "/exec host=gateway security=full ask=always",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
makeAgentExecConfig(home),
|
||||
);
|
||||
|
||||
runEmbeddedPiAgentMock.mockClear();
|
||||
|
||||
await getReplyFromConfig(
|
||||
{
|
||||
Body: "run a command",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1004",
|
||||
},
|
||||
{},
|
||||
makeAgentExecConfig(home),
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
|
||||
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
|
||||
expect(call?.execOverrides).toEqual({
|
||||
host: "gateway",
|
||||
security: "full",
|
||||
ask: "always",
|
||||
node: "worker-alpha",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,7 +79,11 @@ const discordApproveTestPlugin: ChannelPlugin = {
|
||||
nativeCommands: true,
|
||||
},
|
||||
}),
|
||||
auth: discordNativeApprovalAdapterForTests.auth,
|
||||
approvalCapability: {
|
||||
authorizeActorAction: discordNativeApprovalAdapterForTests.auth.authorizeActorAction,
|
||||
getActionAvailabilityState:
|
||||
discordNativeApprovalAdapterForTests.auth.getActionAvailabilityState,
|
||||
},
|
||||
};
|
||||
|
||||
const slackApproveTestPlugin: ChannelPlugin = {
|
||||
@@ -108,7 +112,7 @@ const signalApproveTestPlugin: ChannelPlugin = {
|
||||
nativeCommands: true,
|
||||
},
|
||||
}),
|
||||
auth: createResolvedApproverActionAuthAdapter({
|
||||
approvalCapability: createResolvedApproverActionAuthAdapter({
|
||||
channelLabel: "Signal",
|
||||
resolveApprovers: ({ cfg, accountId }) => {
|
||||
const signal = accountId ? cfg.channels?.signal?.accounts?.[accountId] : cfg.channels?.signal;
|
||||
@@ -308,8 +312,9 @@ const telegramApproveTestPlugin: ChannelPlugin = {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
},
|
||||
}),
|
||||
auth: telegramNativeApprovalAdapter.auth,
|
||||
approvalCapability: {
|
||||
authorizeActorAction: telegramNativeApprovalAdapter.auth.authorizeActorAction,
|
||||
getActionAvailabilityState: telegramNativeApprovalAdapter.auth.getActionAvailabilityState,
|
||||
resolveApproveCommandBehavior: ({ cfg, accountId, senderId, approvalKind }) => {
|
||||
if (approvalKind !== "exec") {
|
||||
return undefined;
|
||||
@@ -608,7 +613,7 @@ describe("handleApproveCommand", () => {
|
||||
pluginId: "slack",
|
||||
plugin: {
|
||||
...createChannelTestPluginBase({ id: "slack", label: "Slack" }),
|
||||
auth: {
|
||||
approvalCapability: {
|
||||
authorizeActorAction: () => ({ authorized: true }),
|
||||
getActionAvailabilityState: () => ({ kind: "disabled" }),
|
||||
},
|
||||
@@ -798,7 +803,7 @@ describe("handleApproveCommand", () => {
|
||||
pluginId: "matrix",
|
||||
plugin: {
|
||||
...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }),
|
||||
auth: {
|
||||
approvalCapability: {
|
||||
authorizeActorAction: ({ approvalKind }: { approvalKind: "exec" | "plugin" }) =>
|
||||
approvalKind === "plugin"
|
||||
? { authorized: true }
|
||||
|
||||
41
src/auto-reply/reply/get-reply-run.exec-hint.test.ts
Normal file
41
src/auto-reply/reply/get-reply-run.exec-hint.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildExecOverridePromptHint } from "./get-reply-run.js";
|
||||
|
||||
describe("buildExecOverridePromptHint", () => {
|
||||
it("returns undefined when exec state is fully inherited and elevated is off", () => {
|
||||
expect(
|
||||
buildExecOverridePromptHint({
|
||||
elevatedLevel: "off",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes current exec defaults and warns against stale denial assumptions", () => {
|
||||
const result = buildExecOverridePromptHint({
|
||||
execOverrides: {
|
||||
host: "gateway",
|
||||
security: "full",
|
||||
ask: "always",
|
||||
node: "worker-1",
|
||||
},
|
||||
elevatedLevel: "off",
|
||||
});
|
||||
|
||||
expect(result).toContain(
|
||||
"Current session exec defaults: host=gateway security=full ask=always node=worker-1.",
|
||||
);
|
||||
expect(result).toContain("Current elevated level: off.");
|
||||
expect(result).toContain("Do not assume a prior denial still applies");
|
||||
});
|
||||
|
||||
it("still reports elevated state when exec overrides are inherited", () => {
|
||||
const result = buildExecOverridePromptHint({
|
||||
elevatedLevel: "full",
|
||||
});
|
||||
|
||||
expect(result).toContain(
|
||||
"Current session exec defaults: inherited from configured agent/global defaults.",
|
||||
);
|
||||
expect(result).toContain("Current elevated level: full.");
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,33 @@ import type { TypingController } from "./typing.js";
|
||||
type AgentDefaults = NonNullable<OpenClawConfig["agents"]>["defaults"];
|
||||
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
|
||||
|
||||
export function buildExecOverridePromptHint(params: {
|
||||
execOverrides?: ExecOverrides;
|
||||
elevatedLevel: ElevatedLevel;
|
||||
}): string | undefined {
|
||||
const exec = params.execOverrides;
|
||||
if (!exec && params.elevatedLevel === "off") {
|
||||
return undefined;
|
||||
}
|
||||
const parts = [
|
||||
exec?.host ? `host=${exec.host}` : undefined,
|
||||
exec?.security ? `security=${exec.security}` : undefined,
|
||||
exec?.ask ? `ask=${exec.ask}` : undefined,
|
||||
exec?.node ? `node=${exec.node}` : undefined,
|
||||
].filter(Boolean);
|
||||
const execLine =
|
||||
parts.length > 0
|
||||
? `Current session exec defaults: ${parts.join(" ")}.`
|
||||
: "Current session exec defaults: inherited from configured agent/global defaults.";
|
||||
const elevatedLine = `Current elevated level: ${params.elevatedLevel}.`;
|
||||
return [
|
||||
"## Current Exec Session State",
|
||||
execLine,
|
||||
elevatedLine,
|
||||
"If the user asks to run a command, use the current exec state above. Do not assume a prior denial still applies after `/exec` or `/elevated` changed.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
let piEmbeddedRuntimePromise: Promise<typeof import("../../agents/pi-embedded.runtime.js")> | null =
|
||||
null;
|
||||
let agentRunnerRuntimePromise: Promise<typeof import("./agent-runner.runtime.js")> | null = null;
|
||||
@@ -231,6 +258,10 @@ export async function runPreparedReply(
|
||||
groupChatContext,
|
||||
groupIntro,
|
||||
groupSystemPrompt,
|
||||
buildExecOverridePromptHint({
|
||||
execOverrides,
|
||||
elevatedLevel: resolvedElevatedLevel,
|
||||
}),
|
||||
].filter(Boolean);
|
||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
// Use CommandBody/RawBody for bare reset detection (clean message without structural context).
|
||||
|
||||
@@ -1,55 +1,49 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveChannelApprovalAdapter, resolveChannelApprovalCapability } from "./approvals.js";
|
||||
|
||||
describe("resolveChannelApprovalCapability", () => {
|
||||
it("falls back to legacy approval fields when approvalCapability is absent", () => {
|
||||
const authorizeActorAction = vi.fn();
|
||||
const getActionAvailabilityState = vi.fn();
|
||||
const delivery = { hasConfiguredDmRoute: vi.fn() };
|
||||
const describeExecApprovalSetup = vi.fn();
|
||||
function createNativeRuntimeStub() {
|
||||
return {
|
||||
availability: {
|
||||
isConfigured: vi.fn(),
|
||||
shouldHandle: vi.fn(),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn(),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveChannelApprovalCapability({
|
||||
auth: {
|
||||
authorizeActorAction,
|
||||
getActionAvailabilityState,
|
||||
},
|
||||
approvals: {
|
||||
describeExecApprovalSetup,
|
||||
delivery,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
authorizeActorAction,
|
||||
getActionAvailabilityState,
|
||||
describeExecApprovalSetup,
|
||||
delivery,
|
||||
render: undefined,
|
||||
native: undefined,
|
||||
});
|
||||
describe("resolveChannelApprovalCapability", () => {
|
||||
it("returns undefined when approvalCapability is absent", () => {
|
||||
expect(resolveChannelApprovalCapability({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges partial approvalCapability fields with legacy approval wiring", () => {
|
||||
it("returns approvalCapability as the canonical approval contract", () => {
|
||||
const capabilityAuth = vi.fn();
|
||||
const legacyAvailability = vi.fn();
|
||||
const legacyDelivery = { hasConfiguredDmRoute: vi.fn() };
|
||||
const capabilityAvailability = vi.fn();
|
||||
const capabilityNativeRuntime = createNativeRuntimeStub();
|
||||
const delivery = { hasConfiguredDmRoute: vi.fn() };
|
||||
|
||||
expect(
|
||||
resolveChannelApprovalCapability({
|
||||
approvalCapability: {
|
||||
authorizeActorAction: capabilityAuth,
|
||||
},
|
||||
auth: {
|
||||
getActionAvailabilityState: legacyAvailability,
|
||||
},
|
||||
approvals: {
|
||||
delivery: legacyDelivery,
|
||||
getActionAvailabilityState: capabilityAvailability,
|
||||
delivery,
|
||||
nativeRuntime: capabilityNativeRuntime,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
authorizeActorAction: capabilityAuth,
|
||||
getActionAvailabilityState: legacyAvailability,
|
||||
delivery: legacyDelivery,
|
||||
getActionAvailabilityState: capabilityAvailability,
|
||||
delivery,
|
||||
nativeRuntime: capabilityNativeRuntime,
|
||||
render: undefined,
|
||||
native: undefined,
|
||||
});
|
||||
@@ -57,23 +51,24 @@ describe("resolveChannelApprovalCapability", () => {
|
||||
});
|
||||
|
||||
describe("resolveChannelApprovalAdapter", () => {
|
||||
it("preserves legacy delivery surfaces when approvalCapability only defines auth", () => {
|
||||
it("returns only delivery/runtime surfaces from approvalCapability", () => {
|
||||
const delivery = { hasConfiguredDmRoute: vi.fn() };
|
||||
const nativeRuntime = createNativeRuntimeStub();
|
||||
const describeExecApprovalSetup = vi.fn();
|
||||
|
||||
expect(
|
||||
resolveChannelApprovalAdapter({
|
||||
approvalCapability: {
|
||||
authorizeActorAction: vi.fn(),
|
||||
},
|
||||
approvals: {
|
||||
describeExecApprovalSetup,
|
||||
delivery,
|
||||
nativeRuntime,
|
||||
authorizeActorAction: vi.fn(),
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
describeExecApprovalSetup,
|
||||
delivery,
|
||||
nativeRuntime,
|
||||
render: undefined,
|
||||
native: undefined,
|
||||
});
|
||||
|
||||
@@ -1,72 +1,30 @@
|
||||
import type { ChannelApprovalAdapter, ChannelApprovalCapability, ChannelPlugin } from "./types.js";
|
||||
|
||||
function buildApprovalCapabilityFromLegacyPlugin(
|
||||
plugin?: Pick<ChannelPlugin, "auth" | "approvals"> | null,
|
||||
): ChannelApprovalCapability | undefined {
|
||||
const authorizeActorAction = plugin?.auth?.authorizeActorAction;
|
||||
const getActionAvailabilityState = plugin?.auth?.getActionAvailabilityState;
|
||||
const resolveApproveCommandBehavior = plugin?.auth?.resolveApproveCommandBehavior;
|
||||
const approvals = plugin?.approvals;
|
||||
if (
|
||||
!authorizeActorAction &&
|
||||
!getActionAvailabilityState &&
|
||||
!resolveApproveCommandBehavior &&
|
||||
!approvals?.describeExecApprovalSetup &&
|
||||
!approvals?.delivery &&
|
||||
!approvals?.render &&
|
||||
!approvals?.native
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
authorizeActorAction,
|
||||
getActionAvailabilityState,
|
||||
resolveApproveCommandBehavior,
|
||||
describeExecApprovalSetup: approvals?.describeExecApprovalSetup,
|
||||
delivery: approvals?.delivery,
|
||||
render: approvals?.render,
|
||||
native: approvals?.native,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveChannelApprovalCapability(
|
||||
plugin?: Pick<ChannelPlugin, "approvalCapability" | "auth" | "approvals"> | null,
|
||||
plugin?: Pick<ChannelPlugin, "approvalCapability"> | null,
|
||||
): ChannelApprovalCapability | undefined {
|
||||
const capability = plugin?.approvalCapability;
|
||||
const legacyCapability = buildApprovalCapabilityFromLegacyPlugin(plugin);
|
||||
if (!capability) {
|
||||
return legacyCapability;
|
||||
}
|
||||
if (!legacyCapability) {
|
||||
return capability;
|
||||
}
|
||||
return {
|
||||
authorizeActorAction: capability.authorizeActorAction ?? legacyCapability.authorizeActorAction,
|
||||
getActionAvailabilityState:
|
||||
capability.getActionAvailabilityState ?? legacyCapability.getActionAvailabilityState,
|
||||
resolveApproveCommandBehavior:
|
||||
capability.resolveApproveCommandBehavior ?? legacyCapability.resolveApproveCommandBehavior,
|
||||
describeExecApprovalSetup:
|
||||
capability.describeExecApprovalSetup ?? legacyCapability.describeExecApprovalSetup,
|
||||
delivery: capability.delivery ?? legacyCapability.delivery,
|
||||
render: capability.render ?? legacyCapability.render,
|
||||
native: capability.native ?? legacyCapability.native,
|
||||
};
|
||||
return plugin?.approvalCapability;
|
||||
}
|
||||
|
||||
export function resolveChannelApprovalAdapter(
|
||||
plugin?: Pick<ChannelPlugin, "approvalCapability" | "auth" | "approvals"> | null,
|
||||
plugin?: Pick<ChannelPlugin, "approvalCapability"> | null,
|
||||
): ChannelApprovalAdapter | undefined {
|
||||
const capability = resolveChannelApprovalCapability(plugin);
|
||||
if (!capability) {
|
||||
return undefined;
|
||||
}
|
||||
if (!capability.delivery && !capability.render && !capability.native) {
|
||||
if (
|
||||
!capability.delivery &&
|
||||
!capability.nativeRuntime &&
|
||||
!capability.render &&
|
||||
!capability.native
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
describeExecApprovalSetup: capability.describeExecApprovalSetup,
|
||||
delivery: capability.delivery,
|
||||
nativeRuntime: capability.nativeRuntime,
|
||||
render: capability.render,
|
||||
native: capability.native,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ConfiguredBindingRule } from "../../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { LegacyConfigRule } from "../../config/legacy.shared.js";
|
||||
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
||||
import type { ChannelApprovalNativeRuntimeAdapter } from "../../infra/approval-handler-runtime.js";
|
||||
import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js";
|
||||
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
||||
import type { OutboundIdentity } from "../../infra/outbound/identity.js";
|
||||
@@ -391,6 +392,8 @@ export type ChannelGatewayContext<ResolvedAccount = unknown> = {
|
||||
* - Built-in channels (slack, discord, etc.) typically don't use this field
|
||||
* because they can directly import internal modules
|
||||
* - External plugins should check for undefined before using
|
||||
* - When provided, this must be a full `createPluginRuntime().channel` surface;
|
||||
* partial stubs are not supported
|
||||
*
|
||||
* @since Plugin SDK 2026.2.19
|
||||
* @see {@link https://docs.openclaw.ai/plugins/developing-plugins | Plugin SDK documentation}
|
||||
@@ -458,22 +461,6 @@ export type ChannelAuthAdapter = {
|
||||
verbose?: boolean;
|
||||
channelInput?: string | null;
|
||||
}) => Promise<void>;
|
||||
authorizeActorAction?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
action: "approve";
|
||||
approvalKind: "exec" | "plugin";
|
||||
}) => {
|
||||
authorized: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
getActionAvailabilityState?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
action: "approve";
|
||||
}) => ChannelActionAvailabilityState;
|
||||
resolveApproveCommandBehavior?: ChannelApprovalCapability["resolveApproveCommandBehavior"];
|
||||
};
|
||||
|
||||
export type ChannelHeartbeatAdapter = {
|
||||
@@ -755,6 +742,7 @@ export type ChannelApprovalRenderAdapter = {
|
||||
|
||||
export type ChannelApprovalAdapter = {
|
||||
delivery?: ChannelApprovalDeliveryAdapter;
|
||||
nativeRuntime?: ChannelApprovalNativeRuntimeAdapter;
|
||||
render?: ChannelApprovalRenderAdapter;
|
||||
native?: ChannelApprovalNativeAdapter;
|
||||
describeExecApprovalSetup?: (params: {
|
||||
@@ -765,8 +753,28 @@ export type ChannelApprovalAdapter = {
|
||||
};
|
||||
|
||||
export type ChannelApprovalCapability = ChannelApprovalAdapter & {
|
||||
authorizeActorAction?: ChannelAuthAdapter["authorizeActorAction"];
|
||||
getActionAvailabilityState?: ChannelAuthAdapter["getActionAvailabilityState"];
|
||||
authorizeActorAction?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
action: "approve";
|
||||
approvalKind: "exec" | "plugin";
|
||||
}) => {
|
||||
authorized: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
getActionAvailabilityState?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
action: "approve";
|
||||
approvalKind?: ChannelApprovalKind;
|
||||
}) => ChannelActionAvailabilityState;
|
||||
/** Exec-native client availability for the initiating surface; distinct from same-chat auth. */
|
||||
getExecInitiatingSurfaceState?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
action: "approve";
|
||||
}) => ChannelActionAvailabilityState;
|
||||
resolveApproveCommandBehavior?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ChannelSetupWizardAdapter } from "./setup-wizard-types.js";
|
||||
import type { ChannelSetupWizard } from "./setup-wizard.js";
|
||||
import type {
|
||||
ChannelApprovalAdapter,
|
||||
ChannelApprovalCapability,
|
||||
ChannelAuthAdapter,
|
||||
ChannelCommandAdapter,
|
||||
@@ -104,13 +103,13 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
|
||||
status?: ChannelStatusAdapter<ResolvedAccount, Probe, Audit>;
|
||||
gatewayMethods?: string[];
|
||||
gateway?: ChannelGatewayAdapter<ResolvedAccount>;
|
||||
// Login/logout and channel-auth only. Approval auth lives on approvalCapability.
|
||||
auth?: ChannelAuthAdapter;
|
||||
approvalCapability?: ChannelApprovalCapability;
|
||||
elevated?: ChannelElevatedAdapter;
|
||||
commands?: ChannelCommandAdapter;
|
||||
lifecycle?: ChannelLifecycleAdapter;
|
||||
secrets?: ChannelSecretsAdapter;
|
||||
approvals?: ChannelApprovalAdapter;
|
||||
allowlist?: ChannelAllowlistAdapter;
|
||||
doctor?: ChannelDoctorAdapter;
|
||||
bindings?: ChannelConfiguredBindingProvider;
|
||||
|
||||
213
src/gateway/server-channels.approval-bootstrap.test.ts
Normal file
213
src/gateway/server-channels.approval-bootstrap.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { type ChannelId, type ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import {
|
||||
createSubsystemLogger,
|
||||
runtimeForLogger,
|
||||
type SubsystemLogger,
|
||||
} from "../logging/subsystem.js";
|
||||
import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js";
|
||||
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createRuntimeChannel } from "../plugins/runtime/runtime-channel.js";
|
||||
import type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
startChannelApprovalHandlerBootstrap: vi.fn(async () => async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/approval-handler-bootstrap.js", () => ({
|
||||
startChannelApprovalHandlerBootstrap: hoisted.startChannelApprovalHandlerBootstrap,
|
||||
}));
|
||||
|
||||
function createDeferred() {
|
||||
let resolvePromise = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
return { promise, resolve: resolvePromise };
|
||||
}
|
||||
|
||||
function createTestPlugin(params: {
|
||||
startAccount: NonNullable<NonNullable<ChannelPlugin["gateway"]>["startAccount"]>;
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
id: "discord",
|
||||
meta: {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
selectionLabel: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "test stub",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
||||
resolveAccount: () => ({ enabled: true, configured: true }),
|
||||
isEnabled: () => true,
|
||||
describeAccount: () => ({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
}),
|
||||
},
|
||||
approvalCapability: {
|
||||
nativeRuntime: {
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn(),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: params.startAccount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installTestRegistry(plugin: ChannelPlugin) {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.channels.push({
|
||||
pluginId: plugin.id,
|
||||
source: "test",
|
||||
plugin,
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
}
|
||||
|
||||
function createManager(
|
||||
createChannelManager: typeof import("./server-channels.js").createChannelManager,
|
||||
options?: {
|
||||
channelRuntime?: PluginRuntime["channel"];
|
||||
},
|
||||
) {
|
||||
const log = createSubsystemLogger("gateway/server-channels-approval-bootstrap-test");
|
||||
const channelLogs = { discord: log } as Record<ChannelId, SubsystemLogger>;
|
||||
const runtime = runtimeForLogger(log);
|
||||
const channelRuntimeEnvs = { discord: runtime } as unknown as Record<ChannelId, RuntimeEnv>;
|
||||
return createChannelManager({
|
||||
loadConfig: () => ({}),
|
||||
channelLogs,
|
||||
channelRuntimeEnvs,
|
||||
...(options?.channelRuntime ? { channelRuntime: options.channelRuntime } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("server-channels approval bootstrap", () => {
|
||||
let previousRegistry: PluginRegistry | null = null;
|
||||
let createChannelManager: typeof import("./server-channels.js").createChannelManager;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createChannelManager } = await import("./server-channels.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
previousRegistry = getActivePluginRegistry();
|
||||
hoisted.startChannelApprovalHandlerBootstrap.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(previousRegistry ?? createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("starts and stops the shared approval bootstrap with the channel lifecycle", async () => {
|
||||
const channelRuntime = createRuntimeChannel();
|
||||
const stopApprovalBootstrap = vi.fn(async () => {});
|
||||
hoisted.startChannelApprovalHandlerBootstrap.mockResolvedValue(stopApprovalBootstrap);
|
||||
|
||||
const started = createDeferred();
|
||||
const stopped = createDeferred();
|
||||
const startAccount = vi.fn(
|
||||
async ({
|
||||
abortSignal,
|
||||
channelRuntime,
|
||||
}: Parameters<NonNullable<NonNullable<ChannelPlugin["gateway"]>["startAccount"]>>[0]) => {
|
||||
channelRuntime?.runtimeContexts.register({
|
||||
channelId: "discord",
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
capability: "approval.native",
|
||||
context: { token: "tracked" },
|
||||
});
|
||||
started.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
stopped.resolve();
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
installTestRegistry(createTestPlugin({ startAccount }));
|
||||
const manager = createManager(createChannelManager, { channelRuntime });
|
||||
|
||||
await manager.startChannels();
|
||||
await started.promise;
|
||||
|
||||
expect(hoisted.startChannelApprovalHandlerBootstrap).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
plugin: expect.objectContaining({ id: "discord" }),
|
||||
cfg: {},
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
channelRuntime: expect.objectContaining({
|
||||
runtimeContexts: expect.any(Object),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
channelRuntime.runtimeContexts.get({
|
||||
channelId: "discord",
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
capability: "approval.native",
|
||||
}),
|
||||
).toEqual({ token: "tracked" });
|
||||
|
||||
await manager.stopChannel("discord", DEFAULT_ACCOUNT_ID);
|
||||
await stopped.promise;
|
||||
|
||||
expect(stopApprovalBootstrap).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
channelRuntime.runtimeContexts.get({
|
||||
channelId: "discord",
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
capability: "approval.native",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the account stopped when approval bootstrap startup fails", async () => {
|
||||
const channelRuntime = createRuntimeChannel();
|
||||
const startAccount = vi.fn(async () => {});
|
||||
hoisted.startChannelApprovalHandlerBootstrap.mockRejectedValue(new Error("boom"));
|
||||
|
||||
installTestRegistry(createTestPlugin({ startAccount }));
|
||||
const manager = createManager(createChannelManager, { channelRuntime });
|
||||
|
||||
await manager.startChannels();
|
||||
|
||||
expect(startAccount).not.toHaveBeenCalled();
|
||||
const accountSnapshot =
|
||||
manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
|
||||
expect(accountSnapshot).toEqual(
|
||||
expect.objectContaining({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
restartPending: false,
|
||||
lastError: "boom",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "../logging/subsystem.js";
|
||||
import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js";
|
||||
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createRuntimeChannel } from "../plugins/runtime/runtime-channel.js";
|
||||
import type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -220,16 +221,20 @@ describe("server-channels auto restart", () => {
|
||||
});
|
||||
|
||||
it("passes channelRuntime through channel gateway context when provided", async () => {
|
||||
const channelRuntime = { marker: "channel-runtime" } as unknown as PluginRuntime["channel"];
|
||||
const startAccount = vi.fn(async (ctx) => {
|
||||
expect(ctx.channelRuntime).toBe(channelRuntime);
|
||||
});
|
||||
const channelRuntime = {
|
||||
...createRuntimeChannel(),
|
||||
marker: "channel-runtime",
|
||||
} as PluginRuntime["channel"] & { marker: string };
|
||||
const startAccount = vi.fn(async (_ctx: { channelRuntime?: PluginRuntime["channel"] }) => {});
|
||||
|
||||
installTestRegistry(createTestPlugin({ startAccount }));
|
||||
const manager = createManager({ channelRuntime });
|
||||
|
||||
await manager.startChannels();
|
||||
expect(startAccount).toHaveBeenCalledTimes(1);
|
||||
const [ctx] = startAccount.mock.calls[0] ?? [];
|
||||
expect(ctx?.channelRuntime).toMatchObject({ marker: "channel-runtime" });
|
||||
expect(ctx?.channelRuntime).not.toBe(channelRuntime);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent start requests for the same account", async () => {
|
||||
@@ -280,12 +285,11 @@ describe("server-channels auto restart", () => {
|
||||
|
||||
it("does not resolve channelRuntime until a channel starts", async () => {
|
||||
const channelRuntime = {
|
||||
...createRuntimeChannel(),
|
||||
marker: "lazy-channel-runtime",
|
||||
} as unknown as PluginRuntime["channel"];
|
||||
} as PluginRuntime["channel"] & { marker: string };
|
||||
const resolveChannelRuntime = vi.fn(() => channelRuntime);
|
||||
const startAccount = vi.fn(async (ctx) => {
|
||||
expect(ctx.channelRuntime).toBe(channelRuntime);
|
||||
});
|
||||
const startAccount = vi.fn(async (_ctx: { channelRuntime?: PluginRuntime["channel"] }) => {});
|
||||
|
||||
installTestRegistry(createTestPlugin({ startAccount }));
|
||||
const manager = createManager({ resolveChannelRuntime });
|
||||
@@ -299,6 +303,56 @@ describe("server-channels auto restart", () => {
|
||||
|
||||
expect(resolveChannelRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(startAccount).toHaveBeenCalledTimes(1);
|
||||
const [ctx] = startAccount.mock.calls[0] ?? [];
|
||||
expect(ctx?.channelRuntime).toMatchObject({ marker: "lazy-channel-runtime" });
|
||||
expect(ctx?.channelRuntime).not.toBe(channelRuntime);
|
||||
});
|
||||
|
||||
it("fails fast when channelRuntime is not a full plugin runtime surface", async () => {
|
||||
installTestRegistry(createTestPlugin({ startAccount: vi.fn(async () => {}) }));
|
||||
const manager = createManager({
|
||||
channelRuntime: { marker: "partial-runtime" } as unknown as PluginRuntime["channel"],
|
||||
});
|
||||
|
||||
await expect(manager.startChannel("discord", DEFAULT_ACCOUNT_ID)).rejects.toThrow(
|
||||
"channelRuntime must provide runtimeContexts.register/get/watch; pass createPluginRuntime().channel or omit channelRuntime.",
|
||||
);
|
||||
await expect(manager.startChannel("discord", DEFAULT_ACCOUNT_ID)).rejects.toThrow(
|
||||
"channelRuntime must provide runtimeContexts.register/get/watch; pass createPluginRuntime().channel or omit channelRuntime.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps auto-restart running when scoped runtime cleanup throws", async () => {
|
||||
const baseChannelRuntime = createRuntimeChannel();
|
||||
const channelRuntime: PluginRuntime["channel"] = {
|
||||
...baseChannelRuntime,
|
||||
runtimeContexts: {
|
||||
...baseChannelRuntime.runtimeContexts,
|
||||
register: () => ({
|
||||
dispose: () => {
|
||||
throw new Error("cleanup boom");
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
const startAccount = vi.fn(
|
||||
async ({ channelRuntime }: { channelRuntime?: PluginRuntime["channel"] }) => {
|
||||
channelRuntime?.runtimeContexts.register({
|
||||
channelId: "discord",
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
capability: "approval.native",
|
||||
context: { token: "tracked" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
installTestRegistry(createTestPlugin({ startAccount }));
|
||||
const manager = createManager({ channelRuntime });
|
||||
|
||||
await manager.startChannels();
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
|
||||
expect(startAccount.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("continues starting later channels after one startup failure", async () => {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { type ChannelId, getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { startChannelApprovalHandlerBootstrap } from "../infra/approval-handler-bootstrap.js";
|
||||
import { type BackoffPolicy, computeBackoff, sleepWithAbort } from "../infra/backoff.js";
|
||||
import { createTaskScopedChannelRuntime } from "../infra/channel-runtime-context.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
|
||||
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
@@ -108,7 +110,8 @@ type ChannelManagerOptions = {
|
||||
* because they can directly import internal modules from the monorepo.
|
||||
*
|
||||
* This field is optional - omitting it maintains backward compatibility
|
||||
* with existing channels.
|
||||
* with existing channels. When provided, it must be a real
|
||||
* `createPluginRuntime().channel` surface; partial stubs are not supported.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
@@ -131,7 +134,8 @@ type ChannelManagerOptions = {
|
||||
*
|
||||
* Use this when the caller wants to avoid instantiating the full plugin channel
|
||||
* runtime during gateway startup. The manager only needs the runtime surface once
|
||||
* a channel account actually starts.
|
||||
* a channel account actually starts. The resolved value must be a real
|
||||
* `createPluginRuntime().channel` surface.
|
||||
*/
|
||||
resolveChannelRuntime?: () => PluginRuntime["channel"];
|
||||
};
|
||||
@@ -296,8 +300,31 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
const abort = new AbortController();
|
||||
store.aborts.set(id, abort);
|
||||
let handedOffTask = false;
|
||||
const log = channelLogs[channelId];
|
||||
let scopedChannelRuntime: ReturnType<typeof createTaskScopedChannelRuntime> | null = null;
|
||||
let channelRuntimeForTask: PluginRuntime["channel"] | undefined;
|
||||
let stopApprovalBootstrap: () => Promise<void> = async () => {};
|
||||
const stopTaskScopedApprovalRuntime = async () => {
|
||||
const scopedRuntime = scopedChannelRuntime;
|
||||
scopedChannelRuntime = null;
|
||||
const stopBootstrap = stopApprovalBootstrap;
|
||||
stopApprovalBootstrap = async () => {};
|
||||
scopedRuntime?.dispose();
|
||||
await stopBootstrap();
|
||||
};
|
||||
const cleanupTaskScopedApprovalRuntime = async (label: string) => {
|
||||
try {
|
||||
await stopTaskScopedApprovalRuntime();
|
||||
} catch (error) {
|
||||
log.error?.(`[${id}] ${label}: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
scopedChannelRuntime = createTaskScopedChannelRuntime({
|
||||
channelRuntime: getChannelRuntime(),
|
||||
});
|
||||
channelRuntimeForTask = scopedChannelRuntime.channelRuntime;
|
||||
const account = plugin.config.resolveAccount(cfg, id);
|
||||
const enabled = plugin.config.isEnabled
|
||||
? plugin.config.isEnabled(account, cfg)
|
||||
@@ -348,6 +375,13 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
if (!preserveRestartAttempts) {
|
||||
restartAttempts.delete(rKey);
|
||||
}
|
||||
stopApprovalBootstrap = await startChannelApprovalHandlerBootstrap({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId: id,
|
||||
channelRuntime: channelRuntimeForTask,
|
||||
logger: log,
|
||||
});
|
||||
setRuntime(channelId, id, {
|
||||
accountId: id,
|
||||
enabled: true,
|
||||
@@ -358,27 +392,27 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
lastError: null,
|
||||
reconnectAttempts: preserveRestartAttempts ? (restartAttempts.get(rKey) ?? 0) : 0,
|
||||
});
|
||||
|
||||
const log = channelLogs[channelId];
|
||||
const resolvedChannelRuntime = getChannelRuntime();
|
||||
const task = startAccount({
|
||||
cfg,
|
||||
accountId: id,
|
||||
account,
|
||||
runtime: channelRuntimeEnvs[channelId],
|
||||
abortSignal: abort.signal,
|
||||
log,
|
||||
getStatus: () => getRuntime(channelId, id),
|
||||
setStatus: (next) => setRuntime(channelId, id, next),
|
||||
...(resolvedChannelRuntime ? { channelRuntime: resolvedChannelRuntime } : {}),
|
||||
});
|
||||
const trackedPromise = Promise.resolve(task)
|
||||
const task = Promise.resolve().then(() =>
|
||||
startAccount({
|
||||
cfg,
|
||||
accountId: id,
|
||||
account,
|
||||
runtime: channelRuntimeEnvs[channelId],
|
||||
abortSignal: abort.signal,
|
||||
log,
|
||||
getStatus: () => getRuntime(channelId, id),
|
||||
setStatus: (next) => setRuntime(channelId, id, next),
|
||||
...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}),
|
||||
}),
|
||||
);
|
||||
const trackedPromise = task
|
||||
.catch((err) => {
|
||||
const message = formatErrorMessage(err);
|
||||
setRuntime(channelId, id, { accountId: id, lastError: message });
|
||||
log.error?.(`[${id}] channel exited: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
.finally(async () => {
|
||||
await cleanupTaskScopedApprovalRuntime("channel cleanup failed");
|
||||
setRuntime(channelId, id, {
|
||||
accountId: id,
|
||||
running: false,
|
||||
@@ -438,11 +472,24 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
});
|
||||
handedOffTask = true;
|
||||
store.tasks.set(id, trackedPromise);
|
||||
} catch (error) {
|
||||
if (!handedOffTask) {
|
||||
setRuntime(channelId, id, {
|
||||
accountId: id,
|
||||
running: false,
|
||||
restartPending: false,
|
||||
lastError: formatErrorMessage(error),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
resolveStart?.();
|
||||
if (store.starting.get(id) === startGate) {
|
||||
store.starting.delete(id);
|
||||
}
|
||||
if (!handedOffTask) {
|
||||
await cleanupTaskScopedApprovalRuntime("channel startup cleanup failed");
|
||||
}
|
||||
if (!handedOffTask && store.aborts.get(id) === abort) {
|
||||
store.aborts.delete(id);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { GatewayRequestHandlers } from "./types.js";
|
||||
const APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS = {
|
||||
reason: "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE",
|
||||
} as const;
|
||||
const RESERVED_PLUGIN_APPROVAL_ID_PREFIX = "plugin:";
|
||||
|
||||
type ExecApprovalIosPushDelivery = {
|
||||
handleRequested?: (request: ExecApprovalRequest) => Promise<boolean>;
|
||||
@@ -167,6 +168,17 @@ export function createExecApprovalHandlers(
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "command is required"));
|
||||
return;
|
||||
}
|
||||
if (explicitId?.startsWith(RESERVED_PLUGIN_APPROVAL_ID_PREFIX)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`approval ids starting with ${RESERVED_PLUGIN_APPROVAL_ID_PREFIX} are reserved`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
host === "node" &&
|
||||
(!Array.isArray(effectiveCommandArgv) || effectiveCommandArgv.length === 0)
|
||||
|
||||
@@ -989,6 +989,26 @@ describe("exec approval handlers", () => {
|
||||
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
});
|
||||
|
||||
it("rejects explicit approval ids with the reserved plugin prefix", async () => {
|
||||
const { handlers, respond, context } = createExecApprovalFixture();
|
||||
|
||||
await requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: { id: "plugin:approval-123", host: "gateway" },
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "approval ids starting with plugin: are reserved",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts unique short approval id prefixes", async () => {
|
||||
const manager = new ExecApprovalManager();
|
||||
const handlers = createExecApprovalHandlers(manager);
|
||||
|
||||
143
src/infra/approval-gateway-resolver.test.ts
Normal file
143
src/infra/approval-gateway-resolver.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveApprovalOverGateway } from "./approval-gateway-resolver.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
createOperatorApprovalsGatewayClient: vi.fn(),
|
||||
clientStart: vi.fn(),
|
||||
clientStop: vi.fn(),
|
||||
clientStopAndWait: vi.fn(),
|
||||
clientRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/operator-approvals-client.js", () => ({
|
||||
createOperatorApprovalsGatewayClient: hoisted.createOperatorApprovalsGatewayClient,
|
||||
}));
|
||||
|
||||
function createGatewayClient(params: {
|
||||
stopAndWaitRejects?: boolean;
|
||||
requestImpl?: typeof hoisted.clientRequest;
|
||||
}) {
|
||||
const request = params.requestImpl ?? hoisted.clientRequest;
|
||||
return {
|
||||
start: () => {
|
||||
hoisted.clientStart();
|
||||
},
|
||||
stop: hoisted.clientStop,
|
||||
stopAndWait: params.stopAndWaitRejects
|
||||
? vi.fn(async () => {
|
||||
hoisted.clientStopAndWait();
|
||||
throw new Error("close failed");
|
||||
})
|
||||
: vi.fn(async () => {
|
||||
hoisted.clientStopAndWait();
|
||||
}),
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveApprovalOverGateway", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.clientStart.mockReset();
|
||||
hoisted.clientStop.mockReset();
|
||||
hoisted.clientStopAndWait.mockReset();
|
||||
hoisted.clientRequest.mockReset().mockResolvedValue({ ok: true });
|
||||
hoisted.createOperatorApprovalsGatewayClient.mockReset().mockImplementation(async (params) => {
|
||||
const client = createGatewayClient({});
|
||||
queueMicrotask(() => {
|
||||
params.onHelloOk?.({} as never);
|
||||
});
|
||||
return client;
|
||||
});
|
||||
});
|
||||
|
||||
it("routes exec approvals through exec.approval.resolve", async () => {
|
||||
await resolveApprovalOverGateway({
|
||||
cfg: { gateway: { auth: { token: "cfg-token" } } } as never,
|
||||
approvalId: "approval-1",
|
||||
decision: "allow-once",
|
||||
gatewayUrl: "ws://gateway.example.test",
|
||||
clientDisplayName: "Discord approval (default)",
|
||||
});
|
||||
|
||||
expect(hoisted.createOperatorApprovalsGatewayClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: { gateway: { auth: { token: "cfg-token" } } },
|
||||
gatewayUrl: "ws://gateway.example.test",
|
||||
clientDisplayName: "Discord approval (default)",
|
||||
}),
|
||||
);
|
||||
expect(hoisted.clientStart).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.clientRequest).toHaveBeenCalledWith("exec.approval.resolve", {
|
||||
id: "approval-1",
|
||||
decision: "allow-once",
|
||||
});
|
||||
expect(hoisted.clientStopAndWait).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes plugin approvals through plugin.approval.resolve", async () => {
|
||||
await resolveApprovalOverGateway({
|
||||
cfg: {} as never,
|
||||
approvalId: "plugin:approval-1",
|
||||
decision: "deny",
|
||||
});
|
||||
|
||||
expect(hoisted.clientRequest).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.clientRequest).toHaveBeenCalledWith("plugin.approval.resolve", {
|
||||
id: "plugin:approval-1",
|
||||
decision: "deny",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to plugin.approval.resolve only for not-found exec approvals when enabled", async () => {
|
||||
const notFoundError = Object.assign(new Error("unknown or expired approval id"), {
|
||||
gatewayCode: "APPROVAL_NOT_FOUND",
|
||||
});
|
||||
hoisted.clientRequest.mockRejectedValueOnce(notFoundError).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await resolveApprovalOverGateway({
|
||||
cfg: {} as never,
|
||||
approvalId: "approval-1",
|
||||
decision: "allow-always",
|
||||
allowPluginFallback: true,
|
||||
});
|
||||
|
||||
expect(hoisted.clientRequest.mock.calls).toEqual([
|
||||
["exec.approval.resolve", { id: "approval-1", decision: "allow-always" }],
|
||||
["plugin.approval.resolve", { id: "approval-1", decision: "allow-always" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not fall back for non-not-found exec approval failures", async () => {
|
||||
hoisted.clientRequest.mockRejectedValueOnce(new Error("permission denied"));
|
||||
|
||||
await expect(
|
||||
resolveApprovalOverGateway({
|
||||
cfg: {} as never,
|
||||
approvalId: "approval-1",
|
||||
decision: "deny",
|
||||
allowPluginFallback: true,
|
||||
}),
|
||||
).rejects.toThrow("permission denied");
|
||||
|
||||
expect(hoisted.clientRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to stop when stopAndWait rejects", async () => {
|
||||
hoisted.createOperatorApprovalsGatewayClient.mockReset().mockImplementation(async (params) => {
|
||||
const client = createGatewayClient({ stopAndWaitRejects: true });
|
||||
queueMicrotask(() => {
|
||||
params.onHelloOk?.({} as never);
|
||||
});
|
||||
return client;
|
||||
});
|
||||
|
||||
await resolveApprovalOverGateway({
|
||||
cfg: {} as never,
|
||||
approvalId: "approval-1",
|
||||
decision: "allow-once",
|
||||
});
|
||||
|
||||
expect(hoisted.clientStopAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.clientStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
79
src/infra/approval-gateway-resolver.ts
Normal file
79
src/infra/approval-gateway-resolver.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js";
|
||||
import { isApprovalNotFoundError } from "./approval-errors.js";
|
||||
import type { ExecApprovalDecision } from "./exec-approvals.js";
|
||||
|
||||
export type ResolveApprovalOverGatewayParams = {
|
||||
cfg: OpenClawConfig;
|
||||
approvalId: string;
|
||||
decision: ExecApprovalDecision;
|
||||
senderId?: string | null;
|
||||
allowPluginFallback?: boolean;
|
||||
gatewayUrl?: string;
|
||||
clientDisplayName?: string;
|
||||
};
|
||||
|
||||
export async function resolveApprovalOverGateway(
|
||||
params: ResolveApprovalOverGatewayParams,
|
||||
): Promise<void> {
|
||||
let readySettled = false;
|
||||
let resolveReady!: () => void;
|
||||
let rejectReady!: (err: unknown) => void;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
const markReady = () => {
|
||||
if (readySettled) {
|
||||
return;
|
||||
}
|
||||
readySettled = true;
|
||||
resolveReady();
|
||||
};
|
||||
const failReady = (err: unknown) => {
|
||||
if (readySettled) {
|
||||
return;
|
||||
}
|
||||
readySettled = true;
|
||||
rejectReady(err);
|
||||
};
|
||||
|
||||
const gatewayClient = await createOperatorApprovalsGatewayClient({
|
||||
config: params.cfg,
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
clientDisplayName:
|
||||
params.clientDisplayName ?? `Approval (${params.senderId?.trim() || "unknown"})`,
|
||||
onHelloOk: markReady,
|
||||
onConnectError: failReady,
|
||||
onClose: (code, reason) => {
|
||||
failReady(new Error(`gateway closed (${code}): ${reason}`));
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
gatewayClient.start();
|
||||
await ready;
|
||||
const requestResolve = async (method: "exec.approval.resolve" | "plugin.approval.resolve") => {
|
||||
await gatewayClient.request(method, {
|
||||
id: params.approvalId,
|
||||
decision: params.decision,
|
||||
});
|
||||
};
|
||||
if (params.approvalId.startsWith("plugin:")) {
|
||||
await requestResolve("plugin.approval.resolve");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await requestResolve("exec.approval.resolve");
|
||||
} catch (err) {
|
||||
if (!params.allowPluginFallback || !isApprovalNotFoundError(err)) {
|
||||
throw err;
|
||||
}
|
||||
await requestResolve("plugin.approval.resolve");
|
||||
}
|
||||
} finally {
|
||||
await gatewayClient.stopAndWait().catch(() => {
|
||||
gatewayClient.stop();
|
||||
});
|
||||
}
|
||||
}
|
||||
406
src/infra/approval-handler-bootstrap.test.ts
Normal file
406
src/infra/approval-handler-bootstrap.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeChannel } from "../plugins/runtime/runtime-channel.js";
|
||||
import { startChannelApprovalHandlerBootstrap } from "./approval-handler-bootstrap.js";
|
||||
|
||||
const { createChannelApprovalHandlerFromCapability } = vi.hoisted(() => ({
|
||||
createChannelApprovalHandlerFromCapability: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./approval-handler-runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./approval-handler-runtime.js")>(
|
||||
"./approval-handler-runtime.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
createChannelApprovalHandlerFromCapability,
|
||||
};
|
||||
});
|
||||
|
||||
describe("startChannelApprovalHandlerBootstrap", () => {
|
||||
beforeEach(() => {
|
||||
createChannelApprovalHandlerFromCapability.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const flushTransitions = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
it("starts and stops the shared approval handler from runtime context registration", async () => {
|
||||
const channelRuntime = createRuntimeChannel();
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
createChannelApprovalHandlerFromCapability.mockResolvedValue({
|
||||
start,
|
||||
stop,
|
||||
});
|
||||
|
||||
const cleanup = await startChannelApprovalHandlerBootstrap({
|
||||
plugin: {
|
||||
id: "slack",
|
||||
meta: { label: "Slack" },
|
||||
approvalCapability: {
|
||||
nativeRuntime: {
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn(),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelRuntime,
|
||||
});
|
||||
|
||||
const lease = channelRuntime.runtimeContexts.register({
|
||||
channelId: "slack",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { app: { ok: true } },
|
||||
});
|
||||
await flushTransitions();
|
||||
|
||||
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalled();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
|
||||
lease.dispose();
|
||||
await flushTransitions();
|
||||
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("starts immediately when the runtime context was already registered", async () => {
|
||||
const channelRuntime = createRuntimeChannel();
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
createChannelApprovalHandlerFromCapability.mockResolvedValue({
|
||||
start,
|
||||
stop,
|
||||
});
|
||||
|
||||
const lease = channelRuntime.runtimeContexts.register({
|
||||
channelId: "slack",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { app: { ok: true } },
|
||||
});
|
||||
|
||||
const cleanup = await startChannelApprovalHandlerBootstrap({
|
||||
plugin: {
|
||||
id: "slack",
|
||||
meta: { label: "Slack" },
|
||||
approvalCapability: {
|
||||
nativeRuntime: {
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn(),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelRuntime,
|
||||
});
|
||||
|
||||
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(1);
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
|
||||
await cleanup();
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
lease.dispose();
|
||||
});
|
||||
|
||||
it("does not start a handler after the runtime context is unregistered mid-boot", async () => {
|
||||
const channelRuntime = createRuntimeChannel();
|
||||
let resolveRuntime:
|
||||
| ((value: { start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn> }) => void)
|
||||
| undefined;
|
||||
const runtimePromise = new Promise<{
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
}>((resolve) => {
|
||||
resolveRuntime = resolve;
|
||||
});
|
||||
createChannelApprovalHandlerFromCapability.mockReturnValue(runtimePromise);
|
||||
|
||||
const cleanup = await startChannelApprovalHandlerBootstrap({
|
||||
plugin: {
|
||||
id: "slack",
|
||||
meta: { label: "Slack" },
|
||||
approvalCapability: {
|
||||
nativeRuntime: {
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn(),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelRuntime,
|
||||
});
|
||||
|
||||
const lease = channelRuntime.runtimeContexts.register({
|
||||
channelId: "slack",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { app: { ok: true } },
|
||||
});
|
||||
await flushTransitions();
|
||||
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
lease.dispose();
|
||||
resolveRuntime?.({ start, stop });
|
||||
await flushTransitions();
|
||||
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("restarts the shared approval handler when the runtime context is replaced", async () => {
|
||||
const channelRuntime = createRuntimeChannel();
|
||||
const startFirst = vi.fn().mockResolvedValue(undefined);
|
||||
const stopFirst = vi.fn().mockResolvedValue(undefined);
|
||||
const startSecond = vi.fn().mockResolvedValue(undefined);
|
||||
const stopSecond = vi.fn().mockResolvedValue(undefined);
|
||||
createChannelApprovalHandlerFromCapability
|
||||
.mockResolvedValueOnce({
|
||||
start: startFirst,
|
||||
stop: stopFirst,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
start: startSecond,
|
||||
stop: stopSecond,
|
||||
});
|
||||
|
||||
const cleanup = await startChannelApprovalHandlerBootstrap({
|
||||
plugin: {
|
||||
id: "slack",
|
||||
meta: { label: "Slack" },
|
||||
approvalCapability: {
|
||||
nativeRuntime: {
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn(),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelRuntime,
|
||||
});
|
||||
|
||||
const firstLease = channelRuntime.runtimeContexts.register({
|
||||
channelId: "slack",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { app: { ok: "first" } },
|
||||
});
|
||||
await flushTransitions();
|
||||
|
||||
const secondLease = channelRuntime.runtimeContexts.register({
|
||||
channelId: "slack",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { app: { ok: "second" } },
|
||||
});
|
||||
await flushTransitions();
|
||||
|
||||
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(2);
|
||||
expect(startFirst).toHaveBeenCalledTimes(1);
|
||||
expect(stopFirst).toHaveBeenCalledTimes(1);
|
||||
expect(startSecond).toHaveBeenCalledTimes(1);
|
||||
|
||||
secondLease.dispose();
|
||||
await flushTransitions();
|
||||
|
||||
expect(stopSecond).toHaveBeenCalledTimes(1);
|
||||
|
||||
firstLease.dispose();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("retries registered-context startup failures until the handler starts", async () => {
|
||||
vi.useFakeTimers();
|
||||
const channelRuntime = createRuntimeChannel();
|
||||
const start = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const logger = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
child: vi.fn(),
|
||||
isEnabled: vi.fn().mockReturnValue(true),
|
||||
isVerboseEnabled: vi.fn().mockReturnValue(false),
|
||||
verbose: vi.fn(),
|
||||
};
|
||||
createChannelApprovalHandlerFromCapability
|
||||
.mockResolvedValueOnce({ start, stop })
|
||||
.mockResolvedValueOnce({ start, stop });
|
||||
|
||||
const cleanup = await startChannelApprovalHandlerBootstrap({
|
||||
plugin: {
|
||||
id: "slack",
|
||||
meta: { label: "Slack" },
|
||||
approvalCapability: {
|
||||
nativeRuntime: {
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn(),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelRuntime,
|
||||
logger: logger as never,
|
||||
});
|
||||
|
||||
channelRuntime.runtimeContexts.register({
|
||||
channelId: "slack",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { app: { ok: true } },
|
||||
});
|
||||
await flushTransitions();
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushTransitions();
|
||||
|
||||
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(2);
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"failed to start native approval handler: Error: boom",
|
||||
);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("does not let a stale retry stop a newer active handler", async () => {
|
||||
vi.useFakeTimers();
|
||||
const channelRuntime = createRuntimeChannel();
|
||||
const firstStart = vi.fn().mockRejectedValueOnce(new Error("boom"));
|
||||
const firstStop = vi.fn().mockResolvedValue(undefined);
|
||||
const secondStart = vi.fn().mockResolvedValue(undefined);
|
||||
const secondStop = vi.fn().mockResolvedValue(undefined);
|
||||
createChannelApprovalHandlerFromCapability
|
||||
.mockResolvedValueOnce({ start: firstStart, stop: firstStop })
|
||||
.mockResolvedValueOnce({ start: secondStart, stop: secondStop })
|
||||
.mockResolvedValueOnce({ start: secondStart, stop: secondStop });
|
||||
|
||||
const cleanup = await startChannelApprovalHandlerBootstrap({
|
||||
plugin: {
|
||||
id: "slack",
|
||||
meta: { label: "Slack" },
|
||||
approvalCapability: {
|
||||
nativeRuntime: {
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn(),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelRuntime,
|
||||
});
|
||||
|
||||
channelRuntime.runtimeContexts.register({
|
||||
channelId: "slack",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { app: { ok: "first" } },
|
||||
});
|
||||
await flushTransitions();
|
||||
expect(firstStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
channelRuntime.runtimeContexts.register({
|
||||
channelId: "slack",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { app: { ok: "second" } },
|
||||
});
|
||||
await flushTransitions();
|
||||
expect(secondStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushTransitions();
|
||||
|
||||
expect(firstStop).toHaveBeenCalledTimes(1);
|
||||
expect(secondStart).toHaveBeenCalledTimes(1);
|
||||
expect(secondStop).not.toHaveBeenCalled();
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
167
src/infra/approval-handler-bootstrap.ts
Normal file
167
src/infra/approval-handler-bootstrap.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { resolveChannelApprovalCapability } from "../channels/plugins/approvals.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
import {
|
||||
CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
|
||||
createChannelApprovalHandlerFromCapability,
|
||||
type ChannelApprovalHandler,
|
||||
} from "./approval-handler-runtime.js";
|
||||
import {
|
||||
getChannelRuntimeContext,
|
||||
watchChannelRuntimeContexts,
|
||||
} from "./channel-runtime-context.js";
|
||||
|
||||
type ApprovalBootstrapHandler = ChannelApprovalHandler;
|
||||
const APPROVAL_HANDLER_BOOTSTRAP_RETRY_MS = 1_000;
|
||||
|
||||
export async function startChannelApprovalHandlerBootstrap(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "meta" | "approvalCapability">;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
channelRuntime?: PluginRuntime["channel"];
|
||||
logger?: ReturnType<typeof createSubsystemLogger>;
|
||||
}): Promise<() => Promise<void>> {
|
||||
const capability = resolveChannelApprovalCapability(params.plugin);
|
||||
if (!capability?.nativeRuntime || !params.channelRuntime) {
|
||||
return async () => {};
|
||||
}
|
||||
|
||||
const channelLabel = params.plugin.meta.label || params.plugin.id;
|
||||
const logger = params.logger ?? createSubsystemLogger(`${params.plugin.id}/approval-bootstrap`);
|
||||
let activeGeneration = 0;
|
||||
let activeHandler: ApprovalBootstrapHandler | null = null;
|
||||
let retryTimer: NodeJS.Timeout | null = null;
|
||||
const invalidateActiveHandler = () => {
|
||||
activeGeneration += 1;
|
||||
};
|
||||
const clearRetryTimer = () => {
|
||||
if (!retryTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
};
|
||||
|
||||
const stopHandler = async () => {
|
||||
const handler = activeHandler;
|
||||
activeHandler = null;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
await handler.stop();
|
||||
};
|
||||
|
||||
const startHandlerForContext = async (context: unknown, generation: number) => {
|
||||
if (generation !== activeGeneration) {
|
||||
return;
|
||||
}
|
||||
await stopHandler();
|
||||
if (generation !== activeGeneration) {
|
||||
return;
|
||||
}
|
||||
const handler = await createChannelApprovalHandlerFromCapability({
|
||||
capability,
|
||||
label: `${params.plugin.id}/native-approvals`,
|
||||
clientDisplayName: `${channelLabel} Native Approvals (${params.accountId})`,
|
||||
channel: params.plugin.id,
|
||||
channelLabel,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
context,
|
||||
});
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
if (generation !== activeGeneration) {
|
||||
await handler.stop().catch(() => {});
|
||||
return;
|
||||
}
|
||||
activeHandler = handler as ApprovalBootstrapHandler;
|
||||
try {
|
||||
await handler.start();
|
||||
} catch (error) {
|
||||
if (activeHandler === handler) {
|
||||
activeHandler = null;
|
||||
}
|
||||
await handler.stop().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const spawn = (label: string, promise: Promise<void>) => {
|
||||
void promise.catch((error) => {
|
||||
logger.error(`${label}: ${String(error)}`);
|
||||
});
|
||||
};
|
||||
const scheduleRetryForContext = (context: unknown, generation: number) => {
|
||||
if (generation !== activeGeneration) {
|
||||
return;
|
||||
}
|
||||
clearRetryTimer();
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
if (generation !== activeGeneration) {
|
||||
return;
|
||||
}
|
||||
spawn(
|
||||
"failed to retry native approval handler",
|
||||
startHandlerForRegisteredContext(context, generation),
|
||||
);
|
||||
}, APPROVAL_HANDLER_BOOTSTRAP_RETRY_MS);
|
||||
retryTimer.unref?.();
|
||||
};
|
||||
const startHandlerForRegisteredContext = async (context: unknown, generation: number) => {
|
||||
try {
|
||||
await startHandlerForContext(context, generation);
|
||||
} catch (error) {
|
||||
if (generation === activeGeneration) {
|
||||
logger.error(`failed to start native approval handler: ${String(error)}`);
|
||||
scheduleRetryForContext(context, generation);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe =
|
||||
watchChannelRuntimeContexts({
|
||||
channelRuntime: params.channelRuntime,
|
||||
channelId: params.plugin.id,
|
||||
accountId: params.accountId,
|
||||
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
|
||||
onEvent: (event) => {
|
||||
if (event.type === "registered") {
|
||||
clearRetryTimer();
|
||||
invalidateActiveHandler();
|
||||
const generation = activeGeneration;
|
||||
spawn(
|
||||
"failed to start native approval handler",
|
||||
startHandlerForRegisteredContext(event.context, generation),
|
||||
);
|
||||
return;
|
||||
}
|
||||
clearRetryTimer();
|
||||
invalidateActiveHandler();
|
||||
spawn("failed to stop native approval handler", stopHandler());
|
||||
},
|
||||
}) ?? (() => {});
|
||||
|
||||
const existingContext = getChannelRuntimeContext({
|
||||
channelRuntime: params.channelRuntime,
|
||||
channelId: params.plugin.id,
|
||||
accountId: params.accountId,
|
||||
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
|
||||
});
|
||||
if (existingContext !== undefined) {
|
||||
clearRetryTimer();
|
||||
invalidateActiveHandler();
|
||||
await startHandlerForContext(existingContext, activeGeneration);
|
||||
}
|
||||
|
||||
return async () => {
|
||||
unsubscribe();
|
||||
clearRetryTimer();
|
||||
invalidateActiveHandler();
|
||||
await stopHandler();
|
||||
};
|
||||
}
|
||||
462
src/infra/approval-handler-runtime.test.ts
Normal file
462
src/infra/approval-handler-runtime.test.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createChannelApprovalHandlerFromCapability,
|
||||
createLazyChannelApprovalNativeRuntimeAdapter,
|
||||
} from "./approval-handler-runtime.js";
|
||||
|
||||
describe("createChannelApprovalHandlerFromCapability", () => {
|
||||
it("returns null when the capability does not expose a native runtime", async () => {
|
||||
await expect(
|
||||
createChannelApprovalHandlerFromCapability({
|
||||
capability: {},
|
||||
label: "test/approval-handler",
|
||||
clientDisplayName: "Test Approval Handler",
|
||||
channel: "test",
|
||||
channelLabel: "Test",
|
||||
cfg: {} as never,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns a runtime when the capability exposes a native runtime", async () => {
|
||||
const runtime = await createChannelApprovalHandlerFromCapability({
|
||||
capability: {
|
||||
nativeRuntime: {
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn(),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
label: "test/approval-handler",
|
||||
clientDisplayName: "Test Approval Handler",
|
||||
channel: "test",
|
||||
channelLabel: "Test",
|
||||
cfg: { channels: {} } as never,
|
||||
});
|
||||
|
||||
expect(runtime).not.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves the original request and resolved approval kind when stop-time cleanup unbinds", async () => {
|
||||
const unbindPending = vi.fn();
|
||||
const runtime = await createChannelApprovalHandlerFromCapability({
|
||||
capability: {
|
||||
native: {
|
||||
describeDeliveryCapabilities: vi.fn().mockReturnValue({
|
||||
enabled: true,
|
||||
preferredSurface: "origin",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: false,
|
||||
notifyOriginWhenDmOnly: false,
|
||||
}),
|
||||
resolveOriginTarget: vi.fn().mockReturnValue({ to: "origin-chat" }),
|
||||
},
|
||||
nativeRuntime: {
|
||||
resolveApprovalKind: vi.fn().mockReturnValue("plugin"),
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn().mockResolvedValue({
|
||||
dedupeKey: "origin-chat",
|
||||
target: { to: "origin-chat" },
|
||||
}),
|
||||
deliverPending: vi.fn().mockResolvedValue({ messageId: "1" }),
|
||||
},
|
||||
interactions: {
|
||||
bindPending: vi.fn().mockResolvedValue({ bindingId: "bound" }),
|
||||
unbindPending,
|
||||
},
|
||||
},
|
||||
},
|
||||
label: "test/approval-handler",
|
||||
clientDisplayName: "Test Approval Handler",
|
||||
channel: "test",
|
||||
channelLabel: "Test",
|
||||
cfg: { channels: {} } as never,
|
||||
});
|
||||
|
||||
expect(runtime).not.toBeNull();
|
||||
const request = {
|
||||
id: "custom:1",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
request: {
|
||||
turnSourceChannel: "test",
|
||||
turnSourceTo: "origin-chat",
|
||||
},
|
||||
} as never;
|
||||
|
||||
await runtime?.handleRequested(request);
|
||||
await runtime?.stop();
|
||||
|
||||
expect(unbindPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
request,
|
||||
approvalKind: "plugin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("unbinds and finalizes every prior pending delivery when the same approval id is requested again", async () => {
|
||||
const unbindPending = vi.fn();
|
||||
const buildResolvedResult = vi.fn().mockResolvedValue({ kind: "leave" });
|
||||
const runtime = await createChannelApprovalHandlerFromCapability({
|
||||
capability: {
|
||||
native: {
|
||||
describeDeliveryCapabilities: vi.fn().mockReturnValue({
|
||||
enabled: true,
|
||||
preferredSurface: "origin",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: false,
|
||||
notifyOriginWhenDmOnly: false,
|
||||
}),
|
||||
resolveOriginTarget: vi.fn().mockReturnValue({ to: "origin-chat" }),
|
||||
},
|
||||
nativeRuntime: {
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
|
||||
buildResolvedResult,
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn().mockResolvedValue({
|
||||
dedupeKey: "origin-chat",
|
||||
target: { to: "origin-chat" },
|
||||
}),
|
||||
deliverPending: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "1" })
|
||||
.mockResolvedValueOnce({ messageId: "2" }),
|
||||
},
|
||||
interactions: {
|
||||
bindPending: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ bindingId: "bound-1" })
|
||||
.mockResolvedValueOnce({ bindingId: "bound-2" }),
|
||||
unbindPending,
|
||||
},
|
||||
},
|
||||
},
|
||||
label: "test/approval-handler",
|
||||
clientDisplayName: "Test Approval Handler",
|
||||
channel: "test",
|
||||
channelLabel: "Test",
|
||||
cfg: { channels: {} } as never,
|
||||
});
|
||||
|
||||
expect(runtime).not.toBeNull();
|
||||
const request = {
|
||||
id: "exec:1",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "test",
|
||||
turnSourceTo: "origin-chat",
|
||||
},
|
||||
} as never;
|
||||
|
||||
await runtime?.handleRequested(request);
|
||||
await runtime?.handleRequested(request);
|
||||
await runtime?.handleResolved({
|
||||
id: "exec:1",
|
||||
decision: "approved",
|
||||
resolvedBy: "operator",
|
||||
} as never);
|
||||
|
||||
expect(unbindPending).toHaveBeenCalledTimes(2);
|
||||
expect(unbindPending).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
entry: { messageId: "1" },
|
||||
binding: { bindingId: "bound-1" },
|
||||
request,
|
||||
}),
|
||||
);
|
||||
expect(unbindPending).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
entry: { messageId: "2" },
|
||||
binding: { bindingId: "bound-2" },
|
||||
request,
|
||||
}),
|
||||
);
|
||||
expect(buildResolvedResult).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("continues finalizing later entries when one resolved entry cleanup throws", async () => {
|
||||
const unbindPending = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("unbind failed"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const buildResolvedResult = vi.fn().mockResolvedValue({ kind: "leave" });
|
||||
const runtime = await createChannelApprovalHandlerFromCapability({
|
||||
capability: {
|
||||
native: {
|
||||
describeDeliveryCapabilities: vi.fn().mockReturnValue({
|
||||
enabled: true,
|
||||
preferredSurface: "origin",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: false,
|
||||
notifyOriginWhenDmOnly: false,
|
||||
}),
|
||||
resolveOriginTarget: vi.fn().mockReturnValue({ to: "origin-chat" }),
|
||||
},
|
||||
nativeRuntime: {
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
|
||||
buildResolvedResult,
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn().mockResolvedValue({
|
||||
dedupeKey: "origin-chat",
|
||||
target: { to: "origin-chat" },
|
||||
}),
|
||||
deliverPending: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "1" })
|
||||
.mockResolvedValueOnce({ messageId: "2" }),
|
||||
},
|
||||
interactions: {
|
||||
bindPending: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ bindingId: "bound-1" })
|
||||
.mockResolvedValueOnce({ bindingId: "bound-2" }),
|
||||
unbindPending,
|
||||
},
|
||||
},
|
||||
},
|
||||
label: "test/approval-handler",
|
||||
clientDisplayName: "Test Approval Handler",
|
||||
channel: "test",
|
||||
channelLabel: "Test",
|
||||
cfg: { channels: {} } as never,
|
||||
});
|
||||
|
||||
const request = {
|
||||
id: "exec:2",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "test",
|
||||
turnSourceTo: "origin-chat",
|
||||
},
|
||||
} as never;
|
||||
|
||||
await runtime?.handleRequested(request);
|
||||
await runtime?.handleRequested(request);
|
||||
await expect(
|
||||
runtime?.handleResolved({
|
||||
id: "exec:2",
|
||||
decision: "approved",
|
||||
resolvedBy: "operator",
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(unbindPending).toHaveBeenCalledTimes(2);
|
||||
expect(buildResolvedResult).toHaveBeenCalledTimes(1);
|
||||
expect(buildResolvedResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entry: { messageId: "2" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues stop-time unbind cleanup when one binding throws", async () => {
|
||||
const unbindPending = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("unbind failed"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const runtime = await createChannelApprovalHandlerFromCapability({
|
||||
capability: {
|
||||
native: {
|
||||
describeDeliveryCapabilities: vi.fn().mockReturnValue({
|
||||
enabled: true,
|
||||
preferredSurface: "origin",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: false,
|
||||
notifyOriginWhenDmOnly: false,
|
||||
}),
|
||||
resolveOriginTarget: vi.fn().mockReturnValue({ to: "origin-chat" }),
|
||||
},
|
||||
nativeRuntime: {
|
||||
availability: {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn().mockResolvedValue({
|
||||
dedupeKey: "origin-chat",
|
||||
target: { to: "origin-chat" },
|
||||
}),
|
||||
deliverPending: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "1" })
|
||||
.mockResolvedValueOnce({ messageId: "2" }),
|
||||
},
|
||||
interactions: {
|
||||
bindPending: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ bindingId: "bound-1" })
|
||||
.mockResolvedValueOnce({ bindingId: "bound-2" }),
|
||||
unbindPending,
|
||||
},
|
||||
},
|
||||
},
|
||||
label: "test/approval-handler",
|
||||
clientDisplayName: "Test Approval Handler",
|
||||
channel: "test",
|
||||
channelLabel: "Test",
|
||||
cfg: { channels: {} } as never,
|
||||
});
|
||||
|
||||
const request = {
|
||||
id: "exec:stop-1",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "test",
|
||||
turnSourceTo: "origin-chat",
|
||||
},
|
||||
} as never;
|
||||
|
||||
await runtime?.handleRequested(request);
|
||||
await runtime?.handleRequested({
|
||||
...request,
|
||||
id: "exec:stop-2",
|
||||
});
|
||||
|
||||
await expect(runtime?.stop()).resolves.toBeUndefined();
|
||||
expect(unbindPending).toHaveBeenCalledTimes(2);
|
||||
await expect(runtime?.stop()).resolves.toBeUndefined();
|
||||
expect(unbindPending).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLazyChannelApprovalNativeRuntimeAdapter", () => {
|
||||
it("loads the runtime lazily and reuses the loaded adapter", async () => {
|
||||
const explicitIsConfigured = vi.fn().mockReturnValue(true);
|
||||
const explicitShouldHandle = vi.fn().mockReturnValue(false);
|
||||
const buildPendingPayload = vi.fn().mockResolvedValue({ text: "pending" });
|
||||
const load = vi.fn().mockResolvedValue({
|
||||
availability: {
|
||||
isConfigured: vi.fn(),
|
||||
shouldHandle: vi.fn(),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload,
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
});
|
||||
const adapter = createLazyChannelApprovalNativeRuntimeAdapter({
|
||||
eventKinds: ["exec"],
|
||||
isConfigured: explicitIsConfigured,
|
||||
shouldHandle: explicitShouldHandle,
|
||||
load,
|
||||
});
|
||||
const cfg = { channels: {} } as never;
|
||||
const request = { id: "exec:1" } as never;
|
||||
const view = {} as never;
|
||||
|
||||
expect(adapter.eventKinds).toEqual(["exec"]);
|
||||
expect(adapter.availability.isConfigured({ cfg })).toBe(true);
|
||||
expect(adapter.availability.shouldHandle({ cfg, request })).toBe(false);
|
||||
await expect(
|
||||
adapter.presentation.buildPendingPayload({
|
||||
cfg,
|
||||
request,
|
||||
approvalKind: "exec",
|
||||
nowMs: 1,
|
||||
view,
|
||||
}),
|
||||
).resolves.toEqual({ text: "pending" });
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
expect(explicitIsConfigured).toHaveBeenCalledWith({ cfg });
|
||||
expect(explicitShouldHandle).toHaveBeenCalledWith({ cfg, request });
|
||||
expect(buildPendingPayload).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
request,
|
||||
approvalKind: "exec",
|
||||
nowMs: 1,
|
||||
view,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps observe hooks synchronous and only uses the already-loaded runtime", async () => {
|
||||
const onDelivered = vi.fn();
|
||||
const load = vi.fn().mockResolvedValue({
|
||||
availability: {
|
||||
isConfigured: vi.fn(),
|
||||
shouldHandle: vi.fn(),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
observe: {
|
||||
onDelivered,
|
||||
},
|
||||
});
|
||||
const adapter = createLazyChannelApprovalNativeRuntimeAdapter({
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
shouldHandle: vi.fn().mockReturnValue(true),
|
||||
load,
|
||||
});
|
||||
|
||||
adapter.observe?.onDelivered?.({ request: { id: "exec:1" } } as never);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
expect(onDelivered).not.toHaveBeenCalled();
|
||||
|
||||
await adapter.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
request: { id: "exec:1" } as never,
|
||||
approvalKind: "exec",
|
||||
nowMs: 1,
|
||||
view: {} as never,
|
||||
});
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
|
||||
adapter.observe?.onDelivered?.({ request: { id: "exec:1" } } as never);
|
||||
expect(onDelivered).toHaveBeenCalledWith({ request: { id: "exec:1" } });
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
1072
src/infra/approval-handler-runtime.ts
Normal file
1072
src/infra/approval-handler-runtime.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import type {
|
||||
ChannelApprovalNativeTarget,
|
||||
} from "../channels/plugins/types.adapters.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { buildChannelApprovalNativeTargetKey } from "./approval-native-target-key.js";
|
||||
import type { ExecApprovalRequest } from "./exec-approvals.js";
|
||||
import type { PluginApprovalRequest } from "./plugin-approvals.js";
|
||||
|
||||
@@ -22,17 +23,13 @@ export type ChannelApprovalNativeDeliveryPlan = {
|
||||
notifyOriginWhenDmOnly: boolean;
|
||||
};
|
||||
|
||||
function buildTargetKey(target: ChannelApprovalNativeTarget): string {
|
||||
return `${target.to}:${target.threadId ?? ""}`;
|
||||
}
|
||||
|
||||
function dedupeTargets(
|
||||
targets: ChannelApprovalNativePlannedTarget[],
|
||||
): ChannelApprovalNativePlannedTarget[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: ChannelApprovalNativePlannedTarget[] = [];
|
||||
for (const target of targets) {
|
||||
const key = buildTargetKey(target.target);
|
||||
const key = buildChannelApprovalNativeTargetKey(target.target);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
212
src/infra/approval-native-route-coordinator.test.ts
Normal file
212
src/infra/approval-native-route-coordinator.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearApprovalNativeRouteStateForTest,
|
||||
createApprovalNativeRouteReporter,
|
||||
} from "./approval-native-route-coordinator.js";
|
||||
|
||||
afterEach(() => {
|
||||
clearApprovalNativeRouteStateForTest();
|
||||
});
|
||||
|
||||
function createGatewayRequestMock() {
|
||||
return vi.fn(async <T = unknown>() => ({ ok: true }) as T);
|
||||
}
|
||||
|
||||
describe("createApprovalNativeRouteReporter", () => {
|
||||
it("caps route-notice cleanup timers to five minutes", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const requestGateway = createGatewayRequestMock();
|
||||
const reporter = createApprovalNativeRouteReporter({
|
||||
handledKinds: new Set(["exec"]),
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
accountId: "default",
|
||||
requestGateway,
|
||||
});
|
||||
reporter.start();
|
||||
|
||||
reporter.observeRequest({
|
||||
approvalKind: "exec",
|
||||
request: {
|
||||
id: "approval-long",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "channel:C123",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 24 * 60 * 60_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5 * 60_000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not wait on runtimes that start after a request was already observed", async () => {
|
||||
const requestGateway = createGatewayRequestMock();
|
||||
const lateRuntimeGateway = createGatewayRequestMock();
|
||||
const request = {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "channel:C123",
|
||||
turnSourceAccountId: "default",
|
||||
turnSourceThreadId: "1712345678.123456",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
} as const;
|
||||
|
||||
const reporter = createApprovalNativeRouteReporter({
|
||||
handledKinds: new Set(["exec"]),
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
accountId: "default",
|
||||
requestGateway,
|
||||
});
|
||||
reporter.start();
|
||||
reporter.observeRequest({
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
});
|
||||
|
||||
const lateReporter = createApprovalNativeRouteReporter({
|
||||
handledKinds: new Set(["exec"]),
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
accountId: "default",
|
||||
requestGateway: lateRuntimeGateway,
|
||||
});
|
||||
lateReporter.start();
|
||||
|
||||
await reporter.reportDelivery({
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
deliveryPlan: {
|
||||
targets: [],
|
||||
originTarget: {
|
||||
to: "channel:C123",
|
||||
threadId: "1712345678.123456",
|
||||
},
|
||||
notifyOriginWhenDmOnly: true,
|
||||
},
|
||||
deliveredTargets: [
|
||||
{
|
||||
surface: "approver-dm",
|
||||
target: {
|
||||
to: "user:owner",
|
||||
},
|
||||
reason: "preferred",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith("send", {
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
accountId: "default",
|
||||
threadId: "1712345678.123456",
|
||||
message: "Approval required. I sent the approval request to Slack DMs, not this chat.",
|
||||
idempotencyKey: "approval-route-notice:approval-1",
|
||||
});
|
||||
expect(lateRuntimeGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not suppress the notice when another account delivered to the same target id", async () => {
|
||||
const originGateway = createGatewayRequestMock();
|
||||
const otherGateway = createGatewayRequestMock();
|
||||
const request = {
|
||||
id: "approval-2",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "channel:C123",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
} as const;
|
||||
|
||||
const originReporter = createApprovalNativeRouteReporter({
|
||||
handledKinds: new Set(["exec"]),
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
accountId: "work-a",
|
||||
requestGateway: originGateway,
|
||||
});
|
||||
const otherReporter = createApprovalNativeRouteReporter({
|
||||
handledKinds: new Set(["exec"]),
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
accountId: "work-b",
|
||||
requestGateway: otherGateway,
|
||||
});
|
||||
originReporter.start();
|
||||
otherReporter.start();
|
||||
|
||||
originReporter.observeRequest({
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
});
|
||||
otherReporter.observeRequest({
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
});
|
||||
|
||||
await originReporter.reportDelivery({
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
deliveryPlan: {
|
||||
targets: [],
|
||||
originTarget: {
|
||||
to: "channel:C123",
|
||||
},
|
||||
notifyOriginWhenDmOnly: true,
|
||||
},
|
||||
deliveredTargets: [
|
||||
{
|
||||
surface: "approver-dm",
|
||||
target: {
|
||||
to: "user:owner-a",
|
||||
},
|
||||
reason: "preferred",
|
||||
},
|
||||
],
|
||||
});
|
||||
await otherReporter.reportDelivery({
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
deliveryPlan: {
|
||||
targets: [],
|
||||
originTarget: {
|
||||
to: "channel:C123",
|
||||
},
|
||||
notifyOriginWhenDmOnly: true,
|
||||
},
|
||||
deliveredTargets: [
|
||||
{
|
||||
surface: "origin",
|
||||
target: {
|
||||
to: "channel:C123",
|
||||
},
|
||||
reason: "fallback",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(originGateway).toHaveBeenCalledWith("send", {
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
accountId: "work-a",
|
||||
threadId: undefined,
|
||||
message: "Approval required. I sent the approval request to Slack DMs, not this chat.",
|
||||
idempotencyKey: "approval-route-notice:approval-2",
|
||||
});
|
||||
expect(otherGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
392
src/infra/approval-native-route-coordinator.ts
Normal file
392
src/infra/approval-native-route-coordinator.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import type { ChannelApprovalKind } from "../channels/plugins/types.adapters.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import type {
|
||||
ChannelApprovalNativeDeliveryPlan,
|
||||
ChannelApprovalNativePlannedTarget,
|
||||
} from "./approval-native-delivery.js";
|
||||
import {
|
||||
describeApprovalDeliveryDestination,
|
||||
resolveApprovalRoutedElsewhereNoticeText,
|
||||
} from "./approval-native-route-notice.js";
|
||||
import { buildChannelApprovalNativeTargetKey } from "./approval-native-target-key.js";
|
||||
import type { ExecApprovalRequest } from "./exec-approvals.js";
|
||||
import type { PluginApprovalRequest } from "./plugin-approvals.js";
|
||||
|
||||
type GatewayRequestFn = <T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<T>;
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
|
||||
type ApprovalRouteRuntimeRecord = {
|
||||
runtimeId: string;
|
||||
handledKinds: ReadonlySet<ChannelApprovalKind>;
|
||||
channel?: string;
|
||||
channelLabel?: string;
|
||||
accountId?: string | null;
|
||||
requestGateway: GatewayRequestFn;
|
||||
};
|
||||
|
||||
type ApprovalRouteReport = {
|
||||
runtimeId: string;
|
||||
request: ApprovalRequest;
|
||||
channel?: string;
|
||||
channelLabel?: string;
|
||||
accountId?: string | null;
|
||||
deliveryPlan: ChannelApprovalNativeDeliveryPlan;
|
||||
deliveredTargets: readonly ChannelApprovalNativePlannedTarget[];
|
||||
requestGateway: GatewayRequestFn;
|
||||
};
|
||||
|
||||
type PendingApprovalRouteNotice = {
|
||||
request: ApprovalRequest;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
expectedRuntimeIds: Set<string>;
|
||||
reports: Map<string, ApprovalRouteReport>;
|
||||
cleanupTimeout: NodeJS.Timeout | null;
|
||||
finalized: boolean;
|
||||
};
|
||||
|
||||
type RouteNoticeTarget = {
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
};
|
||||
|
||||
const activeApprovalRouteRuntimes = new Map<string, ApprovalRouteRuntimeRecord>();
|
||||
const pendingApprovalRouteNotices = new Map<string, PendingApprovalRouteNotice>();
|
||||
let approvalRouteRuntimeSeq = 0;
|
||||
const MAX_APPROVAL_ROUTE_NOTICE_TTL_MS = 5 * 60_000;
|
||||
|
||||
function normalizeChannel(value?: string | null): string {
|
||||
return value?.trim().toLowerCase() || "";
|
||||
}
|
||||
|
||||
function clearPendingApprovalRouteNotice(approvalId: string): void {
|
||||
const entry = pendingApprovalRouteNotices.get(approvalId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
pendingApprovalRouteNotices.delete(approvalId);
|
||||
if (entry.cleanupTimeout) {
|
||||
clearTimeout(entry.cleanupTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
function createPendingApprovalRouteNotice(params: {
|
||||
request: ApprovalRequest;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
expectedRuntimeIds?: Iterable<string>;
|
||||
}): PendingApprovalRouteNotice {
|
||||
const timeoutMs = Math.min(
|
||||
Math.max(0, params.request.expiresAtMs - Date.now()),
|
||||
MAX_APPROVAL_ROUTE_NOTICE_TTL_MS,
|
||||
);
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
clearPendingApprovalRouteNotice(params.request.id);
|
||||
}, timeoutMs);
|
||||
cleanupTimeout.unref?.();
|
||||
return {
|
||||
request: params.request,
|
||||
approvalKind: params.approvalKind,
|
||||
// Snapshot siblings at first observation time so already-running runtimes
|
||||
// can still aggregate one notice, while late-starting runtimes that cannot
|
||||
// replay old gateway events never block the quorum.
|
||||
expectedRuntimeIds: new Set(params.expectedRuntimeIds ?? []),
|
||||
reports: new Map(),
|
||||
cleanupTimeout,
|
||||
finalized: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRouteNoticeTargetFromRequest(request: ApprovalRequest): RouteNoticeTarget | null {
|
||||
const channel = request.request.turnSourceChannel?.trim();
|
||||
const to = request.request.turnSourceTo?.trim();
|
||||
if (!channel || !to) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
to,
|
||||
accountId: request.request.turnSourceAccountId ?? undefined,
|
||||
threadId: request.request.turnSourceThreadId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFallbackRouteNoticeTarget(report: ApprovalRouteReport): RouteNoticeTarget | null {
|
||||
const channel = report.channel?.trim();
|
||||
const to = report.deliveryPlan.originTarget?.to?.trim();
|
||||
if (!channel || !to) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
to,
|
||||
accountId: report.accountId ?? undefined,
|
||||
threadId: report.deliveryPlan.originTarget?.threadId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function didReportDeliverToOrigin(report: ApprovalRouteReport, originAccountId?: string): boolean {
|
||||
const originTarget = report.deliveryPlan.originTarget;
|
||||
if (!originTarget) {
|
||||
return false;
|
||||
}
|
||||
const reportAccountId = normalizeOptionalString(report.accountId);
|
||||
if (
|
||||
originAccountId !== undefined &&
|
||||
reportAccountId !== undefined &&
|
||||
reportAccountId !== originAccountId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const originKey = buildChannelApprovalNativeTargetKey(originTarget);
|
||||
return report.deliveredTargets.some(
|
||||
(plannedTarget) => buildChannelApprovalNativeTargetKey(plannedTarget.target) === originKey,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveApprovalRouteNotice(params: {
|
||||
request: ApprovalRequest;
|
||||
reports: readonly ApprovalRouteReport[];
|
||||
}): { requestGateway: GatewayRequestFn; target: RouteNoticeTarget; text: string } | null {
|
||||
const explicitTarget = resolveRouteNoticeTargetFromRequest(params.request);
|
||||
const originChannel = normalizeChannel(
|
||||
explicitTarget?.channel ?? params.request.request.turnSourceChannel,
|
||||
);
|
||||
const fallbackTarget =
|
||||
params.reports
|
||||
.filter((report) => normalizeChannel(report.channel) === originChannel || !originChannel)
|
||||
.map(resolveFallbackRouteNoticeTarget)
|
||||
.find((target) => target !== null) ?? null;
|
||||
const target = explicitTarget
|
||||
? {
|
||||
...fallbackTarget,
|
||||
...explicitTarget,
|
||||
accountId: explicitTarget.accountId ?? fallbackTarget?.accountId,
|
||||
threadId: explicitTarget.threadId ?? fallbackTarget?.threadId,
|
||||
}
|
||||
: fallbackTarget;
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const originAccountId = normalizeOptionalString(target.accountId);
|
||||
|
||||
// If any same-channel runtime already delivered into the origin chat, every
|
||||
// other fallback delivery becomes supplemental and should not trigger a notice.
|
||||
const originDelivered = params.reports.some((report) => {
|
||||
if (originChannel && normalizeChannel(report.channel) !== originChannel) {
|
||||
return false;
|
||||
}
|
||||
return didReportDeliverToOrigin(report, originAccountId);
|
||||
});
|
||||
if (originDelivered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const destinations = params.reports.flatMap((report) => {
|
||||
if (!report.channelLabel || report.deliveredTargets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const reportChannel = normalizeChannel(report.channel);
|
||||
if (
|
||||
originChannel &&
|
||||
reportChannel === originChannel &&
|
||||
!report.deliveryPlan.notifyOriginWhenDmOnly
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const reportAccountId = normalizeOptionalString(report.accountId);
|
||||
if (
|
||||
originChannel &&
|
||||
reportChannel === originChannel &&
|
||||
originAccountId !== undefined &&
|
||||
reportAccountId !== undefined &&
|
||||
reportAccountId !== originAccountId
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
describeApprovalDeliveryDestination({
|
||||
channelLabel: report.channelLabel,
|
||||
deliveredTargets: report.deliveredTargets,
|
||||
}),
|
||||
];
|
||||
});
|
||||
const text = resolveApprovalRoutedElsewhereNoticeText(destinations);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestGateway =
|
||||
params.reports.find((report) => activeApprovalRouteRuntimes.has(report.runtimeId))
|
||||
?.requestGateway ?? params.reports[0]?.requestGateway;
|
||||
if (!requestGateway) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
requestGateway,
|
||||
target,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeFinalizeApprovalRouteNotice(approvalId: string): Promise<void> {
|
||||
const entry = pendingApprovalRouteNotices.get(approvalId);
|
||||
if (!entry || entry.finalized) {
|
||||
return;
|
||||
}
|
||||
for (const runtimeId of entry.expectedRuntimeIds) {
|
||||
if (!entry.reports.has(runtimeId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
entry.finalized = true;
|
||||
const reports = Array.from(entry.reports.values());
|
||||
const notice = resolveApprovalRouteNotice({
|
||||
request: entry.request,
|
||||
reports,
|
||||
});
|
||||
clearPendingApprovalRouteNotice(approvalId);
|
||||
if (!notice) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await notice.requestGateway("send", {
|
||||
channel: notice.target.channel,
|
||||
to: notice.target.to,
|
||||
accountId: notice.target.accountId ?? undefined,
|
||||
threadId: notice.target.threadId ?? undefined,
|
||||
message: notice.text,
|
||||
idempotencyKey: `approval-route-notice:${approvalId}`,
|
||||
});
|
||||
} catch {
|
||||
// The approval delivery already succeeded; the follow-up notice is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
export function createApprovalNativeRouteReporter(params: {
|
||||
handledKinds: ReadonlySet<ChannelApprovalKind>;
|
||||
channel?: string;
|
||||
channelLabel?: string;
|
||||
accountId?: string | null;
|
||||
requestGateway: GatewayRequestFn;
|
||||
}) {
|
||||
const runtimeId = `native-approval-route:${++approvalRouteRuntimeSeq}`;
|
||||
let registered = false;
|
||||
|
||||
const report = async (payload: {
|
||||
approvalKind: ChannelApprovalKind;
|
||||
request: ApprovalRequest;
|
||||
deliveryPlan: ChannelApprovalNativeDeliveryPlan;
|
||||
deliveredTargets: readonly ChannelApprovalNativePlannedTarget[];
|
||||
}): Promise<void> => {
|
||||
if (!registered || !params.handledKinds.has(payload.approvalKind)) {
|
||||
return;
|
||||
}
|
||||
const entry =
|
||||
pendingApprovalRouteNotices.get(payload.request.id) ??
|
||||
createPendingApprovalRouteNotice({
|
||||
request: payload.request,
|
||||
approvalKind: payload.approvalKind,
|
||||
expectedRuntimeIds: [runtimeId],
|
||||
});
|
||||
entry.expectedRuntimeIds.add(runtimeId);
|
||||
entry.reports.set(runtimeId, {
|
||||
runtimeId,
|
||||
request: payload.request,
|
||||
channel: params.channel,
|
||||
channelLabel: params.channelLabel,
|
||||
accountId: params.accountId,
|
||||
deliveryPlan: payload.deliveryPlan,
|
||||
deliveredTargets: payload.deliveredTargets,
|
||||
requestGateway: params.requestGateway,
|
||||
});
|
||||
pendingApprovalRouteNotices.set(payload.request.id, entry);
|
||||
await maybeFinalizeApprovalRouteNotice(payload.request.id);
|
||||
};
|
||||
|
||||
return {
|
||||
observeRequest(payload: { approvalKind: ChannelApprovalKind; request: ApprovalRequest }): void {
|
||||
if (!registered || !params.handledKinds.has(payload.approvalKind)) {
|
||||
return;
|
||||
}
|
||||
const entry =
|
||||
pendingApprovalRouteNotices.get(payload.request.id) ??
|
||||
createPendingApprovalRouteNotice({
|
||||
request: payload.request,
|
||||
approvalKind: payload.approvalKind,
|
||||
expectedRuntimeIds: Array.from(activeApprovalRouteRuntimes.values())
|
||||
.filter((runtime) => runtime.handledKinds.has(payload.approvalKind))
|
||||
.map((runtime) => runtime.runtimeId),
|
||||
});
|
||||
entry.expectedRuntimeIds.add(runtimeId);
|
||||
pendingApprovalRouteNotices.set(payload.request.id, entry);
|
||||
},
|
||||
start(): void {
|
||||
if (registered) {
|
||||
return;
|
||||
}
|
||||
activeApprovalRouteRuntimes.set(runtimeId, {
|
||||
runtimeId,
|
||||
handledKinds: params.handledKinds,
|
||||
channel: params.channel,
|
||||
channelLabel: params.channelLabel,
|
||||
accountId: params.accountId,
|
||||
requestGateway: params.requestGateway,
|
||||
});
|
||||
registered = true;
|
||||
},
|
||||
async reportSkipped(params: {
|
||||
approvalKind: ChannelApprovalKind;
|
||||
request: ApprovalRequest;
|
||||
}): Promise<void> {
|
||||
await report({
|
||||
approvalKind: params.approvalKind,
|
||||
request: params.request,
|
||||
deliveryPlan: {
|
||||
targets: [],
|
||||
originTarget: null,
|
||||
notifyOriginWhenDmOnly: false,
|
||||
},
|
||||
deliveredTargets: [],
|
||||
});
|
||||
},
|
||||
async reportDelivery(params: {
|
||||
approvalKind: ChannelApprovalKind;
|
||||
request: ApprovalRequest;
|
||||
deliveryPlan: ChannelApprovalNativeDeliveryPlan;
|
||||
deliveredTargets: readonly ChannelApprovalNativePlannedTarget[];
|
||||
}): Promise<void> {
|
||||
await report(params);
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
if (!registered) {
|
||||
return;
|
||||
}
|
||||
registered = false;
|
||||
activeApprovalRouteRuntimes.delete(runtimeId);
|
||||
for (const entry of pendingApprovalRouteNotices.values()) {
|
||||
entry.expectedRuntimeIds.delete(runtimeId);
|
||||
if (entry.expectedRuntimeIds.size === 0) {
|
||||
clearPendingApprovalRouteNotice(entry.request.id);
|
||||
continue;
|
||||
}
|
||||
await maybeFinalizeApprovalRouteNotice(entry.request.id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function clearApprovalNativeRouteStateForTest(): void {
|
||||
for (const approvalId of Array.from(pendingApprovalRouteNotices.keys())) {
|
||||
clearPendingApprovalRouteNotice(approvalId);
|
||||
}
|
||||
activeApprovalRouteRuntimes.clear();
|
||||
approvalRouteRuntimeSeq = 0;
|
||||
}
|
||||
51
src/infra/approval-native-route-notice.test.ts
Normal file
51
src/infra/approval-native-route-notice.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeApprovalDeliveryDestination,
|
||||
resolveApprovalRoutedElsewhereNoticeText,
|
||||
} from "./approval-native-route-notice.js";
|
||||
|
||||
describe("describeApprovalDeliveryDestination", () => {
|
||||
it("labels approver-DM-only delivery as channel DMs", () => {
|
||||
expect(
|
||||
describeApprovalDeliveryDestination({
|
||||
channelLabel: "Telegram",
|
||||
deliveredTargets: [
|
||||
{
|
||||
surface: "approver-dm",
|
||||
target: { to: "111" },
|
||||
reason: "fallback",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("Telegram DMs");
|
||||
});
|
||||
|
||||
it("labels mixed-surface delivery as the channel itself", () => {
|
||||
expect(
|
||||
describeApprovalDeliveryDestination({
|
||||
channelLabel: "Matrix",
|
||||
deliveredTargets: [
|
||||
{
|
||||
surface: "origin",
|
||||
target: { to: "room:!abc:example.com" },
|
||||
reason: "preferred",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("Matrix");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveApprovalRoutedElsewhereNoticeText", () => {
|
||||
it("reports sorted unique destinations", () => {
|
||||
expect(
|
||||
resolveApprovalRoutedElsewhereNoticeText(["Telegram DMs", "Matrix DMs", "Telegram DMs"]),
|
||||
).toBe(
|
||||
"Approval required. I sent the approval request to Matrix DMs or Telegram DMs, not this chat.",
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses the notice when there are no destinations", () => {
|
||||
expect(resolveApprovalRoutedElsewhereNoticeText([])).toBeNull();
|
||||
});
|
||||
});
|
||||
38
src/infra/approval-native-route-notice.ts
Normal file
38
src/infra/approval-native-route-notice.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ChannelApprovalNativePlannedTarget } from "./approval-native-delivery.js";
|
||||
|
||||
function formatHumanList(values: readonly string[]): string {
|
||||
if (values.length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (values.length === 1) {
|
||||
return values[0];
|
||||
}
|
||||
if (values.length === 2) {
|
||||
return `${values[0]} or ${values[1]}`;
|
||||
}
|
||||
return `${values.slice(0, -1).join(", ")}, or ${values.at(-1)}`;
|
||||
}
|
||||
|
||||
export function describeApprovalDeliveryDestination(params: {
|
||||
channelLabel: string;
|
||||
deliveredTargets: readonly ChannelApprovalNativePlannedTarget[];
|
||||
}): string {
|
||||
const surfaces = new Set(params.deliveredTargets.map((target) => target.surface));
|
||||
return surfaces.size === 1 && surfaces.has("approver-dm")
|
||||
? `${params.channelLabel} DMs`
|
||||
: params.channelLabel;
|
||||
}
|
||||
|
||||
export function resolveApprovalRoutedElsewhereNoticeText(
|
||||
destinations: readonly string[],
|
||||
): string | null {
|
||||
const uniqueDestinations = Array.from(new Set(destinations.map((value) => value.trim()))).filter(
|
||||
Boolean,
|
||||
);
|
||||
if (uniqueDestinations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return `Approval required. I sent the approval request to ${formatHumanList(
|
||||
uniqueDestinations.toSorted((a, b) => a.localeCompare(b)),
|
||||
)}, not this chat.`;
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelApprovalNativeAdapter } from "../channels/plugins/types.adapters.js";
|
||||
import { clearApprovalNativeRouteStateForTest } from "./approval-native-route-coordinator.js";
|
||||
import {
|
||||
createChannelNativeApprovalRuntime,
|
||||
deliverApprovalRequestViaChannelNativePlan,
|
||||
} from "./approval-native-runtime.js";
|
||||
|
||||
const mockGatewayClientStarts = vi.hoisted(() => vi.fn());
|
||||
const mockGatewayClientStops = vi.hoisted(() => vi.fn());
|
||||
const mockGatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
|
||||
const mockCreateOperatorApprovalsGatewayClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../gateway/operator-approvals-client.js", () => ({
|
||||
createOperatorApprovalsGatewayClient: mockCreateOperatorApprovalsGatewayClient,
|
||||
}));
|
||||
|
||||
const execRequest = {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
@@ -14,8 +24,13 @@ const execRequest = {
|
||||
expiresAtMs: 120_000,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
clearApprovalNativeRouteStateForTest();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("deliverApprovalRequestViaChannelNativePlan", () => {
|
||||
it("sends an origin notice and dedupes converged prepared targets", async () => {
|
||||
it("dedupes converged prepared targets", async () => {
|
||||
const adapter: ChannelApprovalNativeAdapter = {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
@@ -27,7 +42,6 @@ describe("deliverApprovalRequestViaChannelNativePlan", () => {
|
||||
resolveOriginTarget: async () => ({ to: "origin-room" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "approver-1" }, { to: "approver-2" }],
|
||||
};
|
||||
const sendOriginNotice = vi.fn().mockResolvedValue(undefined);
|
||||
const prepareTarget = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
@@ -51,24 +65,21 @@ describe("deliverApprovalRequestViaChannelNativePlan", () => {
|
||||
);
|
||||
const onDuplicateSkipped = vi.fn();
|
||||
|
||||
const entries = await deliverApprovalRequestViaChannelNativePlan({
|
||||
const result = await deliverApprovalRequestViaChannelNativePlan({
|
||||
cfg: {} as never,
|
||||
approvalKind: "exec",
|
||||
request: execRequest,
|
||||
adapter,
|
||||
sendOriginNotice: async ({ originTarget }) => {
|
||||
await sendOriginNotice(originTarget);
|
||||
},
|
||||
prepareTarget,
|
||||
deliverTarget,
|
||||
onDuplicateSkipped,
|
||||
});
|
||||
|
||||
expect(sendOriginNotice).toHaveBeenCalledWith({ to: "origin-room" });
|
||||
expect(prepareTarget).toHaveBeenCalledTimes(2);
|
||||
expect(deliverTarget).toHaveBeenCalledTimes(1);
|
||||
expect(onDuplicateSkipped).toHaveBeenCalledTimes(1);
|
||||
expect(entries).toEqual([{ channelId: "shared-dm" }]);
|
||||
expect(result.entries).toEqual([{ channelId: "shared-dm" }]);
|
||||
expect(result.deliveryPlan.notifyOriginWhenDmOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("continues after per-target delivery failures", async () => {
|
||||
@@ -83,7 +94,7 @@ describe("deliverApprovalRequestViaChannelNativePlan", () => {
|
||||
};
|
||||
const onDeliveryError = vi.fn();
|
||||
|
||||
const entries = await deliverApprovalRequestViaChannelNativePlan({
|
||||
const result = await deliverApprovalRequestViaChannelNativePlan({
|
||||
cfg: {} as never,
|
||||
approvalKind: "exec",
|
||||
request: execRequest,
|
||||
@@ -102,11 +113,796 @@ describe("deliverApprovalRequestViaChannelNativePlan", () => {
|
||||
});
|
||||
|
||||
expect(onDeliveryError).toHaveBeenCalledTimes(1);
|
||||
expect(entries).toEqual([{ channelId: "approver-2" }]);
|
||||
expect(result.entries).toEqual([{ channelId: "approver-2" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChannelNativeApprovalRuntime", () => {
|
||||
it("posts a same-channel DM redirect notice through the gateway after actual delivery", async () => {
|
||||
mockGatewayClientStarts.mockReset();
|
||||
mockGatewayClientStops.mockReset();
|
||||
mockGatewayClientRequests.mockReset();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset().mockResolvedValue({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
});
|
||||
const runtime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-same-channel-route-notice",
|
||||
clientDisplayName: "Test",
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "channel:C123", threadId: "1712345678.123456" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "owner" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "dm:owner",
|
||||
target: { chatId: "owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "owner", messageId: "m1" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await runtime.handleRequested({
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "channel:C123",
|
||||
turnSourceAccountId: "default",
|
||||
turnSourceThreadId: "1712345678.123456",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(mockGatewayClientRequests).toHaveBeenCalledWith("send", {
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
accountId: "default",
|
||||
threadId: "1712345678.123456",
|
||||
message: "Approval required. I sent the approval request to Slack DMs, not this chat.",
|
||||
idempotencyKey: "approval-route-notice:req-1",
|
||||
});
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("posts the same redirect notice for plugin approvals", async () => {
|
||||
mockGatewayClientStarts.mockReset();
|
||||
mockGatewayClientStops.mockReset();
|
||||
mockGatewayClientRequests.mockReset();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset().mockResolvedValue({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
});
|
||||
const runtime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-plugin-route-notice",
|
||||
clientDisplayName: "Discord",
|
||||
channel: "discord",
|
||||
channelLabel: "Discord",
|
||||
cfg: {} as never,
|
||||
eventKinds: ["exec", "plugin"],
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "channel:C123", threadId: "1712345678.123456" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "user:owner" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
resolveApprovalKind: (request) => (request.id.startsWith("plugin:") ? "plugin" : "exec"),
|
||||
buildPendingContent: async () => "pending plugin",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "discord-dm:owner",
|
||||
target: { chatId: "owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "owner", messageId: "m1" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await runtime.handleRequested({
|
||||
id: "plugin:req-1",
|
||||
request: {
|
||||
title: "Plugin Approval Required",
|
||||
description: "Allow plugin action",
|
||||
pluginId: "git-tools",
|
||||
turnSourceChannel: "discord",
|
||||
turnSourceTo: "channel:C123",
|
||||
turnSourceAccountId: "default",
|
||||
turnSourceThreadId: "1712345678.123456",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(mockGatewayClientRequests).toHaveBeenCalledWith("send", {
|
||||
channel: "discord",
|
||||
to: "channel:C123",
|
||||
accountId: "default",
|
||||
threadId: "1712345678.123456",
|
||||
message: "Approval required. I sent the approval request to Discord DMs, not this chat.",
|
||||
idempotencyKey: "approval-route-notice:plugin:req-1",
|
||||
});
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("does not block routed-elsewhere notices when another runtime throws in shouldHandle", async () => {
|
||||
mockGatewayClientStarts.mockReset();
|
||||
mockGatewayClientStops.mockReset();
|
||||
mockGatewayClientRequests.mockReset();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset().mockResolvedValue({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
});
|
||||
const failingRuntime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-throwing-should-handle",
|
||||
clientDisplayName: "Slack",
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "channel:C123", threadId: "1712345678.123456" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "user:owner" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "slack-dm:owner",
|
||||
target: { chatId: "owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "owner", messageId: "m1" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
const deliveringRuntime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-delivering",
|
||||
clientDisplayName: "Slack",
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "channel:C123", threadId: "1712345678.123456" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "user:owner" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "slack-dm:owner",
|
||||
target: { chatId: "owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "owner", messageId: "m1" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
|
||||
await failingRuntime.start();
|
||||
await deliveringRuntime.start();
|
||||
|
||||
await expect(
|
||||
failingRuntime.handleRequested({
|
||||
id: "req-throwing-should-handle",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "channel:C123",
|
||||
turnSourceAccountId: "default",
|
||||
turnSourceThreadId: "1712345678.123456",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
await deliveringRuntime.handleRequested({
|
||||
id: "req-throwing-should-handle",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "channel:C123",
|
||||
turnSourceAccountId: "default",
|
||||
turnSourceThreadId: "1712345678.123456",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(mockGatewayClientRequests).toHaveBeenCalledWith("send", {
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
accountId: "default",
|
||||
threadId: "1712345678.123456",
|
||||
message: "Approval required. I sent the approval request to Slack DMs, not this chat.",
|
||||
idempotencyKey: "approval-route-notice:req-throwing-should-handle",
|
||||
});
|
||||
|
||||
await failingRuntime.stop();
|
||||
await deliveringRuntime.stop();
|
||||
});
|
||||
|
||||
it("captures approvals emitted during gateway startup before the first onEvent turn", async () => {
|
||||
mockGatewayClientStarts.mockReset();
|
||||
mockGatewayClientStops.mockReset();
|
||||
mockGatewayClientRequests.mockReset();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset().mockImplementation(async (params) => ({
|
||||
start: () => {
|
||||
params.onEvent({
|
||||
event: "exec.approval.requested",
|
||||
payload: {
|
||||
id: "req-startup-race",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "channel:C123",
|
||||
turnSourceAccountId: "default",
|
||||
turnSourceThreadId: "1712345678.123456",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
},
|
||||
});
|
||||
},
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
}));
|
||||
const runtime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-startup-race",
|
||||
clientDisplayName: "Slack",
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "channel:C123", threadId: "1712345678.123456" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "user:owner" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "slack-dm:owner",
|
||||
target: { chatId: "owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "owner", messageId: "m1" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await vi.waitFor(() => {
|
||||
expect(mockGatewayClientRequests).toHaveBeenCalledWith("send", {
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
accountId: "default",
|
||||
threadId: "1712345678.123456",
|
||||
message: "Approval required. I sent the approval request to Slack DMs, not this chat.",
|
||||
idempotencyKey: "approval-route-notice:req-startup-race",
|
||||
});
|
||||
});
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("inherits fallback account and thread when the request omits them", async () => {
|
||||
mockGatewayClientStarts.mockReset();
|
||||
mockGatewayClientStops.mockReset();
|
||||
mockGatewayClientRequests.mockReset();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset().mockResolvedValue({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
});
|
||||
const runtime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-route-notice-fallback-account",
|
||||
clientDisplayName: "Matrix",
|
||||
channel: "matrix",
|
||||
channelLabel: "Matrix",
|
||||
accountId: "alerts",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "room:!ops:example.org", threadId: "$thread-1" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "user:@owner:example.org" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "matrix-dm:owner",
|
||||
target: { chatId: "matrix-dm:owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "matrix-dm:owner", messageId: "m1" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await runtime.handleRequested({
|
||||
id: "req-fallback-account",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "matrix",
|
||||
turnSourceTo: "room:!ops:example.org",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(mockGatewayClientRequests).toHaveBeenCalledWith("send", {
|
||||
channel: "matrix",
|
||||
to: "room:!ops:example.org",
|
||||
accountId: "alerts",
|
||||
threadId: "$thread-1",
|
||||
message: "Approval required. I sent the approval request to Matrix DMs, not this chat.",
|
||||
idempotencyKey: "approval-route-notice:req-fallback-account",
|
||||
});
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("aggregates same-channel and cross-channel fallback destinations into one notice", async () => {
|
||||
mockGatewayClientStarts.mockReset();
|
||||
mockGatewayClientStops.mockReset();
|
||||
mockGatewayClientRequests.mockReset();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset().mockResolvedValue({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
});
|
||||
const matrixRuntime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-matrix-route-notice",
|
||||
clientDisplayName: "Matrix",
|
||||
channel: "matrix",
|
||||
channelLabel: "Matrix",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "room:!ops:example.org" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "user:@owner:example.org" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "matrix-dm:owner",
|
||||
target: { chatId: "matrix-dm:owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "matrix-dm:owner", messageId: "m1" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
const telegramRuntime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-telegram-route-notice",
|
||||
clientDisplayName: "Telegram",
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: false,
|
||||
supportsApproverDmSurface: true,
|
||||
}),
|
||||
resolveApproverDmTargets: async () => [{ to: "owner" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "telegram-dm:owner",
|
||||
target: { chatId: "telegram-dm:owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "telegram-dm:owner", messageId: "m2" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
|
||||
await matrixRuntime.start();
|
||||
await telegramRuntime.start();
|
||||
const request = {
|
||||
id: "req-2",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "matrix",
|
||||
turnSourceTo: "room:!ops:example.org",
|
||||
turnSourceAccountId: "default",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
await telegramRuntime.handleRequested(request);
|
||||
await matrixRuntime.handleRequested(request);
|
||||
|
||||
expect(mockGatewayClientRequests).toHaveBeenCalledWith("send", {
|
||||
channel: "matrix",
|
||||
to: "room:!ops:example.org",
|
||||
accountId: "default",
|
||||
threadId: undefined,
|
||||
message:
|
||||
"Approval required. I sent the approval request to Matrix DMs or Telegram DMs, not this chat.",
|
||||
idempotencyKey: "approval-route-notice:req-2",
|
||||
});
|
||||
await matrixRuntime.stop();
|
||||
await telegramRuntime.stop();
|
||||
});
|
||||
|
||||
it("suppresses the aggregated notice when another runtime already delivered to the origin chat", async () => {
|
||||
mockGatewayClientStarts.mockReset();
|
||||
mockGatewayClientStops.mockReset();
|
||||
mockGatewayClientRequests.mockReset();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset().mockResolvedValue({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
});
|
||||
const matrixRuntime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-matrix-origin-delivered",
|
||||
clientDisplayName: "Matrix",
|
||||
channel: "matrix",
|
||||
channelLabel: "Matrix",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "origin",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: false,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "room:!ops:example.org" }),
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async ({ plannedTarget }) => ({
|
||||
dedupeKey: plannedTarget.target.to,
|
||||
target: { chatId: plannedTarget.target.to },
|
||||
}),
|
||||
deliverTarget: async ({ preparedTarget }) => ({
|
||||
chatId: preparedTarget.chatId,
|
||||
messageId: "matrix-origin",
|
||||
}),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
const telegramRuntime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-telegram-elsewhere",
|
||||
clientDisplayName: "Telegram",
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: false,
|
||||
supportsApproverDmSurface: true,
|
||||
}),
|
||||
resolveApproverDmTargets: async () => [{ to: "owner" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "telegram-dm:owner",
|
||||
target: { chatId: "telegram-dm:owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "telegram-dm:owner", messageId: "m2" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
|
||||
await matrixRuntime.start();
|
||||
await telegramRuntime.start();
|
||||
const request = {
|
||||
id: "req-3",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "matrix",
|
||||
turnSourceTo: "room:!ops:example.org",
|
||||
turnSourceAccountId: "default",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
await telegramRuntime.handleRequested(request);
|
||||
await matrixRuntime.handleRequested(request);
|
||||
|
||||
expect(mockGatewayClientRequests).not.toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({
|
||||
idempotencyKey: "approval-route-notice:req-3",
|
||||
}),
|
||||
);
|
||||
await matrixRuntime.stop();
|
||||
await telegramRuntime.stop();
|
||||
});
|
||||
|
||||
it("respects channels that opt out of same-channel DM redirect notices", async () => {
|
||||
mockGatewayClientStarts.mockReset();
|
||||
mockGatewayClientStops.mockReset();
|
||||
mockGatewayClientRequests.mockReset();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset().mockResolvedValue({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
});
|
||||
const runtime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-matrix-no-origin-notice",
|
||||
clientDisplayName: "Matrix",
|
||||
channel: "matrix",
|
||||
channelLabel: "Matrix",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: false,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "room:!ops:example.org" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "user:@owner:example.org" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "matrix-dm:owner",
|
||||
target: { chatId: "matrix-dm:owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "matrix-dm:owner", messageId: "m1" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await runtime.handleRequested({
|
||||
id: "req-4",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "matrix",
|
||||
turnSourceTo: "room:!ops:example.org",
|
||||
turnSourceAccountId: "default",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(mockGatewayClientRequests).not.toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({
|
||||
idempotencyKey: "approval-route-notice:req-4",
|
||||
}),
|
||||
);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("finalizes pending notices when a sibling runtime stops before reporting", async () => {
|
||||
mockGatewayClientStarts.mockReset();
|
||||
mockGatewayClientStops.mockReset();
|
||||
mockGatewayClientRequests.mockReset();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset().mockResolvedValue({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
});
|
||||
const slackRuntime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-slack-stop-finalize",
|
||||
clientDisplayName: "Slack",
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "channel:C123", threadId: "1712345678.123456" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "owner" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "slack-dm:owner",
|
||||
target: { chatId: "slack-dm:owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "slack-dm:owner", messageId: "m1" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
const telegramRuntime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-telegram-stop-before-report",
|
||||
clientDisplayName: "Telegram",
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: false,
|
||||
supportsApproverDmSurface: true,
|
||||
}),
|
||||
resolveApproverDmTargets: async () => [{ to: "owner" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "telegram-dm:owner",
|
||||
target: { chatId: "telegram-dm:owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "telegram-dm:owner", messageId: "m2" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
|
||||
await slackRuntime.start();
|
||||
await telegramRuntime.start();
|
||||
await slackRuntime.handleRequested({
|
||||
id: "req-5",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "channel:C123",
|
||||
turnSourceAccountId: "default",
|
||||
turnSourceThreadId: "1712345678.123456",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
await telegramRuntime.stop();
|
||||
|
||||
expect(mockGatewayClientRequests).toHaveBeenCalledWith("send", {
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
accountId: "default",
|
||||
threadId: "1712345678.123456",
|
||||
message: "Approval required. I sent the approval request to Slack DMs, not this chat.",
|
||||
idempotencyKey: "approval-route-notice:req-5",
|
||||
});
|
||||
await slackRuntime.stop();
|
||||
});
|
||||
|
||||
it("does not let disabled sibling runtimes block route notices", async () => {
|
||||
mockGatewayClientStarts.mockReset();
|
||||
mockGatewayClientStops.mockReset();
|
||||
mockGatewayClientRequests.mockReset();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset().mockResolvedValue({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
});
|
||||
const slackRuntime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-slack-disabled-sibling",
|
||||
clientDisplayName: "Slack",
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "channel:C123", threadId: "1712345678.123456" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "owner" }],
|
||||
},
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "slack-dm:owner",
|
||||
target: { chatId: "slack-dm:owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "slack-dm:owner", messageId: "m1" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
const disabledTelegramRuntime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-disabled-telegram-sibling",
|
||||
clientDisplayName: "Telegram",
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
cfg: {} as never,
|
||||
nativeAdapter: {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: false,
|
||||
supportsApproverDmSurface: true,
|
||||
}),
|
||||
resolveApproverDmTargets: async () => [{ to: "owner" }],
|
||||
},
|
||||
isConfigured: () => false,
|
||||
shouldHandle: () => true,
|
||||
buildPendingContent: async () => "pending exec",
|
||||
prepareTarget: async () => ({
|
||||
dedupeKey: "telegram-dm:owner",
|
||||
target: { chatId: "telegram-dm:owner" },
|
||||
}),
|
||||
deliverTarget: async () => ({ chatId: "telegram-dm:owner", messageId: "m2" }),
|
||||
finalizeResolved: async () => {},
|
||||
});
|
||||
|
||||
await disabledTelegramRuntime.start();
|
||||
await slackRuntime.start();
|
||||
await slackRuntime.handleRequested({
|
||||
id: "req-disabled-sibling",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "channel:C123",
|
||||
turnSourceAccountId: "default",
|
||||
turnSourceThreadId: "1712345678.123456",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(mockGatewayClientRequests).toHaveBeenCalledWith("send", {
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
accountId: "default",
|
||||
threadId: "1712345678.123456",
|
||||
message: "Approval required. I sent the approval request to Slack DMs, not this chat.",
|
||||
idempotencyKey: "approval-route-notice:req-disabled-sibling",
|
||||
});
|
||||
await slackRuntime.stop();
|
||||
await disabledTelegramRuntime.stop();
|
||||
});
|
||||
|
||||
it("passes the resolved approval kind and pending content through native delivery hooks", async () => {
|
||||
const describeDeliveryCapabilities = vi.fn().mockReturnValue({
|
||||
enabled: true,
|
||||
@@ -131,6 +927,8 @@ describe("createChannelNativeApprovalRuntime", () => {
|
||||
const runtime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime",
|
||||
clientDisplayName: "Test",
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
cfg: {} as never,
|
||||
accountId: "secondary",
|
||||
eventKinds: ["exec", "plugin"] as const,
|
||||
@@ -212,6 +1010,8 @@ describe("createChannelNativeApprovalRuntime", () => {
|
||||
const runtime = createChannelNativeApprovalRuntime({
|
||||
label: "test/native-runtime-expiry",
|
||||
clientDisplayName: "Test",
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
cfg: {} as never,
|
||||
nowMs: Date.now,
|
||||
nativeAdapter: {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import type {
|
||||
ChannelApprovalKind,
|
||||
ChannelApprovalNativeAdapter,
|
||||
ChannelApprovalNativeTarget,
|
||||
} from "../channels/plugins/types.adapters.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveChannelNativeApprovalDeliveryPlan,
|
||||
type ChannelApprovalNativePlannedTarget,
|
||||
type ChannelApprovalNativeDeliveryPlan,
|
||||
} from "./approval-native-delivery.js";
|
||||
import { createApprovalNativeRouteReporter } from "./approval-native-route-coordinator.js";
|
||||
import {
|
||||
createExecApprovalChannelRuntime,
|
||||
type ExecApprovalChannelRuntime,
|
||||
type ExecApprovalChannelRuntimeAdapter,
|
||||
type ExecApprovalChannelRuntimeEventKind,
|
||||
} from "./exec-approval-channel-runtime.js";
|
||||
import type { ExecApprovalResolved } from "./exec-approvals.js";
|
||||
import type { ExecApprovalRequest } from "./exec-approvals.js";
|
||||
@@ -26,9 +28,11 @@ export type PreparedChannelNativeApprovalTarget<TPreparedTarget> = {
|
||||
target: TPreparedTarget;
|
||||
};
|
||||
|
||||
function buildTargetKey(target: ChannelApprovalNativeTarget): string {
|
||||
return `${target.to}:${target.threadId == null ? "" : String(target.threadId)}`;
|
||||
}
|
||||
export type ChannelNativeApprovalPlanDeliveryResult<TPendingEntry> = {
|
||||
entries: TPendingEntry[];
|
||||
deliveryPlan: ChannelApprovalNativeDeliveryPlan;
|
||||
deliveredTargets: ChannelApprovalNativePlannedTarget[];
|
||||
};
|
||||
|
||||
export async function deliverApprovalRequestViaChannelNativePlan<
|
||||
TPreparedTarget,
|
||||
@@ -40,10 +44,6 @@ export async function deliverApprovalRequestViaChannelNativePlan<
|
||||
approvalKind: ChannelApprovalKind;
|
||||
request: TRequest;
|
||||
adapter?: ChannelApprovalNativeAdapter | null;
|
||||
sendOriginNotice?: (params: {
|
||||
originTarget: ChannelApprovalNativeTarget;
|
||||
request: TRequest;
|
||||
}) => Promise<void>;
|
||||
prepareTarget: (params: {
|
||||
plannedTarget: ChannelApprovalNativePlannedTarget;
|
||||
request: TRequest;
|
||||
@@ -56,11 +56,6 @@ export async function deliverApprovalRequestViaChannelNativePlan<
|
||||
preparedTarget: TPreparedTarget;
|
||||
request: TRequest;
|
||||
}) => TPendingEntry | null | Promise<TPendingEntry | null>;
|
||||
onOriginNoticeError?: (params: {
|
||||
error: unknown;
|
||||
originTarget: ChannelApprovalNativeTarget;
|
||||
request: TRequest;
|
||||
}) => void;
|
||||
onDeliveryError?: (params: {
|
||||
error: unknown;
|
||||
plannedTarget: ChannelApprovalNativePlannedTarget;
|
||||
@@ -77,7 +72,7 @@ export async function deliverApprovalRequestViaChannelNativePlan<
|
||||
request: TRequest;
|
||||
entry: TPendingEntry;
|
||||
}) => void;
|
||||
}): Promise<TPendingEntry[]> {
|
||||
}): Promise<ChannelNativeApprovalPlanDeliveryResult<TPendingEntry>> {
|
||||
const deliveryPlan = await resolveChannelNativeApprovalDeliveryPlan({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
@@ -86,34 +81,9 @@ export async function deliverApprovalRequestViaChannelNativePlan<
|
||||
adapter: params.adapter,
|
||||
});
|
||||
|
||||
const originTargetKey = deliveryPlan.originTarget
|
||||
? buildTargetKey(deliveryPlan.originTarget)
|
||||
: null;
|
||||
const plannedTargetKeys = new Set(
|
||||
deliveryPlan.targets.map((plannedTarget) => buildTargetKey(plannedTarget.target)),
|
||||
);
|
||||
|
||||
if (
|
||||
deliveryPlan.notifyOriginWhenDmOnly &&
|
||||
deliveryPlan.originTarget &&
|
||||
(originTargetKey == null || !plannedTargetKeys.has(originTargetKey))
|
||||
) {
|
||||
try {
|
||||
await params.sendOriginNotice?.({
|
||||
originTarget: deliveryPlan.originTarget,
|
||||
request: params.request,
|
||||
});
|
||||
} catch (error) {
|
||||
params.onOriginNoticeError?.({
|
||||
error,
|
||||
originTarget: deliveryPlan.originTarget,
|
||||
request: params.request,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const deliveredKeys = new Set<string>();
|
||||
const pendingEntries: TPendingEntry[] = [];
|
||||
const deliveredTargets: ChannelApprovalNativePlannedTarget[] = [];
|
||||
for (const plannedTarget of deliveryPlan.targets) {
|
||||
try {
|
||||
const preparedTarget = await params.prepareTarget({
|
||||
@@ -143,6 +113,7 @@ export async function deliverApprovalRequestViaChannelNativePlan<
|
||||
|
||||
deliveredKeys.add(preparedTarget.dedupeKey);
|
||||
pendingEntries.push(entry);
|
||||
deliveredTargets.push(plannedTarget);
|
||||
params.onDelivered?.({
|
||||
plannedTarget,
|
||||
preparedTarget,
|
||||
@@ -158,7 +129,11 @@ export async function deliverApprovalRequestViaChannelNativePlan<
|
||||
}
|
||||
}
|
||||
|
||||
return pendingEntries;
|
||||
return {
|
||||
entries: pendingEntries,
|
||||
deliveryPlan,
|
||||
deliveredTargets,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultResolveApprovalKind(request: ApprovalRequest): ChannelApprovalKind {
|
||||
@@ -175,6 +150,8 @@ type ChannelNativeApprovalRuntimeAdapter<
|
||||
ExecApprovalChannelRuntimeAdapter<TPendingEntry, TRequest, TResolved>,
|
||||
"deliverRequested"
|
||||
> & {
|
||||
channel?: string;
|
||||
channelLabel?: string;
|
||||
accountId?: string | null;
|
||||
nativeAdapter?: ChannelApprovalNativeAdapter | null;
|
||||
resolveApprovalKind?: (request: TRequest) => ChannelApprovalKind;
|
||||
@@ -183,12 +160,6 @@ type ChannelNativeApprovalRuntimeAdapter<
|
||||
approvalKind: ChannelApprovalKind;
|
||||
nowMs: number;
|
||||
}) => TPendingContent | Promise<TPendingContent>;
|
||||
sendOriginNotice?: (params: {
|
||||
originTarget: ChannelApprovalNativeTarget;
|
||||
request: TRequest;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
pendingContent: TPendingContent;
|
||||
}) => Promise<void>;
|
||||
prepareTarget: (params: {
|
||||
plannedTarget: ChannelApprovalNativePlannedTarget;
|
||||
request: TRequest;
|
||||
@@ -205,13 +176,6 @@ type ChannelNativeApprovalRuntimeAdapter<
|
||||
approvalKind: ChannelApprovalKind;
|
||||
pendingContent: TPendingContent;
|
||||
}) => TPendingEntry | null | Promise<TPendingEntry | null>;
|
||||
onOriginNoticeError?: (params: {
|
||||
error: unknown;
|
||||
originTarget: ChannelApprovalNativeTarget;
|
||||
request: TRequest;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
pendingContent: TPendingContent;
|
||||
}) => void;
|
||||
onDeliveryError?: (params: {
|
||||
error: unknown;
|
||||
plannedTarget: ChannelApprovalNativePlannedTarget;
|
||||
@@ -234,6 +198,7 @@ type ChannelNativeApprovalRuntimeAdapter<
|
||||
pendingContent: TPendingContent;
|
||||
entry: TPendingEntry;
|
||||
}) => void;
|
||||
onStopped?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function createChannelNativeApprovalRuntime<
|
||||
@@ -254,102 +219,163 @@ export function createChannelNativeApprovalRuntime<
|
||||
const nowMs = adapter.nowMs ?? Date.now;
|
||||
const resolveApprovalKind =
|
||||
adapter.resolveApprovalKind ?? ((request: TRequest) => defaultResolveApprovalKind(request));
|
||||
let runtimeRequest:
|
||||
| ((method: string, params: Record<string, unknown>) => Promise<unknown>)
|
||||
| null = null;
|
||||
const handledEventKinds = new Set<ExecApprovalChannelRuntimeEventKind>(
|
||||
adapter.eventKinds ?? ["exec"],
|
||||
);
|
||||
const routeReporter = createApprovalNativeRouteReporter({
|
||||
handledKinds: handledEventKinds,
|
||||
channel: adapter.channel,
|
||||
channelLabel: adapter.channelLabel,
|
||||
accountId: adapter.accountId,
|
||||
requestGateway: async <T>(method: string, params: Record<string, unknown>): Promise<T> => {
|
||||
if (!runtimeRequest) {
|
||||
throw new Error(`${adapter.label}: gateway client not connected`);
|
||||
}
|
||||
return (await runtimeRequest(method, params)) as T;
|
||||
},
|
||||
});
|
||||
|
||||
return createExecApprovalChannelRuntime<TPendingEntry, TRequest, TResolved>({
|
||||
const runtime = createExecApprovalChannelRuntime<TPendingEntry, TRequest, TResolved>({
|
||||
label: adapter.label,
|
||||
clientDisplayName: adapter.clientDisplayName,
|
||||
cfg: adapter.cfg,
|
||||
gatewayUrl: adapter.gatewayUrl,
|
||||
eventKinds: adapter.eventKinds,
|
||||
isConfigured: adapter.isConfigured,
|
||||
shouldHandle: adapter.shouldHandle,
|
||||
shouldHandle: (request) => {
|
||||
const approvalKind = resolveApprovalKind(request);
|
||||
routeReporter.observeRequest({
|
||||
approvalKind,
|
||||
request,
|
||||
});
|
||||
let shouldHandle: boolean;
|
||||
try {
|
||||
shouldHandle = adapter.shouldHandle(request);
|
||||
} catch (error) {
|
||||
void routeReporter.reportSkipped({
|
||||
approvalKind,
|
||||
request,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (shouldHandle) {
|
||||
return shouldHandle;
|
||||
}
|
||||
void routeReporter.reportSkipped({
|
||||
approvalKind,
|
||||
request,
|
||||
});
|
||||
return false;
|
||||
},
|
||||
finalizeResolved: adapter.finalizeResolved,
|
||||
finalizeExpired: adapter.finalizeExpired,
|
||||
onStopped: adapter.onStopped,
|
||||
beforeGatewayClientStart: () => {
|
||||
routeReporter.start();
|
||||
},
|
||||
nowMs,
|
||||
deliverRequested: async (request) => {
|
||||
const approvalKind = resolveApprovalKind(request);
|
||||
const pendingContent = await adapter.buildPendingContent({
|
||||
request,
|
||||
approvalKind,
|
||||
nowMs: nowMs(),
|
||||
});
|
||||
return await deliverApprovalRequestViaChannelNativePlan({
|
||||
cfg: adapter.cfg,
|
||||
accountId: adapter.accountId,
|
||||
approvalKind,
|
||||
request,
|
||||
adapter: adapter.nativeAdapter,
|
||||
sendOriginNotice: adapter.sendOriginNotice
|
||||
? async ({ originTarget, request }) => {
|
||||
await adapter.sendOriginNotice?.({
|
||||
originTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
prepareTarget: async ({ plannedTarget, request }) =>
|
||||
await adapter.prepareTarget({
|
||||
plannedTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
}),
|
||||
deliverTarget: async ({ plannedTarget, preparedTarget, request }) =>
|
||||
await adapter.deliverTarget({
|
||||
plannedTarget,
|
||||
preparedTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
}),
|
||||
onOriginNoticeError: adapter.onOriginNoticeError
|
||||
? ({ error, originTarget, request }) => {
|
||||
adapter.onOriginNoticeError?.({
|
||||
error,
|
||||
originTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onDeliveryError: adapter.onDeliveryError
|
||||
? ({ error, plannedTarget, request }) => {
|
||||
adapter.onDeliveryError?.({
|
||||
error,
|
||||
plannedTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onDuplicateSkipped: adapter.onDuplicateSkipped
|
||||
? ({ plannedTarget, preparedTarget, request }) => {
|
||||
adapter.onDuplicateSkipped?.({
|
||||
plannedTarget,
|
||||
preparedTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onDelivered: adapter.onDelivered
|
||||
? ({ plannedTarget, preparedTarget, request, entry }) => {
|
||||
adapter.onDelivered?.({
|
||||
plannedTarget,
|
||||
preparedTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
entry,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
let deliveryPlan: ChannelApprovalNativeDeliveryPlan = {
|
||||
targets: [],
|
||||
originTarget: null,
|
||||
notifyOriginWhenDmOnly: false,
|
||||
};
|
||||
let deliveredTargets: ChannelApprovalNativePlannedTarget[] = [];
|
||||
try {
|
||||
const pendingContent = await adapter.buildPendingContent({
|
||||
request,
|
||||
approvalKind,
|
||||
nowMs: nowMs(),
|
||||
});
|
||||
const deliveryResult = await deliverApprovalRequestViaChannelNativePlan({
|
||||
cfg: adapter.cfg,
|
||||
accountId: adapter.accountId,
|
||||
approvalKind,
|
||||
request,
|
||||
adapter: adapter.nativeAdapter,
|
||||
prepareTarget: async ({ plannedTarget, request }) =>
|
||||
await adapter.prepareTarget({
|
||||
plannedTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
}),
|
||||
deliverTarget: async ({ plannedTarget, preparedTarget, request }) =>
|
||||
await adapter.deliverTarget({
|
||||
plannedTarget,
|
||||
preparedTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
}),
|
||||
onDeliveryError: adapter.onDeliveryError
|
||||
? ({ error, plannedTarget, request }) => {
|
||||
adapter.onDeliveryError?.({
|
||||
error,
|
||||
plannedTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onDuplicateSkipped: adapter.onDuplicateSkipped
|
||||
? ({ plannedTarget, preparedTarget, request }) => {
|
||||
adapter.onDuplicateSkipped?.({
|
||||
plannedTarget,
|
||||
preparedTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onDelivered: adapter.onDelivered
|
||||
? ({ plannedTarget, preparedTarget, request, entry }) => {
|
||||
adapter.onDelivered?.({
|
||||
plannedTarget,
|
||||
preparedTarget,
|
||||
request,
|
||||
approvalKind,
|
||||
pendingContent,
|
||||
entry,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
deliveryPlan = deliveryResult.deliveryPlan;
|
||||
deliveredTargets = deliveryResult.deliveredTargets;
|
||||
return deliveryResult.entries;
|
||||
} finally {
|
||||
await routeReporter.reportDelivery({
|
||||
approvalKind,
|
||||
request,
|
||||
deliveryPlan,
|
||||
deliveredTargets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
runtimeRequest = (method, params) => runtime.request(method, params);
|
||||
|
||||
return {
|
||||
...runtime,
|
||||
async start() {
|
||||
try {
|
||||
await runtime.start();
|
||||
} catch (error) {
|
||||
await routeReporter.stop();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async stop() {
|
||||
await routeReporter.stop();
|
||||
await runtime.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
31
src/infra/approval-native-target-key.test.ts
Normal file
31
src/infra/approval-native-target-key.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildChannelApprovalNativeTargetKey } from "./approval-native-target-key.js";
|
||||
|
||||
describe("buildChannelApprovalNativeTargetKey", () => {
|
||||
it("distinguishes targets whose parts contain colons", () => {
|
||||
const first = buildChannelApprovalNativeTargetKey({
|
||||
to: "!room:example.org",
|
||||
threadId: "$event:example.org",
|
||||
});
|
||||
const second = buildChannelApprovalNativeTargetKey({
|
||||
to: "!room",
|
||||
threadId: "example.org:$event:example.org",
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it("normalizes surrounding whitespace", () => {
|
||||
expect(
|
||||
buildChannelApprovalNativeTargetKey({
|
||||
to: " room:one ",
|
||||
threadId: " 123 ",
|
||||
}),
|
||||
).toBe(
|
||||
buildChannelApprovalNativeTargetKey({
|
||||
to: "room:one",
|
||||
threadId: "123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
7
src/infra/approval-native-target-key.ts
Normal file
7
src/infra/approval-native-target-key.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ChannelApprovalNativeTarget } from "../channels/plugins/types.adapters.js";
|
||||
|
||||
export function buildChannelApprovalNativeTargetKey(target: ChannelApprovalNativeTarget): string {
|
||||
return `${target.to.trim()}\u0000${
|
||||
target.threadId == null ? "" : String(target.threadId).trim()
|
||||
}`;
|
||||
}
|
||||
219
src/infra/approval-view-model.ts
Normal file
219
src/infra/approval-view-model.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { ChannelApprovalKind } from "../channels/plugins/types.adapters.js";
|
||||
import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js";
|
||||
import {
|
||||
buildExecApprovalActionDescriptors,
|
||||
type ExecApprovalActionDescriptor,
|
||||
} from "./exec-approval-reply.js";
|
||||
import {
|
||||
resolveExecApprovalRequestAllowedDecisions,
|
||||
type ExecApprovalDecision,
|
||||
type ExecApprovalRequest,
|
||||
type ExecApprovalResolved,
|
||||
} from "./exec-approvals.js";
|
||||
import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-approvals.js";
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
|
||||
type ApprovalPhase = "pending" | "resolved" | "expired";
|
||||
|
||||
export type ApprovalActionView = ExecApprovalActionDescriptor;
|
||||
|
||||
export type ApprovalMetadataView = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ApprovalViewBase = {
|
||||
approvalId: string;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
phase: "pending" | "resolved" | "expired";
|
||||
title: string;
|
||||
description?: string | null;
|
||||
metadata: ApprovalMetadataView[];
|
||||
};
|
||||
|
||||
type ExecApprovalViewBase = ApprovalViewBase & {
|
||||
approvalKind: "exec";
|
||||
ask?: string | null;
|
||||
agentId?: string | null;
|
||||
commandText: string;
|
||||
commandPreview?: string | null;
|
||||
cwd?: string | null;
|
||||
envKeys?: readonly string[];
|
||||
host?: string | null;
|
||||
nodeId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalPendingView = ExecApprovalViewBase & {
|
||||
phase: "pending";
|
||||
actions: ApprovalActionView[];
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
export type ExecApprovalResolvedView = ExecApprovalViewBase & {
|
||||
phase: "resolved";
|
||||
decision: ExecApprovalDecision;
|
||||
resolvedBy?: string | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalExpiredView = ExecApprovalViewBase & {
|
||||
phase: "expired";
|
||||
};
|
||||
|
||||
type PluginApprovalViewBase = ApprovalViewBase & {
|
||||
approvalKind: "plugin";
|
||||
agentId?: string | null;
|
||||
pluginId?: string | null;
|
||||
toolName?: string | null;
|
||||
severity: "info" | "warning" | "critical";
|
||||
};
|
||||
|
||||
export type PluginApprovalPendingView = PluginApprovalViewBase & {
|
||||
phase: "pending";
|
||||
actions: ApprovalActionView[];
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
export type PluginApprovalResolvedView = PluginApprovalViewBase & {
|
||||
phase: "resolved";
|
||||
decision: ExecApprovalDecision;
|
||||
resolvedBy?: string | null;
|
||||
};
|
||||
|
||||
export type PluginApprovalExpiredView = PluginApprovalViewBase & {
|
||||
phase: "expired";
|
||||
};
|
||||
|
||||
export type PendingApprovalView = ExecApprovalPendingView | PluginApprovalPendingView;
|
||||
export type ResolvedApprovalView = ExecApprovalResolvedView | PluginApprovalResolvedView;
|
||||
export type ExpiredApprovalView = ExecApprovalExpiredView | PluginApprovalExpiredView;
|
||||
export type ApprovalViewModel = PendingApprovalView | ResolvedApprovalView | ExpiredApprovalView;
|
||||
|
||||
function buildExecMetadata(request: ExecApprovalRequest): ApprovalMetadataView[] {
|
||||
const metadata: ApprovalMetadataView[] = [];
|
||||
if (request.request.agentId) {
|
||||
metadata.push({ label: "Agent", value: request.request.agentId });
|
||||
}
|
||||
if (request.request.cwd) {
|
||||
metadata.push({ label: "CWD", value: request.request.cwd });
|
||||
}
|
||||
if (request.request.host) {
|
||||
metadata.push({ label: "Host", value: request.request.host });
|
||||
}
|
||||
if (Array.isArray(request.request.envKeys) && request.request.envKeys.length > 0) {
|
||||
metadata.push({ label: "Env Overrides", value: request.request.envKeys.join(", ") });
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function buildPluginMetadata(request: PluginApprovalRequest): ApprovalMetadataView[] {
|
||||
const metadata: ApprovalMetadataView[] = [];
|
||||
const severity = request.request.severity ?? "warning";
|
||||
metadata.push({
|
||||
label: "Severity",
|
||||
value: severity === "critical" ? "Critical" : severity === "info" ? "Info" : "Warning",
|
||||
});
|
||||
if (request.request.toolName) {
|
||||
metadata.push({ label: "Tool", value: request.request.toolName });
|
||||
}
|
||||
if (request.request.pluginId) {
|
||||
metadata.push({ label: "Plugin", value: request.request.pluginId });
|
||||
}
|
||||
if (request.request.agentId) {
|
||||
metadata.push({ label: "Agent", value: request.request.agentId });
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function buildExecViewBase<TPhase extends ApprovalPhase>(
|
||||
request: ExecApprovalRequest,
|
||||
phase: TPhase,
|
||||
): ExecApprovalViewBase & { phase: TPhase } {
|
||||
const { commandText, commandPreview } = resolveExecApprovalCommandDisplay(request.request);
|
||||
return {
|
||||
approvalId: request.id,
|
||||
approvalKind: "exec",
|
||||
phase,
|
||||
title: phase === "pending" ? "Exec Approval Required" : "Exec Approval",
|
||||
description: phase === "pending" ? "A command needs your approval." : null,
|
||||
metadata: buildExecMetadata(request),
|
||||
ask: request.request.ask ?? null,
|
||||
agentId: request.request.agentId ?? null,
|
||||
commandText,
|
||||
commandPreview,
|
||||
cwd: request.request.cwd ?? null,
|
||||
envKeys: request.request.envKeys ?? undefined,
|
||||
host: request.request.host ?? null,
|
||||
nodeId: request.request.nodeId ?? null,
|
||||
sessionKey: request.request.sessionKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginViewBase<TPhase extends ApprovalPhase>(
|
||||
request: PluginApprovalRequest,
|
||||
phase: TPhase,
|
||||
): PluginApprovalViewBase & { phase: TPhase } {
|
||||
return {
|
||||
approvalId: request.id,
|
||||
approvalKind: "plugin",
|
||||
phase,
|
||||
title: request.request.title,
|
||||
description: request.request.description ?? null,
|
||||
metadata: buildPluginMetadata(request),
|
||||
agentId: request.request.agentId ?? null,
|
||||
pluginId: request.request.pluginId ?? null,
|
||||
toolName: request.request.toolName ?? null,
|
||||
severity: request.request.severity ?? "warning",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPendingApprovalView(request: ApprovalRequest): PendingApprovalView {
|
||||
if (request.id.startsWith("plugin:")) {
|
||||
const pluginRequest = request as PluginApprovalRequest;
|
||||
return {
|
||||
...buildPluginViewBase(pluginRequest, "pending"),
|
||||
actions: buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: pluginRequest.id,
|
||||
}),
|
||||
expiresAtMs: pluginRequest.expiresAtMs,
|
||||
};
|
||||
}
|
||||
const execRequest = request as ExecApprovalRequest;
|
||||
return {
|
||||
...buildExecViewBase(execRequest, "pending"),
|
||||
actions: buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: execRequest.id,
|
||||
ask: execRequest.request.ask,
|
||||
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(execRequest.request),
|
||||
}),
|
||||
expiresAtMs: execRequest.expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildResolvedApprovalView(
|
||||
request: ApprovalRequest,
|
||||
resolved: ApprovalResolved,
|
||||
): ResolvedApprovalView {
|
||||
if (request.id.startsWith("plugin:")) {
|
||||
const pluginRequest = request as PluginApprovalRequest;
|
||||
return {
|
||||
...buildPluginViewBase(pluginRequest, "resolved"),
|
||||
decision: resolved.decision,
|
||||
resolvedBy: resolved.resolvedBy,
|
||||
};
|
||||
}
|
||||
const execRequest = request as ExecApprovalRequest;
|
||||
return {
|
||||
...buildExecViewBase(execRequest, "resolved"),
|
||||
decision: resolved.decision,
|
||||
resolvedBy: resolved.resolvedBy,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExpiredApprovalView(request: ApprovalRequest): ExpiredApprovalView {
|
||||
if (request.id.startsWith("plugin:")) {
|
||||
return buildPluginViewBase(request as PluginApprovalRequest, "expired");
|
||||
}
|
||||
return buildExecViewBase(request as ExecApprovalRequest, "expired");
|
||||
}
|
||||
@@ -32,7 +32,7 @@ describe("resolveApprovalCommandAuthorization", () => {
|
||||
|
||||
it("delegates to the channel approval override when present", () => {
|
||||
getChannelPluginMock.mockReturnValue({
|
||||
auth: {
|
||||
approvalCapability: {
|
||||
authorizeActorAction: ({
|
||||
approvalKind,
|
||||
}: {
|
||||
@@ -66,14 +66,12 @@ describe("resolveApprovalCommandAuthorization", () => {
|
||||
).toEqual({ authorized: false, reason: "plugin denied", explicit: true });
|
||||
});
|
||||
|
||||
it("prefers approvalCapability over legacy auth wiring when present", () => {
|
||||
it("uses approvalCapability as the canonical approval auth contract", () => {
|
||||
const getActionAvailabilityState = vi.fn(() => ({ kind: "enabled" as const }));
|
||||
getChannelPluginMock.mockReturnValue({
|
||||
auth: {
|
||||
authorizeActorAction: () => ({ authorized: false, reason: "legacy denied" }),
|
||||
},
|
||||
approvalCapability: {
|
||||
authorizeActorAction: () => ({ authorized: true }),
|
||||
getActionAvailabilityState: () => ({ kind: "enabled" }),
|
||||
getActionAvailabilityState,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -85,13 +83,20 @@ describe("resolveApprovalCommandAuthorization", () => {
|
||||
kind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true, explicit: true });
|
||||
expect(getActionAvailabilityState).toHaveBeenCalledWith({
|
||||
cfg: {} as never,
|
||||
accountId: undefined,
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps disabled approval availability implicit even when same-chat auth returns allow", () => {
|
||||
const getActionAvailabilityState = vi.fn(() => ({ kind: "disabled" as const }));
|
||||
getChannelPluginMock.mockReturnValue({
|
||||
auth: {
|
||||
approvalCapability: {
|
||||
authorizeActorAction: () => ({ authorized: true }),
|
||||
getActionAvailabilityState: () => ({ kind: "disabled" }),
|
||||
getActionAvailabilityState,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -104,5 +109,11 @@ describe("resolveApprovalCommandAuthorization", () => {
|
||||
kind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true, explicit: false });
|
||||
expect(getActionAvailabilityState).toHaveBeenCalledWith({
|
||||
cfg: {} as never,
|
||||
accountId: "work",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ export function resolveApprovalCommandAuthorization(params: {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
action: "approve",
|
||||
approvalKind: params.kind,
|
||||
});
|
||||
return {
|
||||
authorized: resolved.authorized,
|
||||
|
||||
138
src/infra/channel-runtime-context.ts
Normal file
138
src/infra/channel-runtime-context.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
|
||||
export type ChannelRuntimeContextKey = {
|
||||
channelId: string;
|
||||
accountId?: string | null;
|
||||
capability: string;
|
||||
};
|
||||
|
||||
const NOOP_DISPOSE = () => {};
|
||||
|
||||
function resolveScopedRuntimeContextRegistry(params: {
|
||||
channelRuntime: PluginRuntime["channel"];
|
||||
}): PluginRuntime["channel"]["runtimeContexts"] {
|
||||
const runtimeContexts = resolveRuntimeContextRegistry(params);
|
||||
if (
|
||||
runtimeContexts &&
|
||||
typeof runtimeContexts.register === "function" &&
|
||||
typeof runtimeContexts.get === "function" &&
|
||||
typeof runtimeContexts.watch === "function"
|
||||
) {
|
||||
return runtimeContexts;
|
||||
}
|
||||
throw new Error(
|
||||
"channelRuntime must provide runtimeContexts.register/get/watch; pass createPluginRuntime().channel or omit channelRuntime.",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRuntimeContextRegistry(params: {
|
||||
channelRuntime?: PluginRuntime["channel"];
|
||||
}): PluginRuntime["channel"]["runtimeContexts"] | null {
|
||||
return params.channelRuntime?.runtimeContexts ?? null;
|
||||
}
|
||||
|
||||
export function registerChannelRuntimeContext(
|
||||
params: ChannelRuntimeContextKey & {
|
||||
channelRuntime?: PluginRuntime["channel"];
|
||||
context: unknown;
|
||||
abortSignal?: AbortSignal;
|
||||
},
|
||||
): { dispose: () => void } | null {
|
||||
const runtimeContexts = resolveRuntimeContextRegistry(params);
|
||||
if (!runtimeContexts) {
|
||||
return null;
|
||||
}
|
||||
return runtimeContexts.register({
|
||||
channelId: params.channelId,
|
||||
accountId: params.accountId,
|
||||
capability: params.capability,
|
||||
context: params.context,
|
||||
abortSignal: params.abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
export function getChannelRuntimeContext<T = unknown>(
|
||||
params: ChannelRuntimeContextKey & {
|
||||
channelRuntime?: PluginRuntime["channel"];
|
||||
},
|
||||
): T | undefined {
|
||||
const runtimeContexts = resolveRuntimeContextRegistry(params);
|
||||
if (!runtimeContexts) {
|
||||
return undefined;
|
||||
}
|
||||
return runtimeContexts.get<T>({
|
||||
channelId: params.channelId,
|
||||
accountId: params.accountId,
|
||||
capability: params.capability,
|
||||
});
|
||||
}
|
||||
|
||||
export function watchChannelRuntimeContexts(
|
||||
params: ChannelRuntimeContextKey & {
|
||||
channelRuntime?: PluginRuntime["channel"];
|
||||
onEvent: Parameters<PluginRuntime["channel"]["runtimeContexts"]["watch"]>[0]["onEvent"];
|
||||
},
|
||||
): (() => void) | null {
|
||||
const runtimeContexts = resolveRuntimeContextRegistry(params);
|
||||
if (!runtimeContexts) {
|
||||
return null;
|
||||
}
|
||||
return runtimeContexts.watch({
|
||||
channelId: params.channelId,
|
||||
accountId: params.accountId,
|
||||
capability: params.capability,
|
||||
onEvent: params.onEvent,
|
||||
});
|
||||
}
|
||||
|
||||
export function createTaskScopedChannelRuntime(params: {
|
||||
channelRuntime?: PluginRuntime["channel"];
|
||||
}): {
|
||||
channelRuntime?: PluginRuntime["channel"];
|
||||
dispose: () => void;
|
||||
} {
|
||||
const baseRuntime = params.channelRuntime;
|
||||
if (!baseRuntime) {
|
||||
return {
|
||||
channelRuntime: undefined,
|
||||
dispose: NOOP_DISPOSE,
|
||||
};
|
||||
}
|
||||
const runtimeContexts = resolveScopedRuntimeContextRegistry({ channelRuntime: baseRuntime });
|
||||
|
||||
const trackedLeases = new Set<{ dispose: () => void }>();
|
||||
const trackLease = (lease: { dispose: () => void }) => {
|
||||
trackedLeases.add(lease);
|
||||
let disposed = false;
|
||||
return {
|
||||
dispose: () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
trackedLeases.delete(lease);
|
||||
lease.dispose();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const scopedRuntime: PluginRuntime["channel"] = {
|
||||
...baseRuntime,
|
||||
runtimeContexts: {
|
||||
...runtimeContexts,
|
||||
register: (registerParams) => {
|
||||
const lease = runtimeContexts.register(registerParams);
|
||||
return trackLease(lease);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
channelRuntime: scopedRuntime,
|
||||
dispose: () => {
|
||||
for (const lease of Array.from(trackedLeases)) {
|
||||
lease.dispose();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -37,12 +37,14 @@ export type ExecApprovalChannelRuntimeAdapter<
|
||||
isConfigured: () => boolean;
|
||||
shouldHandle: (request: TRequest) => boolean;
|
||||
deliverRequested: (request: TRequest) => Promise<TPending[]>;
|
||||
beforeGatewayClientStart?: () => Promise<void> | void;
|
||||
finalizeResolved: (params: {
|
||||
request: TRequest;
|
||||
resolved: TResolved;
|
||||
entries: TPending[];
|
||||
}) => Promise<void>;
|
||||
finalizeExpired?: (params: { request: TRequest; entries: TPending[] }) => Promise<void>;
|
||||
onStopped?: () => Promise<void> | void;
|
||||
nowMs?: () => number;
|
||||
};
|
||||
|
||||
@@ -239,9 +241,17 @@ export function createExecApprovalChannelRuntime<
|
||||
client.stop();
|
||||
return;
|
||||
}
|
||||
client.start();
|
||||
await adapter.beforeGatewayClientStart?.();
|
||||
gatewayClient = client;
|
||||
started = true;
|
||||
try {
|
||||
client.start();
|
||||
} catch (error) {
|
||||
gatewayClient = null;
|
||||
started = false;
|
||||
client.stop();
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
startPromise = null;
|
||||
});
|
||||
@@ -255,6 +265,7 @@ export function createExecApprovalChannelRuntime<
|
||||
await startPromise.catch(() => {});
|
||||
}
|
||||
if (!started && !gatewayClient) {
|
||||
await adapter.onStopped?.();
|
||||
return;
|
||||
}
|
||||
started = false;
|
||||
@@ -266,6 +277,7 @@ export function createExecApprovalChannelRuntime<
|
||||
pending.clear();
|
||||
gatewayClient?.stop();
|
||||
gatewayClient = null;
|
||||
await adapter.onStopped?.();
|
||||
log.debug("stopped");
|
||||
},
|
||||
|
||||
|
||||
@@ -164,29 +164,34 @@ export function buildExecApprovalActionDescriptors(params: {
|
||||
}
|
||||
|
||||
function buildApprovalInteractiveButtons(
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[],
|
||||
approvalId: string,
|
||||
descriptors: readonly ExecApprovalActionDescriptor[],
|
||||
): InteractiveReplyButton[] {
|
||||
return buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: approvalId,
|
||||
allowedDecisions,
|
||||
}).map((descriptor) => ({
|
||||
return descriptors.map((descriptor) => ({
|
||||
label: descriptor.label,
|
||||
value: descriptor.command,
|
||||
style: descriptor.style,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildApprovalInteractiveReplyFromActionDescriptors(
|
||||
actions: readonly ExecApprovalActionDescriptor[],
|
||||
): InteractiveReply | undefined {
|
||||
const buttons = buildApprovalInteractiveButtons(actions);
|
||||
return buttons.length > 0 ? { blocks: [{ type: "buttons", buttons }] } : undefined;
|
||||
}
|
||||
|
||||
export function buildApprovalInteractiveReply(params: {
|
||||
approvalId: string;
|
||||
ask?: string | null;
|
||||
allowedDecisions?: readonly ExecApprovalReplyDecision[];
|
||||
}): InteractiveReply | undefined {
|
||||
const buttons = buildApprovalInteractiveButtons(
|
||||
resolveAllowedDecisions(params),
|
||||
params.approvalId,
|
||||
return buildApprovalInteractiveReplyFromActionDescriptors(
|
||||
buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: params.approvalId,
|
||||
ask: params.ask,
|
||||
allowedDecisions: params.allowedDecisions,
|
||||
}),
|
||||
);
|
||||
return buttons.length > 0 ? { blocks: [{ type: "buttons", buttons }] } : undefined;
|
||||
}
|
||||
|
||||
export function buildExecApprovalInteractiveReply(params: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveApprovalRequestChannelAccountId,
|
||||
} from "./approval-request-account-binding.js";
|
||||
import {
|
||||
resolveApprovalRequestSessionConversation,
|
||||
resolveApprovalRequestOriginTarget,
|
||||
resolveExecApprovalSessionTarget,
|
||||
} from "./exec-approval-session-target.js";
|
||||
@@ -129,7 +130,7 @@ describe("exec approval session target", () => {
|
||||
channel: "whatsapp",
|
||||
to: "+15555550123",
|
||||
accountId: "work",
|
||||
threadId: 1739201675,
|
||||
threadId: "1739201675.123",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -153,7 +154,7 @@ describe("exec approval session target", () => {
|
||||
channel: "discord",
|
||||
to: "channel:123",
|
||||
accountId: "work",
|
||||
threadId: 55,
|
||||
threadId: "55",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -190,6 +191,64 @@ describe("exec approval session target", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves string thread ids from the session store", () => {
|
||||
withTempDirSync({ prefix: "openclaw-exec-approval-session-target-" }, (tmpDir) => {
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const cfg = writeStoreFile(storePath, {
|
||||
"agent:main:main": {
|
||||
sessionId: "main",
|
||||
updatedAt: 1,
|
||||
lastChannel: "discord",
|
||||
lastTo: "channel:123",
|
||||
lastAccountId: " Work ",
|
||||
lastThreadId: "777888999111222333",
|
||||
},
|
||||
});
|
||||
|
||||
expect(expectResolvedSessionTarget(cfg, baseRequest)).toEqual({
|
||||
channel: "discord",
|
||||
to: "channel:123",
|
||||
accountId: "work",
|
||||
threadId: "777888999111222333",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("parses channel-scoped session conversation fallbacks for approval requests", () => {
|
||||
const request = buildPluginRequest({
|
||||
sessionKey: "agent:main:matrix:channel:!Ops:Example.org:thread:$root",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveApprovalRequestSessionConversation({
|
||||
request,
|
||||
channel: "matrix",
|
||||
}),
|
||||
).toEqual({
|
||||
channel: "matrix",
|
||||
kind: "channel",
|
||||
id: "!Ops:Example.org",
|
||||
rawId: "!Ops:Example.org:thread:$root",
|
||||
threadId: "$root",
|
||||
baseSessionKey: "agent:main:matrix:channel:!Ops:Example.org",
|
||||
baseConversationId: "!Ops:Example.org",
|
||||
parentConversationCandidates: ["!Ops:Example.org"],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores session conversation fallbacks for other channels", () => {
|
||||
const request = buildPluginRequest({
|
||||
sessionKey: "agent:main:matrix:channel:!ops:example.org",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveApprovalRequestSessionConversation({
|
||||
request,
|
||||
channel: "slack",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers explicit turn-source account bindings when session store is missing", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const request = buildRequest({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveSessionConversationRef } from "../channels/plugins/session-conversation.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import { loadSessionStore } from "../config/sessions/store-load.js";
|
||||
@@ -19,7 +20,18 @@ export type ExecApprovalSessionTarget = {
|
||||
channel?: string;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
threadId?: number;
|
||||
threadId?: string | number;
|
||||
};
|
||||
|
||||
export type ApprovalRequestSessionConversation = {
|
||||
channel: string;
|
||||
kind: "group" | "channel";
|
||||
id: string;
|
||||
rawId: string;
|
||||
threadId?: string;
|
||||
baseSessionKey: string;
|
||||
baseConversationId: string;
|
||||
parentConversationCandidates: string[];
|
||||
};
|
||||
|
||||
type ApprovalRequestLike = ExecApprovalRequest | PluginApprovalRequest;
|
||||
@@ -34,15 +46,15 @@ type ApprovalRequestOriginTargetResolver<TTarget> = {
|
||||
resolveFallbackTarget?: (request: ApprovalRequestLike) => TTarget | null;
|
||||
};
|
||||
|
||||
function normalizeOptionalThreadId(value?: string | number | null): number | undefined {
|
||||
function normalizeOptionalThreadValue(value?: string | number | null): string | number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = Number.parseInt(value, 10);
|
||||
return Number.isFinite(normalized) ? normalized : undefined;
|
||||
const normalized = value.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function isExecApprovalRequest(request: ApprovalRequestLike): request is ExecApprovalRequest {
|
||||
@@ -72,6 +84,34 @@ function normalizeOptionalChannel(value?: string | null): string | undefined {
|
||||
return normalizeMessageChannel(value);
|
||||
}
|
||||
|
||||
export function resolveApprovalRequestSessionConversation(params: {
|
||||
request: ApprovalRequestLike;
|
||||
channel?: string | null;
|
||||
}): ApprovalRequestSessionConversation | null {
|
||||
const sessionKey = normalizeOptionalString(params.request.request.sessionKey);
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveSessionConversationRef(sessionKey);
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
const expectedChannel = normalizeOptionalChannel(params.channel);
|
||||
if (expectedChannel && normalizeOptionalChannel(resolved.channel) !== expectedChannel) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel: resolved.channel,
|
||||
kind: resolved.kind,
|
||||
id: resolved.id,
|
||||
rawId: resolved.rawId,
|
||||
threadId: resolved.threadId,
|
||||
baseSessionKey: resolved.baseSessionKey,
|
||||
baseConversationId: resolved.baseConversationId,
|
||||
parentConversationCandidates: resolved.parentConversationCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveExecApprovalSessionTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest;
|
||||
@@ -99,7 +139,7 @@ export function resolveExecApprovalSessionTarget(params: {
|
||||
turnSourceChannel: normalizeOptionalString(params.turnSourceChannel),
|
||||
turnSourceTo: normalizeOptionalString(params.turnSourceTo),
|
||||
turnSourceAccountId: normalizeOptionalString(params.turnSourceAccountId),
|
||||
turnSourceThreadId: normalizeOptionalThreadId(params.turnSourceThreadId),
|
||||
turnSourceThreadId: normalizeOptionalThreadValue(params.turnSourceThreadId),
|
||||
});
|
||||
if (!target.to) {
|
||||
return null;
|
||||
@@ -109,7 +149,7 @@ export function resolveExecApprovalSessionTarget(params: {
|
||||
channel: normalizeOptionalString(target.channel),
|
||||
to: target.to,
|
||||
accountId: normalizeOptionalString(target.accountId),
|
||||
threadId: normalizeOptionalThreadId(target.threadId),
|
||||
threadId: normalizeOptionalThreadValue(target.threadId),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,12 @@ vi.mock("../utils/message-channel.js", () => ({
|
||||
|
||||
type ExecApprovalSurfaceModule = typeof import("./exec-approval-surface.js");
|
||||
|
||||
let hasConfiguredExecApprovalDmRoute: ExecApprovalSurfaceModule["hasConfiguredExecApprovalDmRoute"];
|
||||
let resolveExecApprovalInitiatingSurfaceState: ExecApprovalSurfaceModule["resolveExecApprovalInitiatingSurfaceState"];
|
||||
let supportsNativeExecApprovalClient: ExecApprovalSurfaceModule["supportsNativeExecApprovalClient"];
|
||||
|
||||
describe("resolveExecApprovalInitiatingSurfaceState", () => {
|
||||
beforeAll(async () => {
|
||||
({ hasConfiguredExecApprovalDmRoute, resolveExecApprovalInitiatingSurfaceState } =
|
||||
({ resolveExecApprovalInitiatingSurfaceState, supportsNativeExecApprovalClient } =
|
||||
await import("./exec-approval-surface.js"));
|
||||
});
|
||||
|
||||
@@ -93,14 +93,14 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => {
|
||||
channel === "telegram"
|
||||
? {
|
||||
meta: { label: "Telegram" },
|
||||
auth: {
|
||||
approvalCapability: {
|
||||
getActionAvailabilityState: () => ({ kind: "enabled" }),
|
||||
},
|
||||
}
|
||||
: channel === "discord"
|
||||
? {
|
||||
meta: { label: "Discord" },
|
||||
auth: {
|
||||
approvalCapability: {
|
||||
getActionAvailabilityState: () => ({ kind: "disabled" }),
|
||||
},
|
||||
}
|
||||
@@ -137,10 +137,11 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => {
|
||||
});
|
||||
|
||||
it("reads approval availability from approvalCapability when auth is omitted", () => {
|
||||
const getActionAvailabilityState = vi.fn(() => ({ kind: "disabled" as const }));
|
||||
getChannelPluginMock.mockReturnValue({
|
||||
meta: { label: "Discord" },
|
||||
approvalCapability: {
|
||||
getActionAvailabilityState: () => ({ kind: "disabled" }),
|
||||
getActionAvailabilityState,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -156,6 +157,68 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => {
|
||||
channelLabel: "Discord",
|
||||
accountId: "main",
|
||||
});
|
||||
expect(getActionAvailabilityState).toHaveBeenCalledWith({
|
||||
cfg: {} as never,
|
||||
accountId: "main",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers exec-initiating-surface state over generic approval availability", () => {
|
||||
const getExecInitiatingSurfaceState = vi.fn(() => ({ kind: "disabled" as const }));
|
||||
const getActionAvailabilityState = vi.fn(() => ({ kind: "enabled" as const }));
|
||||
getChannelPluginMock.mockReturnValue({
|
||||
meta: { label: "Matrix" },
|
||||
approvalCapability: {
|
||||
native: {},
|
||||
getExecInitiatingSurfaceState,
|
||||
getActionAvailabilityState,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveExecApprovalInitiatingSurfaceState({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
cfg: {} as never,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "disabled",
|
||||
channel: "matrix",
|
||||
channelLabel: "Matrix",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(getExecInitiatingSurfaceState).toHaveBeenCalledWith({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
action: "approve",
|
||||
});
|
||||
expect(getActionAvailabilityState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not treat plugin-only approval availability as exec availability", () => {
|
||||
getChannelPluginMock.mockReturnValue({
|
||||
meta: { label: "Matrix" },
|
||||
approvalCapability: {
|
||||
native: {},
|
||||
getActionAvailabilityState: ({ approvalKind }: { approvalKind?: "exec" | "plugin" }) =>
|
||||
approvalKind === "plugin" ? { kind: "enabled" as const } : { kind: "disabled" as const },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveExecApprovalInitiatingSurfaceState({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
cfg: {} as never,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "disabled",
|
||||
channel: "matrix",
|
||||
channelLabel: "Matrix",
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("loads config lazily when cfg is omitted and marks unsupported channels", () => {
|
||||
@@ -164,7 +227,7 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => {
|
||||
channel === "telegram"
|
||||
? {
|
||||
meta: { label: "Telegram" },
|
||||
auth: {
|
||||
approvalCapability: {
|
||||
getActionAvailabilityState: () => ({ kind: "disabled" }),
|
||||
},
|
||||
}
|
||||
@@ -200,98 +263,16 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => {
|
||||
accountId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasConfiguredExecApprovalDmRoute", () => {
|
||||
beforeEach(() => {
|
||||
loadConfigMock.mockReset();
|
||||
getChannelPluginMock.mockReset();
|
||||
listChannelPluginsMock.mockReset();
|
||||
isDeliverableMessageChannelMock.mockReset();
|
||||
normalizeMessageChannelMock.mockReset();
|
||||
normalizeMessageChannelMock.mockImplementation((value?: string | null) =>
|
||||
typeof value === "string" ? value.trim().toLowerCase() : undefined,
|
||||
);
|
||||
isDeliverableMessageChannelMock.mockImplementation(
|
||||
(value?: string) => value === "slack" || value === "discord" || value === "telegram",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
plugins: [
|
||||
{
|
||||
approvals: {
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: () => false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
approvals: {
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: () => true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
plugins: [
|
||||
{
|
||||
approvals: {
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: () => false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
approvals: {
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: () => false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
approvals: undefined,
|
||||
},
|
||||
],
|
||||
expected: false,
|
||||
},
|
||||
])("reports whether any plugin routes approvals to DM for %j", ({ plugins, expected }) => {
|
||||
listChannelPluginsMock.mockReturnValueOnce(plugins);
|
||||
expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(expected);
|
||||
});
|
||||
|
||||
it("detects DM routes exposed through approvalCapability", () => {
|
||||
listChannelPluginsMock.mockReturnValueOnce([
|
||||
{
|
||||
approvalCapability: {
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: () => true,
|
||||
},
|
||||
},
|
||||
it("treats exec-specific initiating-surface hooks as native exec client support", () => {
|
||||
getChannelPluginMock.mockReturnValue({
|
||||
meta: { label: "Matrix" },
|
||||
approvalCapability: {
|
||||
native: {},
|
||||
getExecInitiatingSurfaceState: () => ({ kind: "enabled" as const }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves legacy DM routes when approvalCapability only defines auth", () => {
|
||||
listChannelPluginsMock.mockReturnValueOnce([
|
||||
{
|
||||
approvalCapability: {
|
||||
authorizeActorAction: () => ({ authorized: true }),
|
||||
},
|
||||
approvals: {
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: () => true,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(true);
|
||||
expect(supportsNativeExecApprovalClient("matrix")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
getChannelPlugin,
|
||||
listChannelPlugins,
|
||||
resolveChannelApprovalAdapter,
|
||||
resolveChannelApprovalCapability,
|
||||
} from "../channels/plugins/index.js";
|
||||
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||
@@ -32,7 +31,10 @@ function labelForChannel(channel?: string): string {
|
||||
|
||||
function hasNativeExecApprovalCapability(channel?: string): boolean {
|
||||
const capability = resolveChannelApprovalCapability(getChannelPlugin(channel ?? ""));
|
||||
return Boolean(capability?.native && capability.getActionAvailabilityState);
|
||||
if (!capability?.native) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(capability.getExecInitiatingSurfaceState || capability.getActionAvailabilityState);
|
||||
}
|
||||
|
||||
export function resolveExecApprovalInitiatingSurfaceState(params: {
|
||||
@@ -48,13 +50,19 @@ export function resolveExecApprovalInitiatingSurfaceState(params: {
|
||||
}
|
||||
|
||||
const cfg = params.cfg ?? loadConfig();
|
||||
const state = resolveChannelApprovalCapability(
|
||||
getChannelPlugin(channel),
|
||||
)?.getActionAvailabilityState?.({
|
||||
cfg,
|
||||
accountId: params.accountId,
|
||||
action: "approve",
|
||||
});
|
||||
const capability = resolveChannelApprovalCapability(getChannelPlugin(channel));
|
||||
const state =
|
||||
capability?.getExecInitiatingSurfaceState?.({
|
||||
cfg,
|
||||
accountId: params.accountId,
|
||||
action: "approve",
|
||||
}) ??
|
||||
capability?.getActionAvailabilityState?.({
|
||||
cfg,
|
||||
accountId: params.accountId,
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
});
|
||||
if (state) {
|
||||
return { ...state, channel, channelLabel, accountId };
|
||||
}
|
||||
@@ -103,10 +111,3 @@ export function describeNativeExecApprovalClientSetup(params: {
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
|
||||
return listChannelPlugins().some(
|
||||
(plugin) =>
|
||||
resolveChannelApprovalAdapter(plugin)?.delivery?.hasConfiguredDmRoute?.({ cfg }) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,9 +107,6 @@ function resolveAskNote(params: {
|
||||
hostAsk: ExecAsk;
|
||||
effectiveAsk: ExecAsk;
|
||||
}): string {
|
||||
if (params.hostAsk === "off" && params.requestedAsk !== "off") {
|
||||
return "host ask=off suppresses prompts";
|
||||
}
|
||||
if (params.effectiveAsk === params.requestedAsk) {
|
||||
return "requested ask applies";
|
||||
}
|
||||
@@ -200,8 +197,8 @@ export function resolveExecPolicyScopeSnapshot(params: {
|
||||
});
|
||||
const hostPath = params.hostPath ?? DEFAULT_HOST_PATH;
|
||||
const effectiveSecurity = minSecurity(requestedSecurity.value, resolved.agent.security);
|
||||
const effectiveAsk =
|
||||
resolved.agent.ask === "off" ? "off" : maxAsk(requestedAsk.value, resolved.agent.ask);
|
||||
const effectiveAsk = maxAsk(requestedAsk.value, resolved.agent.ask);
|
||||
const effectiveAskFallback = minSecurity(effectiveSecurity, resolved.agent.askFallback);
|
||||
return {
|
||||
scopeLabel: params.scopeLabel,
|
||||
configPath: params.configPath,
|
||||
@@ -250,7 +247,7 @@ export function resolveExecPolicyScopeSnapshot(params: {
|
||||
}),
|
||||
},
|
||||
askFallback: {
|
||||
effective: resolved.agent.askFallback,
|
||||
effective: effectiveAskFallback,
|
||||
source: formatHostFieldSource({
|
||||
hostPath,
|
||||
field: "askFallback",
|
||||
|
||||
@@ -275,7 +275,7 @@ describe("exec approvals policy helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("explains host ask=off suppression separately from stricter ask", () => {
|
||||
it("does not let host ask=off suppress a stricter requested ask", () => {
|
||||
const summary = resolveExecPolicyScopeSummary({
|
||||
approvals: {
|
||||
version: 1,
|
||||
@@ -293,8 +293,32 @@ describe("exec approvals policy helpers", () => {
|
||||
expect(summary.ask).toMatchObject({
|
||||
requested: "always",
|
||||
host: "off",
|
||||
effective: "off",
|
||||
note: "host ask=off suppresses prompts",
|
||||
effective: "always",
|
||||
note: "requested ask applies",
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps askFallback to the effective security", () => {
|
||||
const summary = resolveExecPolicyScopeSummary({
|
||||
approvals: {
|
||||
version: 1,
|
||||
defaults: {
|
||||
security: "full",
|
||||
ask: "always",
|
||||
askFallback: "full",
|
||||
},
|
||||
},
|
||||
scopeExecConfig: {
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
},
|
||||
configPath: "tools.exec",
|
||||
scopeLabel: "tools.exec",
|
||||
});
|
||||
|
||||
expect(summary.askFallback).toEqual({
|
||||
effective: "allowlist",
|
||||
source: "~/.openclaw/exec-approvals.json defaults.askFallback",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createApproverRestrictedNativeApprovalAdapter,
|
||||
createApproverRestrictedNativeApprovalCapability,
|
||||
createChannelApprovalCapability,
|
||||
splitChannelApprovalCapability,
|
||||
} from "./approval-delivery-helpers.js";
|
||||
|
||||
@@ -70,8 +71,13 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
resolveApproverDmTargets: () => [{ to: "approver-1" }],
|
||||
});
|
||||
const getActionAvailabilityState = adapter.auth.getActionAvailabilityState;
|
||||
const getExecInitiatingSurfaceState = adapter.auth.getExecInitiatingSurfaceState;
|
||||
const hasConfiguredDmRoute = adapter.delivery;
|
||||
if (!getActionAvailabilityState || !hasConfiguredDmRoute?.hasConfiguredDmRoute) {
|
||||
if (
|
||||
!getActionAvailabilityState ||
|
||||
!getExecInitiatingSurfaceState ||
|
||||
!hasConfiguredDmRoute?.hasConfiguredDmRoute
|
||||
) {
|
||||
throw new Error("approval availability helpers unavailable");
|
||||
}
|
||||
const nativeCapabilities = adapter.native?.describeDeliveryCapabilities({
|
||||
@@ -107,6 +113,13 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
action: "approve",
|
||||
}),
|
||||
).toEqual({ kind: "enabled" });
|
||||
expect(
|
||||
getExecInitiatingSurfaceState({
|
||||
cfg: {} as never,
|
||||
accountId: "disabled",
|
||||
action: "approve",
|
||||
}),
|
||||
).toEqual({ kind: "disabled" });
|
||||
expect(hasConfiguredDmRoute.hasConfiguredDmRoute({ cfg: {} as never })).toBe(true);
|
||||
expect(nativeCapabilities).toEqual({
|
||||
enabled: true,
|
||||
@@ -128,7 +141,8 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
resolveNativeDeliveryMode: () => "both",
|
||||
});
|
||||
const getActionAvailabilityState = adapter.auth.getActionAvailabilityState;
|
||||
if (!getActionAvailabilityState) {
|
||||
const getExecInitiatingSurfaceState = adapter.auth.getExecInitiatingSurfaceState;
|
||||
if (!getActionAvailabilityState || !getExecInitiatingSurfaceState) {
|
||||
throw new Error("approval availability helper unavailable");
|
||||
}
|
||||
|
||||
@@ -139,6 +153,13 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
action: "approve",
|
||||
}),
|
||||
).toEqual({ kind: "enabled" });
|
||||
expect(
|
||||
getExecInitiatingSurfaceState({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
action: "approve",
|
||||
}),
|
||||
).toEqual({ kind: "disabled" });
|
||||
});
|
||||
|
||||
it("suppresses forwarding fallback only for matching native-delivery surfaces", () => {
|
||||
@@ -231,6 +252,21 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
|
||||
describe("createApproverRestrictedNativeApprovalCapability", () => {
|
||||
it("builds the canonical approval capability and preserves legacy split compatibility", () => {
|
||||
const nativeRuntime = {
|
||||
availability: {
|
||||
isConfigured: vi.fn(),
|
||||
shouldHandle: vi.fn(),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn(),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
};
|
||||
const describeExecApprovalSetup = vi.fn(
|
||||
({
|
||||
channel,
|
||||
@@ -252,6 +288,7 @@ describe("createApproverRestrictedNativeApprovalCapability", () => {
|
||||
isNativeDeliveryEnabled: () => true,
|
||||
resolveNativeDeliveryMode: () => "dm",
|
||||
resolveApproverDmTargets: () => [{ to: "user:@owner:example.com" }],
|
||||
nativeRuntime,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -348,7 +385,83 @@ describe("createApproverRestrictedNativeApprovalCapability", () => {
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
split.auth.getExecInitiatingSurfaceState?.({
|
||||
cfg: {} as never,
|
||||
accountId: "work",
|
||||
action: "approve",
|
||||
}),
|
||||
).toEqual(
|
||||
legacy.auth.getExecInitiatingSurfaceState?.({
|
||||
cfg: {} as never,
|
||||
accountId: "work",
|
||||
action: "approve",
|
||||
}),
|
||||
);
|
||||
expect(split.describeExecApprovalSetup).toBe(describeExecApprovalSetup);
|
||||
expect(split.nativeRuntime).toBe(nativeRuntime);
|
||||
expect(legacy.describeExecApprovalSetup).toBe(describeExecApprovalSetup);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChannelApprovalCapability", () => {
|
||||
it("accepts canonical top-level capability surfaces", () => {
|
||||
const delivery = { hasConfiguredDmRoute: vi.fn() };
|
||||
const nativeRuntime = {
|
||||
availability: {
|
||||
isConfigured: vi.fn(),
|
||||
shouldHandle: vi.fn(),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: vi.fn(),
|
||||
buildResolvedResult: vi.fn(),
|
||||
buildExpiredResult: vi.fn(),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: vi.fn(),
|
||||
deliverPending: vi.fn(),
|
||||
},
|
||||
};
|
||||
const render = { buildPendingReplyPayload: vi.fn() };
|
||||
const native = { describeDeliveryCapabilities: vi.fn() };
|
||||
|
||||
expect(
|
||||
createChannelApprovalCapability({
|
||||
delivery,
|
||||
nativeRuntime,
|
||||
render,
|
||||
native,
|
||||
}),
|
||||
).toEqual({
|
||||
authorizeActorAction: undefined,
|
||||
getActionAvailabilityState: undefined,
|
||||
getExecInitiatingSurfaceState: undefined,
|
||||
resolveApproveCommandBehavior: undefined,
|
||||
describeExecApprovalSetup: undefined,
|
||||
delivery,
|
||||
nativeRuntime,
|
||||
render,
|
||||
native,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the deprecated approvals alias as a compatibility shim", () => {
|
||||
const delivery = { hasConfiguredDmRoute: vi.fn() };
|
||||
|
||||
expect(
|
||||
createChannelApprovalCapability({
|
||||
approvals: { delivery },
|
||||
}),
|
||||
).toEqual({
|
||||
authorizeActorAction: undefined,
|
||||
getActionAvailabilityState: undefined,
|
||||
getExecInitiatingSurfaceState: undefined,
|
||||
resolveApproveCommandBehavior: undefined,
|
||||
describeExecApprovalSetup: undefined,
|
||||
delivery,
|
||||
nativeRuntime: undefined,
|
||||
render: undefined,
|
||||
native: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ type NativeApprovalDeliveryMode = "dm" | "channel" | "both";
|
||||
type NativeApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type NativeApprovalTarget = { to: string; threadId?: string | number | null };
|
||||
type NativeApprovalSurface = "origin" | "approver-dm";
|
||||
type ChannelApprovalCapabilitySurfaces = Pick<
|
||||
ChannelApprovalCapability,
|
||||
"delivery" | "nativeRuntime" | "render" | "native"
|
||||
>;
|
||||
|
||||
type ApprovalAdapterParams = {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -50,6 +54,7 @@ type ApproverRestrictedNativeApprovalParams = {
|
||||
request: NativeApprovalRequest;
|
||||
}) => NativeApprovalTarget[] | Promise<NativeApprovalTarget[]>;
|
||||
notifyOriginWhenDmOnly?: boolean;
|
||||
nativeRuntime?: ChannelApprovalCapability["nativeRuntime"];
|
||||
describeExecApprovalSetup?: ChannelApprovalCapability["describeExecApprovalSetup"];
|
||||
};
|
||||
|
||||
@@ -57,10 +62,36 @@ function buildApproverRestrictedNativeApprovalCapability(
|
||||
params: ApproverRestrictedNativeApprovalParams,
|
||||
): ChannelApprovalCapability {
|
||||
const pluginSenderAuth = params.isPluginAuthorizedSender ?? params.isExecAuthorizedSender;
|
||||
const availabilityState = (enabled: boolean) =>
|
||||
enabled ? ({ kind: "enabled" } as const) : ({ kind: "disabled" } as const);
|
||||
const normalizePreferredSurface = (
|
||||
mode: NativeApprovalDeliveryMode,
|
||||
): NativeApprovalSurface | "both" =>
|
||||
mode === "channel" ? "origin" : mode === "dm" ? "approver-dm" : "both";
|
||||
const hasConfiguredApprovers = ({
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}) => params.hasApprovers({ cfg, accountId });
|
||||
const isExecInitiatingSurfaceEnabled = ({
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}) =>
|
||||
hasConfiguredApprovers({ cfg, accountId }) &&
|
||||
params.isNativeDeliveryEnabled({ cfg, accountId });
|
||||
const resolveExecInitiatingSurfaceState = ({
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
action: "approve";
|
||||
}) => availabilityState(isExecInitiatingSurfaceEnabled({ cfg, accountId }));
|
||||
|
||||
return createChannelApprovalCapability({
|
||||
authorizeActorAction: ({
|
||||
@@ -93,72 +124,67 @@ function buildApproverRestrictedNativeApprovalCapability(
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
action: "approve";
|
||||
}) =>
|
||||
params.hasApprovers({ cfg, accountId })
|
||||
? ({ kind: "enabled" } as const)
|
||||
: ({ kind: "disabled" } as const),
|
||||
}) => availabilityState(hasConfiguredApprovers({ cfg, accountId })),
|
||||
getExecInitiatingSurfaceState: resolveExecInitiatingSurfaceState,
|
||||
describeExecApprovalSetup: params.describeExecApprovalSetup,
|
||||
approvals: {
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: ({ cfg }: { cfg: OpenClawConfig }) =>
|
||||
params.listAccountIds(cfg).some((accountId) => {
|
||||
if (!params.hasApprovers({ cfg, accountId })) {
|
||||
return false;
|
||||
}
|
||||
if (!params.isNativeDeliveryEnabled({ cfg, accountId })) {
|
||||
return false;
|
||||
}
|
||||
const target = params.resolveNativeDeliveryMode({ cfg, accountId });
|
||||
return target === "dm" || target === "both";
|
||||
}),
|
||||
shouldSuppressForwardingFallback: (input: DeliverySuppressionParams) => {
|
||||
const channel = normalizeMessageChannel(input.target.channel) ?? input.target.channel;
|
||||
if (channel !== params.channel) {
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: ({ cfg }: { cfg: OpenClawConfig }) =>
|
||||
params.listAccountIds(cfg).some((accountId) => {
|
||||
if (!hasConfiguredApprovers({ cfg, accountId })) {
|
||||
return false;
|
||||
}
|
||||
if (params.requireMatchingTurnSourceChannel) {
|
||||
const turnSourceChannel = normalizeMessageChannel(
|
||||
input.request.request.turnSourceChannel,
|
||||
);
|
||||
if (turnSourceChannel !== params.channel) {
|
||||
return false;
|
||||
}
|
||||
if (!params.isNativeDeliveryEnabled({ cfg, accountId })) {
|
||||
return false;
|
||||
}
|
||||
const resolvedAccountId = params.resolveSuppressionAccountId?.(input);
|
||||
const accountId =
|
||||
(resolvedAccountId === undefined
|
||||
? input.target.accountId?.trim()
|
||||
: resolvedAccountId.trim()) || undefined;
|
||||
return params.isNativeDeliveryEnabled({ cfg: input.cfg, accountId });
|
||||
},
|
||||
const target = params.resolveNativeDeliveryMode({ cfg, accountId });
|
||||
return target === "dm" || target === "both";
|
||||
}),
|
||||
shouldSuppressForwardingFallback: (input: DeliverySuppressionParams) => {
|
||||
const channel = normalizeMessageChannel(input.target.channel) ?? input.target.channel;
|
||||
if (channel !== params.channel) {
|
||||
return false;
|
||||
}
|
||||
if (params.requireMatchingTurnSourceChannel) {
|
||||
const turnSourceChannel = normalizeMessageChannel(
|
||||
input.request.request.turnSourceChannel,
|
||||
);
|
||||
if (turnSourceChannel !== params.channel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const resolvedAccountId = params.resolveSuppressionAccountId?.(input);
|
||||
const accountId =
|
||||
(resolvedAccountId === undefined
|
||||
? input.target.accountId?.trim()
|
||||
: resolvedAccountId.trim()) || undefined;
|
||||
return params.isNativeDeliveryEnabled({ cfg: input.cfg, accountId });
|
||||
},
|
||||
native:
|
||||
params.resolveOriginTarget || params.resolveApproverDmTargets
|
||||
? {
|
||||
describeDeliveryCapabilities: ({
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ApprovalKind;
|
||||
request: NativeApprovalRequest;
|
||||
}) => ({
|
||||
enabled:
|
||||
params.hasApprovers({ cfg, accountId }) &&
|
||||
params.isNativeDeliveryEnabled({ cfg, accountId }),
|
||||
preferredSurface: normalizePreferredSurface(
|
||||
params.resolveNativeDeliveryMode({ cfg, accountId }),
|
||||
),
|
||||
supportsOriginSurface: Boolean(params.resolveOriginTarget),
|
||||
supportsApproverDmSurface: Boolean(params.resolveApproverDmTargets),
|
||||
notifyOriginWhenDmOnly: params.notifyOriginWhenDmOnly ?? false,
|
||||
}),
|
||||
resolveOriginTarget: params.resolveOriginTarget,
|
||||
resolveApproverDmTargets: params.resolveApproverDmTargets,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
native:
|
||||
params.resolveOriginTarget || params.resolveApproverDmTargets
|
||||
? {
|
||||
describeDeliveryCapabilities: ({
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ApprovalKind;
|
||||
request: NativeApprovalRequest;
|
||||
}) => ({
|
||||
enabled: isExecInitiatingSurfaceEnabled({ cfg, accountId }),
|
||||
preferredSurface: normalizePreferredSurface(
|
||||
params.resolveNativeDeliveryMode({ cfg, accountId }),
|
||||
),
|
||||
supportsOriginSurface: Boolean(params.resolveOriginTarget),
|
||||
supportsApproverDmSurface: Boolean(params.resolveApproverDmTargets),
|
||||
notifyOriginWhenDmOnly: params.notifyOriginWhenDmOnly ?? false,
|
||||
}),
|
||||
resolveOriginTarget: params.resolveOriginTarget,
|
||||
resolveApproverDmTargets: params.resolveApproverDmTargets,
|
||||
}
|
||||
: undefined,
|
||||
nativeRuntime: params.nativeRuntime,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,18 +197,32 @@ export function createApproverRestrictedNativeApprovalAdapter(
|
||||
export function createChannelApprovalCapability(params: {
|
||||
authorizeActorAction?: ChannelApprovalCapability["authorizeActorAction"];
|
||||
getActionAvailabilityState?: ChannelApprovalCapability["getActionAvailabilityState"];
|
||||
getExecInitiatingSurfaceState?: ChannelApprovalCapability["getExecInitiatingSurfaceState"];
|
||||
resolveApproveCommandBehavior?: ChannelApprovalCapability["resolveApproveCommandBehavior"];
|
||||
describeExecApprovalSetup?: ChannelApprovalCapability["describeExecApprovalSetup"];
|
||||
approvals?: Pick<ChannelApprovalCapability, "delivery" | "render" | "native">;
|
||||
delivery?: ChannelApprovalCapability["delivery"];
|
||||
nativeRuntime?: ChannelApprovalCapability["nativeRuntime"];
|
||||
render?: ChannelApprovalCapability["render"];
|
||||
native?: ChannelApprovalCapability["native"];
|
||||
/** @deprecated Pass delivery/nativeRuntime/render/native directly. */
|
||||
approvals?: ChannelApprovalCapabilitySurfaces;
|
||||
}): ChannelApprovalCapability {
|
||||
const surfaces: ChannelApprovalCapabilitySurfaces = {
|
||||
delivery: params.delivery ?? params.approvals?.delivery,
|
||||
nativeRuntime: params.nativeRuntime ?? params.approvals?.nativeRuntime,
|
||||
render: params.render ?? params.approvals?.render,
|
||||
native: params.native ?? params.approvals?.native,
|
||||
};
|
||||
return {
|
||||
authorizeActorAction: params.authorizeActorAction,
|
||||
getActionAvailabilityState: params.getActionAvailabilityState,
|
||||
getExecInitiatingSurfaceState: params.getExecInitiatingSurfaceState,
|
||||
resolveApproveCommandBehavior: params.resolveApproveCommandBehavior,
|
||||
describeExecApprovalSetup: params.describeExecApprovalSetup,
|
||||
delivery: params.approvals?.delivery,
|
||||
render: params.approvals?.render,
|
||||
native: params.approvals?.native,
|
||||
delivery: surfaces.delivery,
|
||||
nativeRuntime: surfaces.nativeRuntime,
|
||||
render: surfaces.render,
|
||||
native: surfaces.native,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,9 +230,11 @@ export function splitChannelApprovalCapability(capability: ChannelApprovalCapabi
|
||||
auth: {
|
||||
authorizeActorAction?: ChannelApprovalCapability["authorizeActorAction"];
|
||||
getActionAvailabilityState?: ChannelApprovalCapability["getActionAvailabilityState"];
|
||||
getExecInitiatingSurfaceState?: ChannelApprovalCapability["getExecInitiatingSurfaceState"];
|
||||
resolveApproveCommandBehavior?: ChannelApprovalCapability["resolveApproveCommandBehavior"];
|
||||
};
|
||||
delivery: ChannelApprovalCapability["delivery"];
|
||||
nativeRuntime: ChannelApprovalCapability["nativeRuntime"];
|
||||
render: ChannelApprovalCapability["render"];
|
||||
native: ChannelApprovalCapability["native"];
|
||||
describeExecApprovalSetup: ChannelApprovalCapability["describeExecApprovalSetup"];
|
||||
@@ -201,9 +243,11 @@ export function splitChannelApprovalCapability(capability: ChannelApprovalCapabi
|
||||
auth: {
|
||||
authorizeActorAction: capability.authorizeActorAction,
|
||||
getActionAvailabilityState: capability.getActionAvailabilityState,
|
||||
getExecInitiatingSurfaceState: capability.getExecInitiatingSurfaceState,
|
||||
resolveApproveCommandBehavior: capability.resolveApproveCommandBehavior,
|
||||
},
|
||||
delivery: capability.delivery,
|
||||
nativeRuntime: capability.nativeRuntime,
|
||||
render: capability.render,
|
||||
native: capability.native,
|
||||
describeExecApprovalSetup: capability.describeExecApprovalSetup,
|
||||
|
||||
31
src/plugin-sdk/approval-handler-runtime.ts
Normal file
31
src/plugin-sdk/approval-handler-runtime.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export {
|
||||
createChannelApprovalHandler,
|
||||
createChannelApprovalNativeRuntimeAdapter,
|
||||
createChannelApprovalHandlerFromCapability,
|
||||
createLazyChannelApprovalNativeRuntimeAdapter,
|
||||
resolveApprovalOverGateway,
|
||||
CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
|
||||
type ApprovalActionView,
|
||||
type ApprovalMetadataView,
|
||||
type ApprovalViewModel,
|
||||
type ExecApprovalExpiredView,
|
||||
type ExecApprovalPendingView,
|
||||
type ExecApprovalResolvedView,
|
||||
type ChannelApprovalNativeFinalAction,
|
||||
type ChannelApprovalNativeAvailabilityAdapter,
|
||||
type ChannelApprovalNativeInteractionAdapter,
|
||||
type ChannelApprovalNativeObserveAdapter,
|
||||
type ChannelApprovalNativePresentationAdapter,
|
||||
type ChannelApprovalNativeRuntimeAdapter,
|
||||
type ChannelApprovalNativeRuntimeSpec,
|
||||
type ChannelApprovalNativeTransportAdapter,
|
||||
type ChannelApprovalHandler,
|
||||
type ChannelApprovalHandlerAdapter,
|
||||
type ChannelApprovalCapabilityHandlerContext,
|
||||
type ExpiredApprovalView,
|
||||
type PendingApprovalView,
|
||||
type PluginApprovalExpiredView,
|
||||
type PluginApprovalPendingView,
|
||||
type PluginApprovalResolvedView,
|
||||
type ResolvedApprovalView,
|
||||
} from "../infra/approval-handler-runtime.js";
|
||||
@@ -3,11 +3,14 @@ export {
|
||||
createChannelNativeOriginTargetResolver,
|
||||
} from "./approval-native-helpers.js";
|
||||
export {
|
||||
resolveApprovalRequestSessionConversation,
|
||||
resolveApprovalRequestOriginTarget,
|
||||
resolveApprovalRequestSessionTarget,
|
||||
resolveExecApprovalSessionTarget,
|
||||
type ApprovalRequestSessionConversation,
|
||||
type ExecApprovalSessionTarget,
|
||||
} from "../infra/exec-approval-session-target.js";
|
||||
export { buildChannelApprovalNativeTargetKey } from "../infra/approval-native-target-key.js";
|
||||
export {
|
||||
doesApprovalRequestMatchChannelAccount,
|
||||
resolveApprovalRequestAccountId,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export {
|
||||
buildApprovalInteractiveReplyFromActionDescriptors,
|
||||
buildExecApprovalActionDescriptors,
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
getExecApprovalApproverDmNoticeText,
|
||||
getExecApprovalReplyMetadata,
|
||||
type ExecApprovalActionDescriptor,
|
||||
type ExecApprovalPendingReplyParams,
|
||||
type ExecApprovalReplyDecision,
|
||||
type ExecApprovalReplyMetadata,
|
||||
|
||||
6
src/plugin-sdk/channel-runtime-context.ts
Normal file
6
src/plugin-sdk/channel-runtime-context.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getChannelRuntimeContext,
|
||||
registerChannelRuntimeContext,
|
||||
watchChannelRuntimeContexts,
|
||||
type ChannelRuntimeContextKey,
|
||||
} from "../infra/channel-runtime-context.js";
|
||||
@@ -442,7 +442,7 @@ describe("plugin-sdk subpath exports", () => {
|
||||
resolve(REPO_ROOT, "extensions"),
|
||||
resolve(REPO_ROOT, "test"),
|
||||
],
|
||||
pattern: /openclaw\/plugin-sdk\/channel-runtime/u,
|
||||
pattern: /openclaw\/plugin-sdk\/channel-runtime(?=["'])/u,
|
||||
exclude: ["src/plugins/sdk-alias.test.ts"],
|
||||
});
|
||||
expect(matches).toEqual([]);
|
||||
|
||||
226
src/plugins/runtime/runtime-channel.test.ts
Normal file
226
src/plugins/runtime/runtime-channel.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeChannel } from "./runtime-channel.js";
|
||||
|
||||
describe("runtimeContexts", () => {
|
||||
it("registers, resolves, watches, and unregisters contexts", () => {
|
||||
const channel = createRuntimeChannel();
|
||||
const onEvent = vi.fn();
|
||||
const unsubscribe = channel.runtimeContexts.watch({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
onEvent,
|
||||
});
|
||||
|
||||
const lease = channel.runtimeContexts.register({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { client: "ok" },
|
||||
});
|
||||
|
||||
expect(
|
||||
channel.runtimeContexts.get<{ client: string }>({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
}),
|
||||
).toEqual({ client: "ok" });
|
||||
expect(onEvent).toHaveBeenCalledWith({
|
||||
type: "registered",
|
||||
key: {
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
},
|
||||
context: { client: "ok" },
|
||||
});
|
||||
|
||||
lease.dispose();
|
||||
|
||||
expect(
|
||||
channel.runtimeContexts.get({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(onEvent).toHaveBeenLastCalledWith({
|
||||
type: "unregistered",
|
||||
key: {
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
},
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("auto-disposes registrations when the abort signal fires", () => {
|
||||
const channel = createRuntimeChannel();
|
||||
const controller = new AbortController();
|
||||
const lease = channel.runtimeContexts.register({
|
||||
channelId: "telegram",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { token: "abc" },
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
|
||||
expect(
|
||||
channel.runtimeContexts.get({
|
||||
channelId: "telegram",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
lease.dispose();
|
||||
});
|
||||
|
||||
it("does not register contexts when the abort signal is already aborted", () => {
|
||||
const channel = createRuntimeChannel();
|
||||
const onEvent = vi.fn();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
channel.runtimeContexts.watch({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
onEvent,
|
||||
});
|
||||
|
||||
const lease = channel.runtimeContexts.register({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { client: "stale" },
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
expect(
|
||||
channel.runtimeContexts.get({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
lease.dispose();
|
||||
});
|
||||
|
||||
it("isolates watcher exceptions so registration and disposal still complete", () => {
|
||||
const channel = createRuntimeChannel();
|
||||
const badWatcher = vi.fn((event) => {
|
||||
throw new Error(`boom:${event.type}`);
|
||||
});
|
||||
const goodWatcher = vi.fn();
|
||||
|
||||
channel.runtimeContexts.watch({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
onEvent: badWatcher,
|
||||
});
|
||||
channel.runtimeContexts.watch({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
onEvent: goodWatcher,
|
||||
});
|
||||
|
||||
const lease = channel.runtimeContexts.register({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { client: "ok" },
|
||||
});
|
||||
|
||||
expect(
|
||||
channel.runtimeContexts.get({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
}),
|
||||
).toEqual({ client: "ok" });
|
||||
expect(badWatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "registered",
|
||||
}),
|
||||
);
|
||||
expect(goodWatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "registered",
|
||||
}),
|
||||
);
|
||||
|
||||
lease.dispose();
|
||||
|
||||
expect(
|
||||
channel.runtimeContexts.get({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(badWatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "unregistered",
|
||||
}),
|
||||
);
|
||||
expect(goodWatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "unregistered",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-disposes when a watcher aborts during the registered event", () => {
|
||||
const channel = createRuntimeChannel();
|
||||
const controller = new AbortController();
|
||||
const onEvent = vi.fn((event) => {
|
||||
if (event.type === "registered") {
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
channel.runtimeContexts.watch({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
onEvent,
|
||||
});
|
||||
|
||||
const lease = channel.runtimeContexts.register({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
context: { client: "ok" },
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
expect(
|
||||
channel.runtimeContexts.get({
|
||||
channelId: "matrix",
|
||||
accountId: "default",
|
||||
capability: "approval.native",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(onEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
type: "registered",
|
||||
}),
|
||||
);
|
||||
expect(onEvent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
type: "unregistered",
|
||||
}),
|
||||
);
|
||||
|
||||
lease.dispose();
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
updateLastRoute,
|
||||
} from "../../config/sessions.js";
|
||||
import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js";
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
import { fetchRemoteMedia } from "../../media/fetch.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
@@ -66,9 +67,103 @@ import {
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import type {
|
||||
PluginRuntimeChannelContextEvent,
|
||||
PluginRuntimeChannelContextKey,
|
||||
} from "./types-channel.js";
|
||||
import type { PluginRuntime } from "./types.js";
|
||||
|
||||
type StoredRuntimeContext = {
|
||||
token: symbol;
|
||||
context: unknown;
|
||||
normalizedKey: {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
capability: string;
|
||||
};
|
||||
};
|
||||
|
||||
const log = createSubsystemLogger("plugins/runtime-channel");
|
||||
|
||||
function normalizeRuntimeContextString(value: string | null | undefined): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function normalizeRuntimeContextKey(params: PluginRuntimeChannelContextKey): {
|
||||
mapKey: string;
|
||||
normalizedKey: {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
capability: string;
|
||||
};
|
||||
} | null {
|
||||
const channelId = normalizeRuntimeContextString(params.channelId);
|
||||
const capability = normalizeRuntimeContextString(params.capability);
|
||||
const accountId = normalizeRuntimeContextString(params.accountId);
|
||||
if (!channelId || !capability) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mapKey: `${channelId}\u0000${accountId}\u0000${capability}`,
|
||||
normalizedKey: {
|
||||
channelId,
|
||||
capability,
|
||||
...(accountId ? { accountId } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function doesRuntimeContextWatcherMatch(params: {
|
||||
watcher: {
|
||||
channelId?: string;
|
||||
accountId?: string;
|
||||
capability?: string;
|
||||
};
|
||||
event: PluginRuntimeChannelContextEvent;
|
||||
}): boolean {
|
||||
if (params.watcher.channelId && params.watcher.channelId !== params.event.key.channelId) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
params.watcher.accountId !== undefined &&
|
||||
params.watcher.accountId !== (params.event.key.accountId ?? "")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (params.watcher.capability && params.watcher.capability !== params.event.key.capability) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createRuntimeChannel(): PluginRuntime["channel"] {
|
||||
const runtimeContexts = new Map<string, StoredRuntimeContext>();
|
||||
const runtimeContextWatchers = new Set<{
|
||||
filter: {
|
||||
channelId?: string;
|
||||
accountId?: string;
|
||||
capability?: string;
|
||||
};
|
||||
onEvent: (event: PluginRuntimeChannelContextEvent) => void;
|
||||
}>();
|
||||
const emitRuntimeContextEvent = (event: PluginRuntimeChannelContextEvent) => {
|
||||
for (const watcher of runtimeContextWatchers) {
|
||||
if (!doesRuntimeContextWatcherMatch({ watcher: watcher.filter, event })) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
watcher.onEvent(event);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(
|
||||
`runtime context watcher failed during ${event.type} ` +
|
||||
`channel=${event.key.channelId} capability=${event.key.capability}` +
|
||||
(event.key.accountId ? ` account=${event.key.accountId}` : "") +
|
||||
`: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const channelRuntime = {
|
||||
text: {
|
||||
chunkByNewline,
|
||||
@@ -172,6 +267,74 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
|
||||
maxAgeMs,
|
||||
}),
|
||||
},
|
||||
runtimeContexts: {
|
||||
register: (params) => {
|
||||
const normalized = normalizeRuntimeContextKey(params);
|
||||
if (!normalized) {
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
if (params.abortSignal?.aborted) {
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
const token = Symbol(normalized.mapKey);
|
||||
let disposed = false;
|
||||
const dispose = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
const current = runtimeContexts.get(normalized.mapKey);
|
||||
if (!current || current.token !== token) {
|
||||
return;
|
||||
}
|
||||
runtimeContexts.delete(normalized.mapKey);
|
||||
emitRuntimeContextEvent({
|
||||
type: "unregistered",
|
||||
key: normalized.normalizedKey,
|
||||
});
|
||||
};
|
||||
params.abortSignal?.addEventListener("abort", dispose, { once: true });
|
||||
if (params.abortSignal?.aborted) {
|
||||
dispose();
|
||||
return { dispose };
|
||||
}
|
||||
runtimeContexts.set(normalized.mapKey, {
|
||||
token,
|
||||
context: params.context,
|
||||
normalizedKey: normalized.normalizedKey,
|
||||
});
|
||||
if (disposed) {
|
||||
return { dispose };
|
||||
}
|
||||
emitRuntimeContextEvent({
|
||||
type: "registered",
|
||||
key: normalized.normalizedKey,
|
||||
context: params.context,
|
||||
});
|
||||
return { dispose };
|
||||
},
|
||||
get: <T = unknown>(params: PluginRuntimeChannelContextKey) => {
|
||||
const normalized = normalizeRuntimeContextKey(params);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return runtimeContexts.get(normalized.mapKey)?.context as T | undefined;
|
||||
},
|
||||
watch: (params) => {
|
||||
const watcher = {
|
||||
filter: {
|
||||
...(params.channelId?.trim() ? { channelId: params.channelId.trim() } : {}),
|
||||
...(params.accountId != null ? { accountId: params.accountId.trim() } : {}),
|
||||
...(params.capability?.trim() ? { capability: params.capability.trim() } : {}),
|
||||
},
|
||||
onEvent: params.onEvent,
|
||||
};
|
||||
runtimeContextWatchers.add(watcher);
|
||||
return () => {
|
||||
runtimeContextWatchers.delete(watcher);
|
||||
};
|
||||
},
|
||||
},
|
||||
} satisfies PluginRuntime["channel"];
|
||||
|
||||
return channelRuntime as PluginRuntime["channel"];
|
||||
|
||||
@@ -29,6 +29,38 @@ export type RuntimeThreadBindingLifecycleRecord =
|
||||
maxAgeMs?: number;
|
||||
};
|
||||
|
||||
export type PluginRuntimeChannelContextKey = {
|
||||
channelId: string;
|
||||
accountId?: string | null;
|
||||
capability: string;
|
||||
};
|
||||
|
||||
export type PluginRuntimeChannelContextEvent = {
|
||||
type: "registered" | "unregistered";
|
||||
key: {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
capability: string;
|
||||
};
|
||||
context?: unknown;
|
||||
};
|
||||
|
||||
export type PluginRuntimeChannelContextRegistry = {
|
||||
register: (
|
||||
params: PluginRuntimeChannelContextKey & {
|
||||
context: unknown;
|
||||
abortSignal?: AbortSignal;
|
||||
},
|
||||
) => { dispose: () => void };
|
||||
get: <T = unknown>(params: PluginRuntimeChannelContextKey) => T | undefined;
|
||||
watch: (params: {
|
||||
channelId?: string;
|
||||
accountId?: string | null;
|
||||
capability?: string;
|
||||
onEvent: (event: PluginRuntimeChannelContextEvent) => void;
|
||||
}) => () => void;
|
||||
};
|
||||
|
||||
export type PluginRuntimeChannel = {
|
||||
text: {
|
||||
chunkByNewline: typeof import("../../auto-reply/chunk.js").chunkByNewline;
|
||||
@@ -121,4 +153,5 @@ export type PluginRuntimeChannel = {
|
||||
maxAgeMs: number;
|
||||
}) => RuntimeThreadBindingLifecycleRecord[];
|
||||
};
|
||||
runtimeContexts: PluginRuntimeChannelContextRegistry;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user