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:
Gustavo Madeira Santana
2026-04-07 14:40:26 -04:00
committed by GitHub
parent 4108901932
commit d78512b09d
128 changed files with 8839 additions and 3995 deletions

View File

@@ -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",

View File

@@ -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"

View File

@@ -57,6 +57,7 @@ function createLifecycleContext(params: {
pendingMessagingTargets: new Map(),
successfulCronAdds: 0,
pendingMessagingMediaUrls: new Map(),
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
} as never,
log: {

View File

@@ -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: [],

View File

@@ -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" ||

View File

@@ -35,6 +35,7 @@ function createMockContext(overrides?: {
messagingToolSentTextsNormalized: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
},
log: { debug: vi.fn(), warn: vi.fn() },

View File

@@ -47,6 +47,7 @@ function createTestContext(): {
pendingMessagingMediaUrls: new Map<string, string[]>(),
pendingToolMediaUrls: [],
pendingToolAudioAsVoice: false,
deterministicApprovalPromptPending: false,
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
messagingToolSentMediaUrls: [],

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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 () => {

View File

@@ -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);
};

View File

@@ -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[],

View File

@@ -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",
});
});
});
});

View File

@@ -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 }

View 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.");
});
});

View File

@@ -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).

View File

@@ -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,
});

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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;

View 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",
}),
);
});
});

View File

@@ -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 () => {

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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);

View 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);
});
});

View 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();
});
}
}

View 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();
});
});

View 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();
};
}

View 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);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View 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();
});
});

View 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;
}

View 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();
});
});

View 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.`;
}

View File

@@ -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: {

View File

@@ -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();
},
};
}

View 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",
}),
);
});
});

View 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()
}`;
}

View 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");
}

View File

@@ -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",
});
});
});

View File

@@ -34,6 +34,7 @@ export function resolveApprovalCommandAuthorization(params: {
cfg: params.cfg,
accountId: params.accountId,
action: "approve",
approvalKind: params.kind,
});
return {
authorized: resolved.authorized,

View 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();
}
},
};
}

View File

@@ -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");
},

View File

@@ -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: {

View File

@@ -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({

View File

@@ -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),
};
}

View File

@@ -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);
});
});

View File

@@ -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,
);
}

View File

@@ -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",

View File

@@ -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",
});
});

View File

@@ -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,
});
});
});

View File

@@ -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,

View 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";

View File

@@ -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,

View File

@@ -1,7 +1,10 @@
export {
buildApprovalInteractiveReplyFromActionDescriptors,
buildExecApprovalActionDescriptors,
buildExecApprovalPendingReplyPayload,
getExecApprovalApproverDmNoticeText,
getExecApprovalReplyMetadata,
type ExecApprovalActionDescriptor,
type ExecApprovalPendingReplyParams,
type ExecApprovalReplyDecision,
type ExecApprovalReplyMetadata,

View File

@@ -0,0 +1,6 @@
export {
getChannelRuntimeContext,
registerChannelRuntimeContext,
watchChannelRuntimeContexts,
type ChannelRuntimeContextKey,
} from "../infra/channel-runtime-context.js";

View File

@@ -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([]);

View 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();
});
});

View File

@@ -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"];

View File

@@ -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;
};