feat(plugin-sdk): consolidate session workflow APIs

This commit is contained in:
Eva
2026-05-10 14:56:17 +01:00
committed by Peter Steinberger
parent a41f1e8d63
commit 50ee68cdf4
73 changed files with 6503 additions and 126 deletions

View File

@@ -5,6 +5,21 @@ import Foundation
public let GATEWAY_PROTOCOL_VERSION = 4
public let GATEWAY_MIN_PROTOCOL_VERSION = 3
private struct GatewayAnyCodingKey: CodingKey, Hashable {
let stringValue: String
let intValue: Int?
init?(stringValue: String) {
self.stringValue = stringValue
self.intValue = nil
}
init?(intValue: Int) {
self.stringValue = String(intValue)
self.intValue = intValue
}
}
public enum ErrorCode: String, Codable, Sendable {
case notLinked = "NOT_LINKED"
case notPaired = "NOT_PAIRED"
@@ -579,6 +594,9 @@ public struct SendParams: Codable, Sendable {
public let agentid: String?
public let replytoid: String?
public let threadid: String?
public let forcedocument: Bool?
public let silent: Bool?
public let parsemode: String?
public let sessionkey: String?
public let idempotencykey: String
@@ -594,6 +612,9 @@ public struct SendParams: Codable, Sendable {
agentid: String?,
replytoid: String?,
threadid: String?,
forcedocument: Bool?,
silent: Bool?,
parsemode: String?,
sessionkey: String?,
idempotencykey: String)
{
@@ -608,6 +629,9 @@ public struct SendParams: Codable, Sendable {
self.agentid = agentid
self.replytoid = replytoid
self.threadid = threadid
self.forcedocument = forcedocument
self.silent = silent
self.parsemode = parsemode
self.sessionkey = sessionkey
self.idempotencykey = idempotencykey
}
@@ -624,6 +648,9 @@ public struct SendParams: Codable, Sendable {
case agentid = "agentId"
case replytoid = "replyToId"
case threadid = "threadId"
case forcedocument = "forceDocument"
case silent
case parsemode = "parseMode"
case sessionkey = "sessionKey"
case idempotencykey = "idempotencyKey"
}
@@ -5707,6 +5734,156 @@ public struct PluginControlUiDescriptor: Codable, Sendable {
}
}
public struct PluginsSessionActionFailureResult: Codable, Sendable {
public let ok: Bool
public let error: String
public let code: String?
public let details: AnyCodable?
public init(
error: String,
code: String?,
details: AnyCodable?
)
{
self.ok = false
self.error = error
self.code = code
self.details = details
}
private enum CodingKeys: String, CodingKey {
case ok
case error
case code
case details
}
public init(from decoder: Decoder) throws {
let rawContainer = try decoder.container(keyedBy: GatewayAnyCodingKey.self)
let unexpectedKeys = rawContainer.allKeys
.map(\.stringValue)
.filter { !Set(["ok", "error", "code", "details"]).contains($0) }
if !unexpectedKeys.isEmpty {
throw DecodingError.dataCorrupted(
.init(
codingPath: rawContainer.codingPath,
debugDescription: "Unexpected keys for PluginsSessionActionFailureResult: \(unexpectedKeys.sorted().joined(separator: ", "))"
)
)
}
let container = try decoder.container(keyedBy: CodingKeys.self)
let decodedOk = try container.decode(Bool.self, forKey: .ok)
guard decodedOk == false else {
throw DecodingError.dataCorruptedError(
forKey: .ok,
in: container,
debugDescription: "Expected ok to equal false"
)
}
self.ok = false
self.error = try container.decode(String.self, forKey: .error)
self.code = try container.decodeIfPresent(String.self, forKey: .code)
self.details = try container.decodeIfPresent(AnyCodable.self, forKey: .details)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(false, forKey: .ok)
try container.encode(error, forKey: .error)
try container.encodeIfPresent(code, forKey: .code)
try container.encodeIfPresent(details, forKey: .details)
}
}
public struct PluginsSessionActionParams: Codable, Sendable {
public let pluginid: String
public let actionid: String
public let sessionkey: String?
public let payload: AnyCodable?
public init(
pluginid: String,
actionid: String,
sessionkey: String?,
payload: AnyCodable?)
{
self.pluginid = pluginid
self.actionid = actionid
self.sessionkey = sessionkey
self.payload = payload
}
private enum CodingKeys: String, CodingKey {
case pluginid = "pluginId"
case actionid = "actionId"
case sessionkey = "sessionKey"
case payload
}
}
public struct PluginsSessionActionSuccessResult: Codable, Sendable {
public let ok: Bool
public let result: AnyCodable?
public let continueagent: Bool?
public let reply: AnyCodable?
public init(
result: AnyCodable?,
continueagent: Bool?,
reply: AnyCodable?
)
{
self.ok = true
self.result = result
self.continueagent = continueagent
self.reply = reply
}
private enum CodingKeys: String, CodingKey {
case ok
case result
case continueagent = "continueAgent"
case reply
}
public init(from decoder: Decoder) throws {
let rawContainer = try decoder.container(keyedBy: GatewayAnyCodingKey.self)
let unexpectedKeys = rawContainer.allKeys
.map(\.stringValue)
.filter { !Set(["ok", "result", "continueAgent", "reply"]).contains($0) }
if !unexpectedKeys.isEmpty {
throw DecodingError.dataCorrupted(
.init(
codingPath: rawContainer.codingPath,
debugDescription: "Unexpected keys for PluginsSessionActionSuccessResult: \(unexpectedKeys.sorted().joined(separator: ", "))"
)
)
}
let container = try decoder.container(keyedBy: CodingKeys.self)
let decodedOk = try container.decode(Bool.self, forKey: .ok)
guard decodedOk == true else {
throw DecodingError.dataCorruptedError(
forKey: .ok,
in: container,
debugDescription: "Expected ok to equal true"
)
}
self.ok = true
self.result = try container.decodeIfPresent(AnyCodable.self, forKey: .result)
self.continueagent = try container.decodeIfPresent(Bool.self, forKey: .continueagent)
self.reply = try container.decodeIfPresent(AnyCodable.self, forKey: .reply)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(true, forKey: .ok)
try container.encodeIfPresent(result, forKey: .result)
try container.encodeIfPresent(continueagent, forKey: .continueagent)
try container.encodeIfPresent(reply, forKey: .reply)
}
}
public struct PluginsUiDescriptorsParams: Codable, Sendable {}
public struct PluginsUiDescriptorsResult: Codable, Sendable {
@@ -6157,6 +6334,37 @@ public struct ShutdownEvent: Codable, Sendable {
}
}
public enum PluginsSessionActionResult: Codable, Sendable {
case success(PluginsSessionActionSuccessResult)
case failure(PluginsSessionActionFailureResult)
private enum CodingKeys: String, CodingKey {
case discriminator = "ok"
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let discriminator = try container.decode(Bool.self, forKey: .discriminator)
switch discriminator {
case true: self = try .success(PluginsSessionActionSuccessResult(from: decoder))
case false: self = try .failure(PluginsSessionActionFailureResult(from: decoder))
default:
throw DecodingError.dataCorruptedError(
forKey: .discriminator,
in: container,
debugDescription: "Unknown PluginsSessionActionResult discriminator value"
)
}
}
public func encode(to encoder: Encoder) throws {
switch self {
case .success(let value): try value.encode(to: encoder)
case .failure(let value): try value.encode(to: encoder)
}
}
}
public enum GatewayFrame: Codable, Sendable {
case req(RequestFrame)
case res(ResponseFrame)

View File

@@ -0,0 +1,2 @@
export { GoogleChatConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema";
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";

View File

@@ -1,3 +1,3 @@
import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../runtime-api.js";
import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../config-api.js";
export const GoogleChatChannelConfigSchema = buildChannelConfigSchema(GoogleChatConfigSchema);

View File

@@ -27,6 +27,7 @@ type MatrixHandlerTestHarnessOptions = {
client?: Partial<MatrixClient>;
runtime?: RuntimeEnv;
logger?: RuntimeLogger;
currentConfig?: () => unknown;
logVerboseMessage?: (message: string) => void;
allowFrom?: string[];
allowFromResolvedEntries?: MatrixMonitorHandlerParams["allowFromResolvedEntries"];
@@ -193,7 +194,7 @@ export function createMatrixHandlerTestHarness(
} as never,
core: {
config: {
current: () => options.liveCfg ?? cfgForHandler,
current: options.currentConfig ?? (() => options.liveCfg ?? cfgForHandler),
},
channel: {
pairing: {

View File

@@ -367,6 +367,15 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
updateLastRoute: expect.objectContaining({
channel: "matrix",
to: "room:!dm:example.org",
mainDmOwnerPin: undefined,
}),
}),
);
const inbound = requireRecord(
callArg(recordInboundSession, 0, 0, "record inbound session"),
"record inbound session",

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { escapeTelegramHtml, markdownToTelegramHtml } from "./api.js";
describe("@openclaw/telegram api re-exports", () => {
it("re-exports markdownToTelegramHtml as a working function", () => {
expect(typeof markdownToTelegramHtml).toBe("function");
const rendered = markdownToTelegramHtml("**bold** plain");
expect(rendered).toContain("<b>");
expect(rendered).toContain("plain");
});
it("re-exports escapeTelegramHtml that escapes Telegram-reserved characters", () => {
expect(typeof escapeTelegramHtml).toBe("function");
expect(escapeTelegramHtml("<b>x & y</b>")).toBe("&lt;b&gt;x &amp; y&lt;/b&gt;");
});
});

View File

@@ -182,3 +182,11 @@ export {
export type { TelegramButtonStyle, TelegramInlineButtons } from "./src/button-types.js";
export type { StickerMetadata } from "./src/bot/types.js";
export type { TelegramTokenResolution } from "./src/token.js";
export {
escapeTelegramHtml,
markdownToTelegramChunks,
markdownToTelegramHtml,
markdownToTelegramHtmlChunks,
splitTelegramHtmlChunks,
type TelegramFormattedChunk,
} from "./src/format.js";

View File

@@ -12,6 +12,7 @@ import type { TelegramRuntime } from "./runtime.types.js";
const probeTelegram = vi.fn();
const monitorTelegramProvider = vi.fn();
const sendMessageTelegram = vi.fn();
function installTelegramRuntime() {
const runtime = createPluginRuntimeMock();
@@ -22,6 +23,7 @@ function installTelegramRuntime() {
telegram: {
probeTelegram: probeTelegram as TelegramProbeFn,
monitorTelegramProvider: monitorTelegramProvider as TelegramMonitorFn,
sendMessageTelegram,
},
},
} as unknown as TelegramRuntime);
@@ -80,6 +82,7 @@ afterEach(() => {
clearTelegramRuntime();
probeTelegram.mockReset();
monitorTelegramProvider.mockReset();
sendMessageTelegram.mockReset();
});
describe("telegramPlugin gateway startup", () => {
@@ -220,3 +223,47 @@ describe("telegramPlugin gateway startup", () => {
);
});
});
describe("telegramPlugin outbound attachments", () => {
it("preserves default markdown rendering unless a parse mode is explicit", async () => {
installTelegramRuntime();
sendMessageTelegram.mockResolvedValue({ messageId: "tg-1", chatId: "12345" });
const sendText = telegramPlugin.outbound?.sendText;
expect(sendText).toBeDefined();
await sendText!({
cfg: createTelegramConfig(),
to: "12345",
text: "hi **boss**",
});
expect(sendMessageTelegram.mock.calls[0]?.[2]).not.toHaveProperty("textMode");
await sendText!({
cfg: createTelegramConfig(),
to: "12345",
text: "<b>hi boss</b>",
formatting: { parseMode: "HTML" },
});
expect(sendMessageTelegram.mock.calls[1]?.[2]).toMatchObject({ textMode: "html" });
});
it("preserves explicit HTML parse mode for payload media captions", async () => {
installTelegramRuntime();
sendMessageTelegram.mockResolvedValue({ messageId: "tg-payload", chatId: "12345" });
const sendPayload = telegramPlugin.outbound?.sendPayload;
expect(sendPayload).toBeDefined();
await sendPayload!({
cfg: createTelegramConfig(),
to: "12345",
text: "",
payload: {
text: "<b>report</b>",
mediaUrl: "https://example.com/report.png",
},
formatting: { parseMode: "HTML" },
});
expect(sendMessageTelegram.mock.calls[0]?.[2]).toMatchObject({ textMode: "html" });
});
});

View File

@@ -10,15 +10,19 @@ import {
} from "openclaw/plugin-sdk/text-chunking";
import { renderMarkdownWithMarkers } from "openclaw/plugin-sdk/text-chunking";
type TelegramFormattedChunk = {
export type TelegramFormattedChunk = {
html: string;
text: string;
};
function escapeHtml(text: string): string {
export function escapeTelegramHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeHtml(text: string): string {
return escapeTelegramHtml(text);
}
function escapeHtmlAttr(text: string): string {
return escapeHtml(text).replace(/"/g, "&quot;");
}

View File

@@ -74,13 +74,17 @@ describe("telegramOutbound", () => {
deps: { sendTelegram: sendMessageTelegramMock },
});
const options = callOptionsAt(sendMessageTelegramMock, 0, "12345", "hello");
expect(options.mediaUrl).toBe("/tmp/image.png");
expect(options.mediaLocalRoots).toEqual(["/tmp/agent-root"]);
expect(options.accountId).toBe("ops");
expect(options.replyToMessageId).toBe(900);
expect(options.messageThreadId).toBe(12);
expect(options.textMode).toBe("html");
expect(sendMessageTelegramMock).toHaveBeenCalledWith(
"12345",
"hello",
expect.objectContaining({
mediaUrl: "/tmp/image.png",
mediaLocalRoots: ["/tmp/agent-root"],
accountId: "ops",
replyToMessageId: 900,
messageThreadId: 12,
}),
);
expect(result).toEqual({ channel: "telegram", messageId: "tg-media" });
});
@@ -189,6 +193,7 @@ describe("telegramOutbound", () => {
cfg: {} as never,
to: "12345",
text: "hello",
formatting: { parseMode: "HTML" },
deps: { sendTelegram: sendMessageTelegramMock },
});
const options = lastCallOptions(sendMessageTelegramMock, "12345", "hello");

View File

@@ -8,6 +8,7 @@ import {
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
import type { OutboundDeliveryFormattingOptions } from "openclaw/plugin-sdk/outbound-runtime";
import {
resolveOutboundSendDep,
type OutboundSendDeps,
@@ -19,7 +20,7 @@ import {
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import { markdownToTelegramHtmlChunks } from "./format.js";
import { markdownToTelegramHtmlChunks, splitTelegramHtmlChunks } from "./format.js";
import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js";
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
@@ -46,12 +47,23 @@ async function resolveDefaultTelegramSend(deps?: OutboundSendDeps): Promise<Tele
);
}
function chunkTelegramOutboundText(
text: string,
limit: number,
ctx?: { formatting?: OutboundDeliveryFormattingOptions },
): string[] {
return ctx?.formatting?.parseMode === "HTML"
? splitTelegramHtmlChunks(text, limit)
: markdownToTelegramHtmlChunks(text, limit);
}
async function resolveTelegramSendContext(params: {
cfg: NonNullable<TelegramSendOpts>["cfg"];
deps?: OutboundSendDeps;
accountId?: string | null;
replyToId?: string | null;
threadId?: string | number | null;
formatting?: OutboundDeliveryFormattingOptions;
silent?: boolean;
gatewayClientScopes?: readonly string[];
resolveSend: ResolveTelegramSendFn;
@@ -60,7 +72,7 @@ async function resolveTelegramSendContext(params: {
baseOpts: {
cfg: NonNullable<TelegramSendOpts>["cfg"];
verbose: false;
textMode: "html";
textMode?: "html";
messageThreadId?: number;
replyToMessageId?: number;
accountId?: string;
@@ -73,13 +85,13 @@ async function resolveTelegramSendContext(params: {
send,
baseOpts: {
verbose: false,
textMode: "html",
cfg: params.cfg,
messageThreadId: parseTelegramThreadId(params.threadId),
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
accountId: params.accountId ?? undefined,
silent: params.silent,
gatewayClientScopes: params.gatewayClientScopes,
...(params.formatting?.parseMode === "HTML" ? { textMode: "html" as const } : {}),
},
};
}
@@ -148,8 +160,9 @@ export function createTelegramOutboundAdapter(
return {
deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks,
chunker: chunkTelegramOutboundText,
chunkerMode: "markdown",
chunkedTextFormatting: { parseMode: "HTML" },
extractMarkdownImages: true,
textChunkLimit: TELEGRAM_TEXT_CHUNK_LIMIT,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
@@ -209,6 +222,7 @@ export function createTelegramOutboundAdapter(
deps,
replyToId,
threadId,
formatting,
silent,
gatewayClientScopes,
}) => {
@@ -218,6 +232,7 @@ export function createTelegramOutboundAdapter(
accountId,
replyToId,
threadId,
formatting,
silent,
gatewayClientScopes,
resolveSend,
@@ -237,6 +252,7 @@ export function createTelegramOutboundAdapter(
deps,
replyToId,
threadId,
formatting,
forceDocument,
silent,
gatewayClientScopes,
@@ -247,6 +263,7 @@ export function createTelegramOutboundAdapter(
accountId,
replyToId,
threadId,
formatting,
silent,
gatewayClientScopes,
resolveSend,
@@ -270,6 +287,7 @@ export function createTelegramOutboundAdapter(
deps,
replyToId,
threadId,
formatting,
forceDocument,
silent,
gatewayClientScopes,
@@ -280,6 +298,7 @@ export function createTelegramOutboundAdapter(
accountId,
replyToId,
threadId,
formatting,
silent,
gatewayClientScopes,
resolveSend,

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { markdownToTelegramHtmlChunks } from "./format.js";
import { markdownToTelegramHtmlChunks, splitTelegramHtmlChunks } from "./format.js";
import { telegramOutbound } from "./outbound-adapter.js";
import { clearTelegramRuntime } from "./runtime.js";
@@ -12,7 +12,20 @@ describe("telegramPlugin outbound", () => {
expect(telegramOutbound.chunker?.(text, 4000)).toEqual(expected);
expect(telegramOutbound.deliveryMode).toBe("direct");
expect(telegramOutbound.chunkerMode).toBe("markdown");
expect(telegramOutbound.chunkedTextFormatting).toEqual({ parseMode: "HTML" });
expect(telegramOutbound.textChunkLimit).toBe(4000);
expect(telegramOutbound.pollMaxOptions).toBe(10);
});
it("preserves explicit HTML parse mode before chunking", () => {
clearTelegramRuntime();
const text = "<b>hi</b>";
expect(telegramOutbound.chunker?.(text, 4000, { formatting: { parseMode: "HTML" } })).toEqual(
splitTelegramHtmlChunks(text, 4000),
);
expect(telegramOutbound.chunker?.(text, 4000)).toEqual(
markdownToTelegramHtmlChunks(text, 4000),
);
});
});

View File

@@ -10,11 +10,15 @@ import {
type JsonSchema = {
type?: string | string[];
const?: boolean | number | string | null;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: string[];
patternProperties?: Record<string, JsonSchema>;
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
additionalProperties?: boolean | JsonSchema;
};
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -31,7 +35,12 @@ const outPaths = [
),
];
const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\n// swiftlint:disable file_length\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\npublic let GATEWAY_MIN_PROTOCOL_VERSION = ${MIN_CLIENT_PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values(
const STRICT_LITERAL_STRUCTS = new Set([
"PluginsSessionActionSuccessResult",
"PluginsSessionActionFailureResult",
]);
const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\n// swiftlint:disable file_length\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\npublic let GATEWAY_MIN_PROTOCOL_VERSION = ${MIN_CLIENT_PROTOCOL_VERSION}\n\nprivate struct GatewayAnyCodingKey: CodingKey, Hashable {\n let stringValue: String\n let intValue: Int?\n\n init?(stringValue: String) {\n self.stringValue = stringValue\n self.intValue = nil\n }\n\n init?(intValue: Int) {\n self.stringValue = String(intValue)\n self.intValue = intValue\n }\n}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values(
ErrorCodes,
)
.map((c) => ` case ${camelCase(c)} = "${c}"`)
@@ -172,10 +181,111 @@ function emitEnum(name: string, schema: JsonSchema): string {
function emitStruct(name: string, schema: JsonSchema): string {
const props = schema.properties ?? {};
const required = new Set(schema.required ?? []);
const literalProps = Object.entries(props)
.map(([key, propSchema]) => ({
key,
propSchema,
literal: literalSchemaValue(propSchema),
}))
.filter(
(
entry,
): entry is {
key: string;
propSchema: JsonSchema;
literal: boolean | number | string | null;
} => entry.literal !== undefined,
);
const lines: string[] = [];
if (Object.keys(props).length === 0) {
return `public struct ${name}: Codable, Sendable {}\n`;
}
if (STRICT_LITERAL_STRUCTS.has(name) && literalProps.length > 0) {
const literalPropByKey = new Map(literalProps.map((entry) => [entry.key, entry.literal]));
lines.push(`public struct ${name}: Codable, Sendable {`);
const codingKeys: string[] = [];
for (const [key, propSchema] of Object.entries(props)) {
const propName = safeName(key);
const propType = swiftType(propSchema, required.has(key), true);
lines.push(` public let ${propName}: ${propType}`);
if (propName !== key) {
codingKeys.push(` case ${propName} = "${key}"`);
} else {
codingKeys.push(` case ${propName}`);
}
}
const initializerParams = Object.entries(props)
.filter(([key]) => !literalPropByKey.has(key))
.map(([key, prop]) => {
const propName = safeName(key);
const req = required.has(key);
return ` ${propName}: ${swiftType(prop, true, true)}${req ? "" : "?"}`;
});
lines.push(
"\n public init(\n" +
(initializerParams.length > 0 ? initializerParams.join(",\n") : " ") +
"\n )\n" +
" {\n" +
Object.entries(props)
.map(([key]) => {
const propName = safeName(key);
if (literalPropByKey.has(key)) {
return ` self.${propName} = ${swiftLiteralSource(literalPropByKey.get(key)!)}`;
}
return ` self.${propName} = ${propName}`;
})
.join("\n") +
"\n }\n\n" +
" private enum CodingKeys: String, CodingKey {\n" +
codingKeys.join("\n") +
"\n }\n\n" +
" public init(from decoder: Decoder) throws {\n" +
(schema.additionalProperties === false
? ` let rawContainer = try decoder.container(keyedBy: GatewayAnyCodingKey.self)\n let unexpectedKeys = rawContainer.allKeys\n .map(\\.stringValue)\n .filter { !Set([${Object.keys(
props,
)
.map((key) => JSON.stringify(key))
.join(
", ",
)}]).contains($0) }\n if !unexpectedKeys.isEmpty {\n throw DecodingError.dataCorrupted(\n .init(\n codingPath: rawContainer.codingPath,\n debugDescription: "Unexpected keys for ${name}: \\(unexpectedKeys.sorted().joined(separator: ", "))"\n )\n )\n }\n`
: "") +
" let container = try decoder.container(keyedBy: CodingKeys.self)\n" +
Object.entries(props)
.map(([key, propSchema]) => {
const propName = safeName(key);
const capitalizedPropName = propName.slice(0, 1).toUpperCase() + propName.slice(1);
const literal = literalPropByKey.get(key);
if (literal !== undefined) {
const literalType = swiftType(propSchema, true, true);
return ` let decoded${capitalizedPropName} = try container.decode(${literalType}.self, forKey: .${propName})\n guard decoded${capitalizedPropName} == ${swiftLiteralSource(literal)} else {\n throw DecodingError.dataCorruptedError(\n forKey: .${propName},\n in: container,\n debugDescription: "Expected ${key} to equal ${String(literal)}"\n )\n }\n self.${propName} = ${swiftLiteralSource(literal)}`;
}
if (required.has(key)) {
return ` self.${propName} = try container.decode(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
}
return ` self.${propName} = try container.decodeIfPresent(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
})
.join("\n") +
"\n }\n\n" +
" public func encode(to encoder: Encoder) throws {\n" +
" var container = encoder.container(keyedBy: CodingKeys.self)\n" +
Object.entries(props)
.map(([key]) => {
const propName = safeName(key);
const literal = literalPropByKey.get(key);
if (literal !== undefined) {
return ` try container.encode(${swiftLiteralSource(literal)}, forKey: .${propName})`;
}
if (required.has(key)) {
return ` try container.encode(${propName}, forKey: .${propName})`;
}
return ` try container.encodeIfPresent(${propName}, forKey: .${propName})`;
})
.join("\n") +
"\n }\n}",
);
lines.push("");
return lines.join("\n");
}
lines.push(`public struct ${name}: Codable, Sendable {`);
const codingKeys: string[] = [];
for (const [key, propSchema] of Object.entries(props)) {
@@ -214,6 +324,131 @@ function emitStruct(name: string, schema: JsonSchema): string {
return lines.join("\n");
}
function literalSchemaValue(schema: JsonSchema): boolean | number | string | null | undefined {
if ("const" in schema) {
return schema.const;
}
if (schema.enum?.length === 1) {
return schema.enum[0] ?? undefined;
}
return undefined;
}
function swiftLiteralTypeName(value: boolean | number | string | null): string {
if (typeof value === "boolean") {
return "Bool";
}
if (typeof value === "number") {
return Number.isInteger(value) ? "Int" : "Double";
}
if (value === null) {
return "AnyCodable";
}
return "String";
}
function swiftLiteralSource(value: boolean | number | string | null): string {
if (typeof value === "string") {
return JSON.stringify(value);
}
if (value === null) {
return "AnyCodable(nil)";
}
return String(value);
}
function swiftUnionCaseName(value: boolean | number | string | null, fallback: string): string {
if (typeof value === "boolean") {
return value ? "success" : "failure";
}
if (value === null) {
return fallback;
}
return safeName(String(value));
}
function emitDiscriminatedUnion(name: string, schema: JsonSchema): string | undefined {
const branches = schema.oneOf ?? schema.anyOf;
if (!branches || branches.length < 2) {
return undefined;
}
const objectBranches = branches.filter((branch) => branch.type === "object");
if (objectBranches.length !== branches.length) {
return undefined;
}
const discriminatorCandidates = Object.keys(objectBranches[0]?.properties ?? {});
for (const discriminator of discriminatorCandidates) {
const cases = objectBranches.map((branch, index) => {
const discriminatorSchema = branch.properties?.[discriminator];
const literal = discriminatorSchema ? literalSchemaValue(discriminatorSchema) : undefined;
const branchName = namedSchema(branch, true);
if (literal === undefined || !branchName) {
return undefined;
}
return {
branchName,
caseName: swiftUnionCaseName(literal, `case${index + 1}`),
literal,
};
});
if (cases.some((entry) => !entry)) {
continue;
}
const resolvedCases: Array<{
branchName: string;
caseName: string;
literal: boolean | number | string | null;
}> = cases;
const [firstCase] = resolvedCases;
if (!firstCase) {
continue;
}
const literalType = swiftLiteralTypeName(firstCase.literal);
if (
resolvedCases.some((entry) => swiftLiteralTypeName(entry.literal) !== literalType) ||
new Set(resolvedCases.map((entry) => String(entry.literal))).size !== resolvedCases.length
) {
continue;
}
return [
`public enum ${name}: Codable, Sendable {`,
...resolvedCases.map((entry) => ` case ${entry.caseName}(${entry.branchName})`),
"",
" private enum CodingKeys: String, CodingKey {",
` case discriminator = "${discriminator}"`,
" }",
"",
" public init(from decoder: Decoder) throws {",
" let container = try decoder.container(keyedBy: CodingKeys.self)",
` let discriminator = try container.decode(${literalType}.self, forKey: .discriminator)`,
" switch discriminator {",
...resolvedCases.map(
(entry) =>
` case ${swiftLiteralSource(entry.literal)}: self = try .${entry.caseName}(${entry.branchName}(from: decoder))`,
),
" default:",
" throw DecodingError.dataCorruptedError(",
" forKey: .discriminator,",
" in: container,",
` debugDescription: "Unknown ${name} discriminator value"`,
" )",
" }",
" }",
"",
" public func encode(to encoder: Encoder) throws {",
" switch self {",
...resolvedCases.map(
(entry) => ` case .${entry.caseName}(let value): try value.encode(to: encoder)`,
),
" }",
" }",
"}",
"",
].join("\n");
}
return undefined;
}
function emitGatewayFrame(): string {
const cases = ["req", "res", "event"];
const associated: Record<string, string> = {
@@ -298,6 +533,16 @@ async function generate() {
}
}
for (const [name, schema] of definitions) {
if (name === "GatewayFrame") {
continue;
}
const union = emitDiscriminatedUnion(name, schema);
if (union) {
parts.push(union);
}
}
// Frame enum must come after payload structs
parts.push(emitGatewayFrame());

View File

@@ -2172,9 +2172,9 @@ describe("AcpSessionManager", () => {
});
const internals = manager as unknown as {
actorTailBySession: Map<string, Promise<void>>;
actorQueue: { getTailMapForTesting(): Map<string, Promise<void>> };
};
expect(internals.actorTailBySession.size).toBe(0);
expect(internals.actorQueue.getTailMapForTesting().size).toBe(0);
});
it("surfaces backend failures raised after a done event", async () => {

View File

@@ -1,5 +1,7 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CallGatewayScopedOptions } from "../../gateway/call.js";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { callGatewayTool, resolveGatewayOptions } from "./gateway.js";
const mocks = vi.hoisted(() => ({
@@ -33,6 +35,7 @@ describe("gateway tool defaults", () => {
beforeEach(() => {
mocks.callGateway.mockClear();
mocks.configState.value = {};
setActivePluginRegistry(createEmptyPluginRegistry());
delete process.env.OPENCLAW_GATEWAY_TOKEN;
});
@@ -166,6 +169,69 @@ describe("gateway tool defaults", () => {
expect(call.scopes).toEqual(["operator.admin"]);
});
it("derives plugin session action scopes from call params", async () => {
const registry = createEmptyPluginRegistry();
registry.sessionActions = [
{
pluginId: "scope-plugin",
pluginName: "Scope Plugin",
source: "test",
action: {
id: "approve",
requiredScopes: ["operator.approvals"],
handler: () => ({ result: { ok: true } }),
},
},
];
setActivePluginRegistry(registry);
mocks.callGateway.mockResolvedValueOnce({ ok: true });
await callGatewayTool(
"plugins.sessionAction",
{},
{
pluginId: "scope-plugin",
actionId: "approve",
sessionKey: "agent:main:main",
},
);
expect(mocks.callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "plugins.sessionAction",
scopes: ["operator.approvals"],
}),
);
});
it("falls back to broad scopes when a plugin session action is not locally registered", async () => {
setActivePluginRegistry(createEmptyPluginRegistry());
mocks.callGateway.mockResolvedValueOnce({ ok: true });
await callGatewayTool(
"plugins.sessionAction",
{},
{
pluginId: "remote-plugin",
actionId: "approve",
},
);
expect(mocks.callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "plugins.sessionAction",
scopes: [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
"operator.talk.secrets",
],
}),
);
});
it("allows explicit scope overrides for dynamic callers", async () => {
mocks.callGateway.mockResolvedValueOnce({ ok: true });
await callGatewayTool(

View File

@@ -154,7 +154,7 @@ export async function callGatewayTool<T = Record<string, unknown>>(
const gateway = resolveGatewayOptions(opts);
const scopes = Array.isArray(extra?.scopes)
? extra.scopes
: resolveLeastPrivilegeOperatorScopesForMethod(method);
: resolveLeastPrivilegeOperatorScopesForMethod(method, params);
return await callGateway<T>({
url: gateway.url,
token: gateway.token,

View File

@@ -100,6 +100,7 @@ export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text: string, limit: number, ctx?: ChannelOutboundChunkContext) => string[]) | null;
chunkerMode?: "text" | "markdown";
chunkedTextFormatting?: OutboundDeliveryFormattingOptions;
/** Lift remote Markdown image syntax in text into outbound media attachments. */
extractMarkdownImages?: boolean;
textChunkLimit?: number;

View File

@@ -5,6 +5,7 @@ import type { SessionEntry } from "./types.js";
const storeState = vi.hoisted(() => ({
store: {} as Record<string, SessionEntry>,
stores: {} as Record<string, Record<string, SessionEntry>>,
}));
vi.mock("../io.js", () => ({
@@ -12,11 +13,20 @@ vi.mock("../io.js", () => ({
}));
vi.mock("./paths.js", () => ({
resolveStorePath: () => "/tmp/sessions.json",
resolveStorePath: (_store?: string, opts?: { agentId?: string }) =>
opts?.agentId === "worker" ? "/tmp/worker-sessions.json" : "/tmp/sessions.json",
}));
vi.mock("./store.js", () => ({
loadSessionStore: () => storeState.store,
loadSessionStore: (storePath: string) => storeState.stores[storePath] ?? storeState.store,
}));
vi.mock("./targets.js", () => ({
resolveAllAgentSessionStoreTargetsSync: () => [
{ agentId: "main", storePath: "/tmp/sessions.json" },
{ agentId: "shadow", storePath: "/tmp/shadow-sessions.json" },
{ agentId: "worker", storePath: "/tmp/worker-sessions.json" },
],
}));
let extractDeliveryInfo: typeof import("./delivery-info.js").extractDeliveryInfo;
@@ -35,6 +45,7 @@ beforeAll(async () => {
beforeEach(() => {
setActivePluginRegistry(createSessionConversationTestRegistry());
storeState.store = {};
storeState.stores = {};
});
describe("extractDeliveryInfo", () => {
@@ -94,6 +105,35 @@ describe("extractDeliveryInfo", () => {
});
});
it("does not build the normalized index when an exact routable key is present", () => {
const sessionKey = "agent:main:webchat:dm:user-123";
storeState.store = new Proxy(
{
[sessionKey]: buildEntry({
channel: "webchat",
to: "webchat:user-123",
accountId: "default",
}),
},
{
ownKeys() {
throw new Error("normalized index should not be built");
},
},
);
const result = extractDeliveryInfo(sessionKey);
expect(result).toEqual({
deliveryContext: {
channel: "webchat",
to: "webchat:user-123",
accountId: "default",
},
threadId: undefined,
});
});
it("falls back to base sessions for :thread: keys", () => {
const baseKey = "agent:main:slack:channel:C0123ABC";
const threadKey = `${baseKey}:thread:1234567890.123456`;
@@ -115,6 +155,57 @@ describe("extractDeliveryInfo", () => {
});
});
it("looks up deliveryContext in per-agent session stores", () => {
const sessionKey = "agent:worker:webchat:dm:user-456";
storeState.stores["/tmp/sessions.json"] = {};
storeState.stores["/tmp/worker-sessions.json"] = {
[sessionKey]: buildEntry({
channel: "webchat",
to: "webchat:user-456",
accountId: "worker-account",
}),
};
const result = extractDeliveryInfo(sessionKey);
expect(result).toEqual({
deliveryContext: {
channel: "webchat",
to: "webchat:user-456",
accountId: "worker-account",
},
threadId: undefined,
});
});
it("continues across per-agent stores until it finds a routable deliveryContext", () => {
const sessionKey = "agent:shadow:webchat:dm:user-789";
storeState.stores["/tmp/sessions.json"] = {
[sessionKey]: {
sessionId: "stale-shadow",
updatedAt: Date.now() - 1000,
},
};
storeState.stores["/tmp/shadow-sessions.json"] = {
[sessionKey]: buildEntry({
channel: "webchat",
to: "webchat:user-789",
accountId: "shadow-account",
}),
};
const result = extractDeliveryInfo(sessionKey);
expect(result).toEqual({
deliveryContext: {
channel: "webchat",
to: "webchat:user-789",
accountId: "shadow-account",
},
threadId: undefined,
});
});
it("falls back to base sessions for :topic: keys", () => {
const baseKey = "agent:main:telegram:group:98765";
const topicKey = `${baseKey}:topic:55`;
@@ -156,7 +247,7 @@ describe("extractDeliveryInfo", () => {
channel: "telegram",
to: "group:98765",
accountId: "main",
threadId: "77",
threadId: 77,
},
threadId: undefined,
});
@@ -186,6 +277,135 @@ describe("extractDeliveryInfo", () => {
});
});
it("continues candidate session keys until it finds the freshest routable entry", () => {
const sessionKey = "agent:main:matrix:channel:!MixedCase:Example.Org";
const canonicalKey = "agent:main:matrix:channel:!mixedcase:example.org";
storeState.store[sessionKey] = {
sessionId: "stale-session",
updatedAt: Date.now() - 1000,
origin: {
provider: "matrix",
},
};
storeState.store[canonicalKey] = {
sessionId: "fresh-session",
updatedAt: Date.now(),
lastChannel: "matrix",
lastTo: "room:!MixedCase:Example.Org",
};
const result = extractDeliveryInfo(sessionKey);
expect(result).toEqual({
deliveryContext: {
channel: "matrix",
to: "room:!MixedCase:Example.Org",
accountId: undefined,
},
threadId: undefined,
});
});
it("prefers an older routable direct entry over a fresher normalized alias without a route", () => {
const sessionKey = "agent:main:matrix:channel:!MixedCase:Example.Org";
const canonicalKey = "agent:main:matrix:channel:!mixedcase:example.org";
storeState.store[sessionKey] = {
sessionId: "direct-routable-session",
updatedAt: Date.now() - 1_000,
deliveryContext: {
channel: "matrix",
to: "room:!MixedCase:Example.Org",
accountId: "matrix-account",
},
};
storeState.store[canonicalKey] = {
sessionId: "fresh-normalized-session",
updatedAt: Date.now(),
origin: {
provider: "matrix",
},
};
const result = extractDeliveryInfo(sessionKey);
expect(result).toEqual({
deliveryContext: {
channel: "matrix",
to: "room:!MixedCase:Example.Org",
accountId: "matrix-account",
},
threadId: undefined,
});
});
it("prefers an older routable normalized alias over a fresher non-routable alias", () => {
const queriedKey = "agent:main:matrix:channel:!MiXeDCase:Example.Org";
const routableAlias = "agent:main:matrix:channel:!MixedCase:Example.Org";
const canonicalKey = "agent:main:matrix:channel:!mixedcase:example.org";
storeState.store[canonicalKey] = {
sessionId: "fresh-normalized-session",
updatedAt: Date.now(),
origin: {
provider: "matrix",
},
};
storeState.store[routableAlias] = {
sessionId: "older-routable-session",
updatedAt: Date.now() - 1_000,
deliveryContext: {
channel: "matrix",
to: "room:!MixedCase:Example.Org",
accountId: "matrix-account",
},
};
const result = extractDeliveryInfo(queriedKey);
expect(result).toEqual({
deliveryContext: {
channel: "matrix",
to: "room:!MixedCase:Example.Org",
accountId: "matrix-account",
},
threadId: undefined,
});
});
it("prefers the freshest routable alias even when the normalized key is already routable", () => {
const queriedKey = "agent:main:matrix:channel:!MiXeDCase:Example.Org";
const canonicalKey = "agent:main:matrix:channel:!mixedcase:example.org";
const fresherAlias = "agent:main:matrix:channel:!MixedCase:Example.Org";
storeState.store[canonicalKey] = {
sessionId: "older-canonical-session",
updatedAt: Date.now() - 1_000,
deliveryContext: {
channel: "matrix",
to: "room:!mixedcase:example.org",
accountId: "matrix-account",
},
};
storeState.store[fresherAlias] = {
sessionId: "fresh-alias-session",
updatedAt: Date.now(),
deliveryContext: {
channel: "matrix",
to: "room:!MixedCase:Example.Org",
accountId: "matrix-account",
},
};
const result = extractDeliveryInfo(queriedKey);
expect(result).toEqual({
deliveryContext: {
channel: "matrix",
to: "room:!MixedCase:Example.Org",
accountId: "matrix-account",
},
threadId: undefined,
});
});
it("falls back to the base session when a thread entry only has partial route metadata", () => {
const baseKey = "agent:main:matrix:channel:!MixedCase:example.org";
const threadKey = `${baseKey}:thread:$thread-event`;

View File

@@ -1,43 +1,56 @@
import {
resolveSessionStoreAgentId,
resolveSessionStoreKey,
} from "../../gateway/session-store-key.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js";
import { getRuntimeConfig } from "../io.js";
import type { OpenClawConfig } from "../types.openclaw.js";
import { resolveStorePath } from "./paths.js";
import { loadSessionStore } from "./store.js";
export { parseSessionThreadInfo } from "./thread-info.js";
import { resolveAllAgentSessionStoreTargetsSync } from "./targets.js";
import { parseSessionThreadInfo } from "./thread-info.js";
import type { SessionEntry } from "./types.js";
export { parseSessionThreadInfo };
export function extractDeliveryInfo(sessionKey: string | undefined): {
function hasRoutableDeliveryContext(context?: {
channel?: string;
to?: string;
accountId?: string;
threadId?: string | number;
}): context is {
channel: string;
to: string;
accountId?: string;
threadId?: string | number;
} {
return Boolean(context?.channel && context?.to);
}
export function extractDeliveryInfo(
sessionKey: string | undefined,
options?: { cfg?: OpenClawConfig },
): {
deliveryContext:
| { channel?: string; to?: string; accountId?: string; threadId?: string }
| { channel?: string; to?: string; accountId?: string; threadId?: string | number }
| undefined;
threadId: string | undefined;
} {
const hasRoutableDeliveryContext = (context?: {
channel?: string;
to?: string;
accountId?: string;
threadId?: string | number;
}): context is {
channel: string;
to: string;
accountId?: string;
threadId?: string | number;
} => Boolean(context?.channel && context?.to);
const { baseSessionKey, threadId } = parseSessionThreadInfo(sessionKey);
if (!sessionKey || !baseSessionKey) {
return { deliveryContext: undefined, threadId };
}
let deliveryContext:
| { channel?: string; to?: string; accountId?: string; threadId?: string }
| { channel?: string; to?: string; accountId?: string; threadId?: string | number }
| undefined;
try {
const cfg = getRuntimeConfig();
const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath);
let entry = store[sessionKey];
const cfg = options?.cfg ?? getRuntimeConfig();
const lookup = loadDeliverySessionEntry({ cfg, sessionKey, baseSessionKey });
let entry = lookup.entry;
let storedDeliveryContext = deliveryContextFromSession(entry);
if (!hasRoutableDeliveryContext(storedDeliveryContext) && baseSessionKey !== sessionKey) {
entry = store[baseSessionKey];
entry = lookup.baseEntry;
storedDeliveryContext = deliveryContextFromSession(entry);
}
if (hasRoutableDeliveryContext(storedDeliveryContext)) {
@@ -45,10 +58,7 @@ export function extractDeliveryInfo(sessionKey: string | undefined): {
channel: storedDeliveryContext.channel,
to: storedDeliveryContext.to,
accountId: storedDeliveryContext.accountId,
threadId:
storedDeliveryContext.threadId != null
? String(storedDeliveryContext.threadId)
: undefined,
threadId: storedDeliveryContext.threadId,
};
}
} catch {
@@ -56,3 +66,123 @@ export function extractDeliveryInfo(sessionKey: string | undefined): {
}
return { deliveryContext, threadId };
}
function resolveDeliveryStorePaths(cfg: OpenClawConfig, agentId: string): string[] {
const paths = new Set<string>();
paths.add(resolveStorePath(cfg.session?.store, { agentId }));
for (const target of resolveAllAgentSessionStoreTargetsSync(cfg)) {
if (target.agentId === agentId) {
paths.add(target.storePath);
}
}
return [...paths];
}
function findSessionEntryInStore(
store: ReturnType<typeof loadSessionStore>,
keys: readonly string[],
) {
let normalizedIndex: Map<string, SessionEntry> | undefined;
let bestEntry: SessionEntry | undefined;
let bestUpdatedAt = 0;
let bestRoutable = false;
const acceptCandidate = (candidate: SessionEntry | undefined) => {
if (!candidate) {
return;
}
const candidateRoutable = hasRoutableDeliveryContext(deliveryContextFromSession(candidate));
const candidateUpdatedAt = candidate.updatedAt ?? 0;
if (
!bestEntry ||
(candidateRoutable && !bestRoutable) ||
(candidateRoutable === bestRoutable && candidateUpdatedAt > bestUpdatedAt)
) {
bestEntry = candidate;
bestUpdatedAt = candidateUpdatedAt;
bestRoutable = candidateRoutable;
}
};
for (const key of keys) {
const trimmed = key.trim();
const normalized = normalizeLowercaseStringOrEmpty(key);
let foundRoutableCandidate = false;
if (Object.prototype.hasOwnProperty.call(store, normalized)) {
foundRoutableCandidate ||= hasRoutableDeliveryContext(
deliveryContextFromSession(store[normalized]),
);
acceptCandidate(store[normalized]);
}
if (trimmed !== normalized && Object.prototype.hasOwnProperty.call(store, trimmed)) {
foundRoutableCandidate ||= hasRoutableDeliveryContext(
deliveryContextFromSession(store[trimmed]),
);
acceptCandidate(store[trimmed]);
}
if (trimmed !== normalized || !foundRoutableCandidate) {
normalizedIndex ??= buildFreshestSessionEntryIndex(store);
const freshest = normalizedIndex.get(normalized);
acceptCandidate(freshest);
}
}
return bestEntry;
}
function buildFreshestSessionEntryIndex(
store: Record<string, SessionEntry>,
): Map<string, SessionEntry> {
const index = new Map<string, SessionEntry>();
for (const [key, entry] of Object.entries(store)) {
const normalized = normalizeLowercaseStringOrEmpty(key);
const existing = index.get(normalized);
const entryRoutable = hasRoutableDeliveryContext(deliveryContextFromSession(entry));
const existingRoutable = hasRoutableDeliveryContext(deliveryContextFromSession(existing));
if (
!existing ||
(entryRoutable && !existingRoutable) ||
(entryRoutable === existingRoutable && (entry.updatedAt ?? 0) > (existing.updatedAt ?? 0))
) {
index.set(normalized, entry);
}
}
return index;
}
function loadDeliverySessionEntry(params: {
cfg: OpenClawConfig;
sessionKey: string;
baseSessionKey: string;
}) {
const canonicalKey = resolveSessionStoreKey({
cfg: params.cfg,
sessionKey: params.sessionKey,
});
const canonicalBaseKey = resolveSessionStoreKey({
cfg: params.cfg,
sessionKey: params.baseSessionKey,
});
const agentId = resolveSessionStoreAgentId(params.cfg, canonicalKey);
const sessionKeys = [params.sessionKey, canonicalKey];
const baseKeys = [params.baseSessionKey, canonicalBaseKey];
let fallback:
| {
entry: ReturnType<typeof findSessionEntryInStore>;
baseEntry: ReturnType<typeof findSessionEntryInStore>;
}
| undefined;
for (const storePath of resolveDeliveryStorePaths(params.cfg, agentId)) {
const store = loadSessionStore(storePath);
const entry = findSessionEntryInStore(store, sessionKeys);
const baseEntry = findSessionEntryInStore(store, baseKeys);
if (!entry && !baseEntry) {
continue;
}
fallback ??= { entry, baseEntry };
if (
hasRoutableDeliveryContext(deliveryContextFromSession(entry)) ||
hasRoutableDeliveryContext(deliveryContextFromSession(baseEntry))
) {
return { entry, baseEntry };
}
}
return fallback ?? { entry: undefined, baseEntry: undefined };
}

View File

@@ -4,6 +4,8 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { DeviceIdentity } from "../infra/device-identity.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { captureEnv } from "../test-utils/env.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
@@ -569,6 +571,28 @@ describe("callGateway url resolution", () => {
]);
});
it("falls back to broad operator scopes for unresolved plugin session actions", async () => {
setLocalLoopbackGatewayConfig();
setActivePluginRegistry(createEmptyPluginRegistry());
await callGatewayCli({
method: "plugins.sessionAction",
params: {
pluginId: "remote-plugin",
actionId: "approve",
},
});
expect(lastClientOptions?.scopes).toEqual([
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
"operator.talk.secrets",
]);
});
it("passes explicit scopes through, including empty arrays", async () => {
setLocalLoopbackGatewayConfig();

View File

@@ -779,7 +779,7 @@ export async function callGatewayCli<T = Record<string, unknown>>(
const scopes = Array.isArray(opts.scopes)
? opts.scopes
: isGatewayMethodClassified(opts.method)
? resolveLeastPrivilegeOperatorScopesForMethod(opts.method)
? resolveLeastPrivilegeOperatorScopesForMethod(opts.method, opts.params)
: CLI_DEFAULT_OPERATOR_SCOPES;
return await callGatewayWithScopes(opts, scopes);
}
@@ -787,7 +787,7 @@ export async function callGatewayCli<T = Record<string, unknown>>(
export async function callGatewayLeastPrivilege<T = Record<string, unknown>>(
opts: CallGatewayBaseOptions,
): Promise<T> {
const scopes = resolveLeastPrivilegeOperatorScopesForMethod(opts.method);
const scopes = resolveLeastPrivilegeOperatorScopesForMethod(opts.method, opts.params);
return await callGatewayWithScopes(opts, scopes);
}

View File

@@ -68,6 +68,113 @@ describe("method scope resolution", () => {
expect(resolveLeastPrivilegeOperatorScopesForMethod("node.pending.drain")).toStrictEqual([]);
});
it("classifies plugin session actions with a CLI-safe default operator scope", () => {
expect(resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction")).toEqual([
"operator.write",
]);
expect(isGatewayMethodClassified("plugins.sessionAction")).toBe(true);
expect(authorizeOperatorScopesForMethod("plugins.sessionAction", ["operator.read"])).toEqual({
allowed: false,
missingScope: "operator.write",
});
});
it("derives least-privilege scopes from registered plugin session action params", () => {
const registry = createEmptyPluginRegistry();
registry.sessionActions = [
{
pluginId: "scope-plugin",
pluginName: "Scope Plugin",
source: "test",
action: {
id: "approve",
requiredScopes: ["operator.approvals"],
handler: () => ({ result: { ok: true } }),
},
},
{
pluginId: "scope-plugin",
pluginName: "Scope Plugin",
source: "test",
action: {
id: "view",
requiredScopes: ["operator.read"],
handler: () => ({ result: { ok: true } }),
},
},
{
pluginId: "scope-plugin",
pluginName: "Scope Plugin",
source: "test",
action: {
id: "default-write",
handler: () => ({ result: { ok: true } }),
},
},
];
setActivePluginRegistry(registry);
expect(
resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction", {
pluginId: "scope-plugin",
actionId: "approve",
}),
).toEqual(["operator.approvals"]);
expect(
resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction", {
pluginId: " scope-plugin ",
actionId: " view ",
}),
).toEqual(["operator.read"]);
expect(
resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction", {
pluginId: "scope-plugin",
actionId: "default-write",
}),
).toEqual(["operator.write"]);
expect(
resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction", {
pluginId: "scope-plugin",
actionId: "missing",
}),
).toEqual([
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
"operator.talk.secrets",
]);
expect(
authorizeOperatorScopesForMethod("plugins.sessionAction", ["operator.approvals"], {
pluginId: "scope-plugin",
actionId: "approve",
}),
).toEqual({ allowed: true });
expect(
authorizeOperatorScopesForMethod("plugins.sessionAction", ["operator.write"], {
pluginId: "scope-plugin",
actionId: "approve",
}),
).toEqual({ allowed: false, missingScope: "operator.approvals" });
});
it("falls back to broad operator scopes when a dynamic session action is not locally registered", () => {
expect(
resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction", {
pluginId: "remote-plugin",
actionId: "approve",
}),
).toEqual([
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
"operator.talk.secrets",
]);
});
it("returns empty scopes for unknown methods", () => {
expect(resolveLeastPrivilegeOperatorScopesForMethod("totally.unknown.method")).toStrictEqual(
[],

View File

@@ -7,6 +7,7 @@ import {
READ_SCOPE,
TALK_SECRETS_SCOPE,
WRITE_SCOPE,
isOperatorScope,
type OperatorScope,
} from "./operator-scopes.js";
@@ -39,6 +40,8 @@ const NODE_ROLE_METHODS = new Set([
"skills.bins",
]);
const DYNAMIC_OPERATOR_SCOPE_METHODS = new Set(["plugins.sessionAction"]);
const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
[APPROVALS_SCOPE]: [
"exec.approval.get",
@@ -270,7 +273,65 @@ export function resolveRequiredOperatorScopeForMethod(method: string): OperatorS
return resolveScopedMethod(method);
}
export function resolveLeastPrivilegeOperatorScopesForMethod(method: string): OperatorScope[] {
function normalizeSessionActionParam(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function resolveSessionActionRegisteredScopes(params: unknown): OperatorScope[] | undefined {
if (!params || typeof params !== "object" || Array.isArray(params)) {
return undefined;
}
const pluginId = normalizeSessionActionParam((params as { pluginId?: unknown }).pluginId);
const actionId = normalizeSessionActionParam((params as { actionId?: unknown }).actionId);
if (!pluginId || !actionId) {
return undefined;
}
const registration = getPluginRegistryState()?.activeRegistry?.sessionActions?.find(
(entry) => entry.pluginId === pluginId && entry.action.id === actionId,
);
if (!registration) {
return undefined;
}
const requiredScopes = registration.action.requiredScopes;
return requiredScopes && requiredScopes.length > 0 ? [...requiredScopes] : [WRITE_SCOPE];
}
function resolveSessionActionLeastPrivilegeScopes(params: unknown): OperatorScope[] {
const registeredScopes = resolveSessionActionRegisteredScopes(params);
if (registeredScopes) {
return registeredScopes;
}
if (params && typeof params === "object" && !Array.isArray(params)) {
const pluginId = normalizeSessionActionParam((params as { pluginId?: unknown }).pluginId);
const actionId = normalizeSessionActionParam((params as { actionId?: unknown }).actionId);
if (pluginId && actionId) {
// A standalone CLI/tool caller may be talking to a gateway whose live
// plugin registry is not present in this local process. Avoid under-scoping
// valid dynamic actions when we cannot determine the exact requirement
// locally.
return [...CLI_DEFAULT_OPERATOR_SCOPES];
}
}
return [WRITE_SCOPE];
}
function resolveDynamicLeastPrivilegeOperatorScopesForMethod(
method: string,
params: unknown,
): OperatorScope[] {
if (method === "plugins.sessionAction") {
return resolveSessionActionLeastPrivilegeScopes(params);
}
return [WRITE_SCOPE];
}
export function resolveLeastPrivilegeOperatorScopesForMethod(
method: string,
params?: unknown,
): OperatorScope[] {
if (DYNAMIC_OPERATOR_SCOPE_METHODS.has(method)) {
return resolveDynamicLeastPrivilegeOperatorScopesForMethod(method, params);
}
const requiredScope = resolveRequiredOperatorScopeForMethod(method);
if (requiredScope) {
return [requiredScope];
@@ -282,10 +343,28 @@ export function resolveLeastPrivilegeOperatorScopesForMethod(method: string): Op
export function authorizeOperatorScopesForMethod(
method: string,
scopes: readonly string[],
params?: unknown,
): { allowed: true } | { allowed: false; missingScope: OperatorScope } {
if (scopes.includes(ADMIN_SCOPE)) {
return { allowed: true };
}
if (DYNAMIC_OPERATOR_SCOPE_METHODS.has(method)) {
const registeredScopes = resolveSessionActionRegisteredScopes(params);
if (!registeredScopes && params && typeof params === "object" && !Array.isArray(params)) {
const pluginId = normalizeSessionActionParam((params as { pluginId?: unknown }).pluginId);
const actionId = normalizeSessionActionParam((params as { actionId?: unknown }).actionId);
if (!pluginId || !actionId) {
return scopes.some((scope) => isOperatorScope(scope))
? { allowed: true }
: { allowed: false, missingScope: WRITE_SCOPE };
}
}
const requiredScopes = registeredScopes ?? [WRITE_SCOPE];
const missingScope = requiredScopes.find((scope) => {
return !scopes.includes(scope) && !(scope === READ_SCOPE && scopes.includes(WRITE_SCOPE));
});
return missingScope ? { allowed: false, missingScope } : { allowed: true };
}
const requiredScope = resolveRequiredOperatorScopeForMethod(method) ?? ADMIN_SCOPE;
if (requiredScope === READ_SCOPE) {
if (scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE)) {
@@ -303,5 +382,8 @@ export function isGatewayMethodClassified(method: string): boolean {
if (isNodeRoleMethod(method)) {
return true;
}
if (DYNAMIC_OPERATOR_SCOPE_METHODS.has(method)) {
return true;
}
return resolveRequiredOperatorScopeForMethod(method) !== undefined;
}

View File

@@ -192,6 +192,10 @@ import {
PluginApprovalRequestParamsSchema,
type PluginApprovalResolveParams,
PluginApprovalResolveParamsSchema,
type PluginsSessionActionParams,
type PluginsSessionActionResult,
PluginsSessionActionParamsSchema,
PluginsSessionActionResultSchema,
type PluginsUiDescriptorsParams,
PluginsUiDescriptorsParamsSchema,
ErrorCodes,
@@ -714,6 +718,12 @@ export const validatePluginApprovalResolveParams = ajv.compile<PluginApprovalRes
export const validatePluginsUiDescriptorsParams = ajv.compile<PluginsUiDescriptorsParams>(
PluginsUiDescriptorsParamsSchema,
);
export const validatePluginsSessionActionParams = ajv.compile<PluginsSessionActionParams>(
PluginsSessionActionParamsSchema,
);
export const validatePluginsSessionActionResult = ajv.compile<PluginsSessionActionResult>(
PluginsSessionActionResultSchema,
);
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
ExecApprovalsNodeGetParamsSchema,
);
@@ -910,6 +920,8 @@ export {
AgentsListResultSchema,
CommandsListParamsSchema,
CommandsListResultSchema,
PluginsSessionActionParamsSchema,
PluginsSessionActionResultSchema,
PluginsUiDescriptorsParamsSchema,
ModelsListParamsSchema,
SkillsStatusParamsSchema,
@@ -1049,6 +1061,8 @@ export type {
CommandsListParams,
CommandsListResult,
CommandEntry,
PluginsSessionActionParams,
PluginsSessionActionResult,
SkillsStatusParams,
ToolsCatalogParams,
ToolsCatalogResult,

View File

@@ -101,6 +101,12 @@ export const SendParamsSchema = Type.Object(
replyToId: Type.Optional(Type.String()),
/** Thread id (channel-specific meaning, e.g. Telegram forum topic id). */
threadId: Type.Optional(Type.String()),
/** Force document-style media sends where supported. */
forceDocument: Type.Optional(Type.Boolean()),
/** Send silently (no notification) where supported. */
silent: Type.Optional(Type.Boolean()),
/** Channel-specific parse mode for formatted text. */
parseMode: Type.Optional(Type.Literal("HTML")),
/** Optional session key for mirroring delivered output back into the transcript. */
sessionKey: Type.Optional(Type.String()),
idempotencyKey: NonEmptyString,

View File

@@ -32,3 +32,38 @@ export const PluginsUiDescriptorsResultSchema = Type.Object(
},
{ additionalProperties: false },
);
export const PluginsSessionActionParamsSchema = Type.Object(
{
pluginId: NonEmptyString,
actionId: NonEmptyString,
sessionKey: Type.Optional(NonEmptyString),
payload: Type.Optional(PluginJsonValueSchema),
},
{ additionalProperties: false },
);
export const PluginsSessionActionSuccessResultSchema = Type.Object(
{
ok: Type.Literal(true),
result: Type.Optional(PluginJsonValueSchema),
continueAgent: Type.Optional(Type.Boolean()),
reply: Type.Optional(PluginJsonValueSchema),
},
{ additionalProperties: false },
);
export const PluginsSessionActionFailureResultSchema = Type.Object(
{
ok: Type.Literal(false),
error: Type.String(),
code: Type.Optional(Type.String()),
details: Type.Optional(PluginJsonValueSchema),
},
{ additionalProperties: false },
);
export const PluginsSessionActionResultSchema = Type.Union([
PluginsSessionActionSuccessResultSchema,
PluginsSessionActionFailureResultSchema,
]);

View File

@@ -202,6 +202,10 @@ import {
} from "./plugin-approvals.js";
import {
PluginControlUiDescriptorSchema,
PluginsSessionActionFailureResultSchema,
PluginsSessionActionParamsSchema,
PluginsSessionActionResultSchema,
PluginsSessionActionSuccessResultSchema,
PluginsUiDescriptorsParamsSchema,
PluginsUiDescriptorsResultSchema,
} from "./plugins.js";
@@ -469,6 +473,10 @@ export const ProtocolSchemas = {
PluginApprovalRequestParams: PluginApprovalRequestParamsSchema,
PluginApprovalResolveParams: PluginApprovalResolveParamsSchema,
PluginControlUiDescriptor: PluginControlUiDescriptorSchema,
PluginsSessionActionFailureResult: PluginsSessionActionFailureResultSchema,
PluginsSessionActionParams: PluginsSessionActionParamsSchema,
PluginsSessionActionResult: PluginsSessionActionResultSchema,
PluginsSessionActionSuccessResult: PluginsSessionActionSuccessResultSchema,
PluginsUiDescriptorsParams: PluginsUiDescriptorsParamsSchema,
PluginsUiDescriptorsResult: PluginsUiDescriptorsResultSchema,
DevicePairListParams: DevicePairListParamsSchema,

View File

@@ -162,6 +162,8 @@ export type CommandsListResult = SchemaType<"CommandsListResult">;
export type PluginControlUiDescriptor = SchemaType<"PluginControlUiDescriptor">;
export type PluginsUiDescriptorsParams = SchemaType<"PluginsUiDescriptorsParams">;
export type PluginsUiDescriptorsResult = SchemaType<"PluginsUiDescriptorsResult">;
export type PluginsSessionActionParams = SchemaType<"PluginsSessionActionParams">;
export type PluginsSessionActionResult = SchemaType<"PluginsSessionActionResult">;
export type SkillsStatusParams = SchemaType<"SkillsStatusParams">;
export type ToolsCatalogParams = SchemaType<"ToolsCatalogParams">;
export type ToolCatalogProfile = SchemaType<"ToolCatalogProfile">;

View File

@@ -52,6 +52,7 @@ const BASE_METHODS = [
"plugin.approval.waitDecision",
"plugin.approval.resolve",
"plugins.uiDescriptors",
"plugins.sessionAction",
"wizard.start",
"wizard.next",
"wizard.cancel",

View File

@@ -56,7 +56,11 @@ const CONTROL_PLANE_WRITE_METHODS = new Set([
"gateway.restart.request",
"update.run",
]);
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
function authorizeGatewayMethod(
method: string,
client: GatewayRequestOptions["client"],
params: unknown,
) {
if (!client?.connect) {
return null;
}
@@ -78,7 +82,7 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
if (scopes.includes(ADMIN_SCOPE)) {
return null;
}
const scopeAuth = authorizeOperatorScopesForMethod(method, scopes);
const scopeAuth = authorizeOperatorScopesForMethod(method, scopes, params);
if (!scopeAuth.allowed) {
return errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${scopeAuth.missingScope}`);
}
@@ -132,7 +136,7 @@ export async function handleGatewayRequest(
opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers },
): Promise<void> {
const { req, respond, client, isWebchatConnect, context } = opts;
const authError = authorizeGatewayMethod(req.method, client);
const authError = authorizeGatewayMethod(req.method, client, req.params);
if (authError) {
respond(false, undefined, authError);
return;

View File

@@ -1,12 +1,45 @@
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { isPluginJsonValue } from "../../plugins/host-hooks.js";
import { getActivePluginRegistry } from "../../plugins/runtime.js";
import {
validateJsonSchemaValue,
type JsonSchemaValidationError,
type JsonSchemaValue,
} from "../../plugins/schema-validator.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../operator-scopes.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validatePluginsSessionActionParams,
validatePluginsSessionActionResult,
validatePluginsUiDescriptorsParams,
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
const log = createSubsystemLogger("gateway/plugin-host-hooks");
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function formatSessionActionPayloadSchemaErrors(errors: JsonSchemaValidationError[]): string {
return errors.map((error) => error.text).join("; ");
}
function validatePluginSessionActionJsonFields(
result: Record<string, unknown>,
): string | undefined {
for (const field of ["result", "reply", "details"] as const) {
if (result[field] !== undefined && !isPluginJsonValue(result[field])) {
return `plugin session action ${field} must be JSON-compatible`;
}
}
return undefined;
}
export const pluginHostHookHandlers: GatewayRequestHandlers = {
"plugins.uiDescriptors": ({ params, respond }) => {
if (!validatePluginsUiDescriptorsParams(params)) {
@@ -28,4 +61,182 @@ export const pluginHostHookHandlers: GatewayRequestHandlers = {
);
respond(true, { ok: true, descriptors }, undefined);
},
"plugins.sessionAction": async ({ params, client, respond }) => {
if (!validatePluginsSessionActionParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid plugins.sessionAction params: ${formatValidationErrors(validatePluginsSessionActionParams.errors)}`,
),
);
return;
}
const pluginId = normalizeOptionalString(params.pluginId);
const actionId = normalizeOptionalString(params.actionId);
const sessionKey = normalizeOptionalString(params.sessionKey);
if (!pluginId || !actionId) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"plugins.sessionAction pluginId and actionId must be non-empty",
),
);
return;
}
const registry = getActivePluginRegistry();
const pluginLoaded = Boolean(
registry?.plugins.some((plugin) => plugin.id === pluginId && plugin.status === "loaded"),
);
const registration = (registry?.sessionActions ?? []).find(
(entry) => entry.pluginId === pluginId && entry.action.id === actionId,
);
if (!registration || !pluginLoaded) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
`unknown plugin session action: ${pluginId}/${actionId}`,
),
);
return;
}
const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : [];
const hasAdmin = scopes.includes(ADMIN_SCOPE);
const requiredScopes =
registration.action.requiredScopes && registration.action.requiredScopes.length > 0
? registration.action.requiredScopes
: [WRITE_SCOPE];
const missingScope = requiredScopes.find(
(scope) =>
!hasAdmin &&
!scopes.includes(scope) &&
!(scope === READ_SCOPE && scopes.includes(WRITE_SCOPE)),
);
if (missingScope) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${missingScope}`),
);
return;
}
try {
if (params.payload !== undefined && !isPluginJsonValue(params.payload)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"plugin session action payload must be JSON-compatible",
),
);
return;
}
if (registration.action.schema !== undefined) {
if (
typeof registration.action.schema !== "boolean" &&
!isRecord(registration.action.schema)
) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"plugin session action schema must be an object or boolean",
),
);
return;
}
const validation = validateJsonSchemaValue({
schema: registration.action.schema as JsonSchemaValue,
cacheKey: `plugin-session-action:${pluginId}:${actionId}`,
value: params.payload,
});
if (!validation.ok) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`plugin session action payload does not match schema: ${formatSessionActionPayloadSchemaErrors(validation.errors)}`,
),
);
return;
}
}
const result = await registration.action.handler({
pluginId,
actionId,
...(sessionKey ? { sessionKey } : {}),
...(params.payload !== undefined ? { payload: params.payload } : {}),
client: {
...(client?.connId ? { connId: client.connId } : {}),
scopes: [...scopes],
},
});
if (result !== undefined && !isRecord(result)) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "plugin session action result must be an object"),
);
return;
}
const wireResult = result?.ok === false ? result : { ok: true as const, ...result };
if (!validatePluginsSessionActionResult(wireResult)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid plugin session action result: ${formatValidationErrors(validatePluginsSessionActionResult.errors)}`,
),
);
return;
}
const jsonFieldError = result ? validatePluginSessionActionJsonFields(result) : undefined;
if (jsonFieldError) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, jsonFieldError));
return;
}
if (!wireResult.ok) {
// Plugin-declared action failures are returned as a successful RPC
// with `ok: false` per PluginsSessionActionResultSchema. Reserve
// transport errorShape for protocol-level failures (validation,
// schema mismatch, dispatch error). Distinguishing these in the
// wire shape lets callers handle plugin failures (often retryable
// or user-facing) differently from transport errors (operator
// diagnostics).
respond(
true,
{
ok: false,
error: wireResult.error,
...(wireResult.code !== undefined ? { code: wireResult.code } : {}),
...(wireResult.details !== undefined ? { details: wireResult.details } : {}),
},
undefined,
);
return;
}
respond(true, {
ok: true,
...(wireResult.result !== undefined ? { result: wireResult.result } : {}),
...(wireResult.continueAgent !== undefined
? { continueAgent: wireResult.continueAgent }
: {}),
...(wireResult.reply !== undefined ? { reply: wireResult.reply } : {}),
});
} catch (error) {
log.warn(
`plugin session action failed plugin=${pluginId} action=${actionId}: ${formatErrorMessage(error)}`,
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "plugin session action failed"));
}
},
};

View File

@@ -774,6 +774,28 @@ describe("gateway send mirroring", () => {
expect(deliveryCall()?.threadId).toBe("1710000000.9999");
});
it("forwards gateway send delivery options to outbound delivery", async () => {
mockDeliverySuccess("m-options");
await runSend({
to: "channel:C1",
message: "<b>report</b>",
channel: "slack",
forceDocument: true,
silent: true,
parseMode: "HTML",
idempotencyKey: "idem-send-options",
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
forceDocument: true,
silent: true,
formatting: { parseMode: "HTML" },
}),
);
});
it("updates mirror session keys and delivery thread ids when Slack routing derives a thread", async () => {
mockDeliverySuccess("m-thread-derived");
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({

View File

@@ -402,6 +402,9 @@ export const sendHandlers: GatewayRequestHandlers = {
agentId?: string;
replyToId?: string;
threadId?: string;
forceDocument?: boolean;
silent?: boolean;
parseMode?: "HTML";
sessionKey?: string;
idempotencyKey: string;
};
@@ -552,9 +555,12 @@ export const sendHandlers: GatewayRequestHandlers = {
replyToId: replyToId ?? null,
session: outboundSession,
gifPlayback: request.gifPlayback,
forceDocument: request.forceDocument,
threadId: outboundRoute?.threadId ?? threadId ?? null,
deps: outboundDeps,
gatewayClientScopes: client?.connect?.scopes ?? [],
silent: request.silent,
formatting: request.parseMode ? { parseMode: request.parseMode } : undefined,
mirror: outboundSessionKey
? {
sessionKey: outboundSessionKey,

View File

@@ -2143,6 +2143,49 @@ describe("deliverOutboundPayloads", () => {
expect(chunker).toHaveBeenNthCalledWith(1, text, 4000);
});
it("passes formatting overrides for pre-rendered chunker output", async () => {
const chunker = vi.fn(() => ["<b>bold</b>"]);
const sendText = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
channel: "matrix" as const,
messageId: text,
roomId: "r1",
}));
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: {
deliveryMode: "direct",
chunker,
chunkerMode: "markdown",
chunkedTextFormatting: { parseMode: "HTML" },
textChunkLimit: 4000,
sendText,
},
}),
},
]),
);
await deliverOutboundPayloads({
cfg: matrixChunkConfig,
channel: "matrix",
to: "!room",
payloads: [{ text: "**bold**" }],
});
expect(chunker).toHaveBeenCalledWith("**bold**", 4000);
expect(sendText).toHaveBeenCalledWith(
expect.objectContaining({
text: "<b>bold</b>",
formatting: { parseMode: "HTML" },
}),
);
});
it("passes config through for plugin media sends", async () => {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-media", roomId: "!room" });
setActivePluginRegistry(

View File

@@ -136,6 +136,7 @@ async function loadChannelBootstrapRuntime() {
type ChannelHandler = {
chunker: ChannelOutboundAdapter["chunker"] | null;
chunkerMode?: "text" | "markdown";
chunkedTextFormatting?: OutboundDeliveryFormattingOptions;
textChunkLimit?: number;
supportsMedia: boolean;
sanitizeText?: (payload: ReplyPayload) => string;
@@ -345,6 +346,7 @@ function createPluginHandler(
replyToIdSource?: "explicit" | "implicit";
threadId?: string | number | null;
audioAsVoice?: boolean;
formatting?: OutboundDeliveryFormattingOptions;
}): Omit<ChannelOutboundContext, "text" | "mediaUrl"> => ({
...baseCtx,
replyToId: overrides && "replyToId" in overrides ? overrides.replyToId : baseCtx.replyToId,
@@ -354,6 +356,10 @@ function createPluginHandler(
: baseCtx.replyToIdSource,
threadId: overrides && "threadId" in overrides ? overrides.threadId : baseCtx.threadId,
audioAsVoice: overrides?.audioAsVoice,
formatting:
overrides && "formatting" in overrides
? { ...baseCtx.formatting, ...overrides.formatting }
: baseCtx.formatting,
});
const buildTargetRef = (overrides?: {
threadId?: string | number | null;
@@ -366,6 +372,7 @@ function createPluginHandler(
return {
chunker,
chunkerMode,
chunkedTextFormatting: outbound?.chunkedTextFormatting,
textChunkLimit: outbound?.textChunkLimit,
supportsMedia: Boolean(messageMedia ?? sendMedia),
sanitizeText: outbound?.sanitizeText
@@ -1391,6 +1398,7 @@ async function deliverOutboundPayloadsCore(
overrides,
chunker: handler.chunker,
chunkerMode: handler.chunkerMode,
chunkedTextFormatting: handler.chunkedTextFormatting,
textLimit,
chunkMode,
formatting: params.formatting,

View File

@@ -6,4 +6,5 @@ export type OutboundDeliveryFormattingOptions = {
maxLinesPerMessage?: number;
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
parseMode?: "HTML";
};

View File

@@ -85,4 +85,22 @@ describe("outbound message planning", () => {
["media", undefined, "https://example.com/2.png", undefined],
]);
});
it("adds formatting overrides only to chunked text units", () => {
const units = planOutboundTextMessageUnits({
text: "**bold**",
textLimit: 4000,
chunker: () => ["<b>bold</b>"],
chunkedTextFormatting: { parseMode: "HTML" },
overrides: {},
});
expect(units).toEqual([
{
kind: "text",
text: "<b>bold</b>",
overrides: { formatting: { parseMode: "HTML" } },
},
]);
});
});

View File

@@ -10,6 +10,7 @@ export type OutboundMessageSendOverrides = ReplyToOverride & {
threadId?: string | number | null;
audioAsVoice?: boolean;
forceDocument?: boolean;
formatting?: OutboundDeliveryFormattingOptions;
};
export type OutboundMessageUnit =
@@ -40,6 +41,15 @@ function withPlannedReplyTo(
return consumeReplyTo ? consumeReplyTo({ ...overrides }) : { ...overrides };
}
function withChunkedTextFormatting(
overrides: OutboundMessageSendOverrides,
formatting?: OutboundDeliveryFormattingOptions,
): OutboundMessageSendOverrides {
return formatting
? { ...overrides, formatting: { ...overrides.formatting, ...formatting } }
: overrides;
}
function chunkTextForPlan(params: {
text: string;
limit: number;
@@ -56,6 +66,7 @@ export function planOutboundTextMessageUnits(params: {
overrides: OutboundMessageSendOverrides;
chunker?: OutboundMessageChunker | null;
chunkerMode?: "text" | "markdown";
chunkedTextFormatting?: OutboundDeliveryFormattingOptions;
textLimit?: number;
chunkMode?: ChunkMode;
formatting?: OutboundDeliveryFormattingOptions;
@@ -66,6 +77,13 @@ export function planOutboundTextMessageUnits(params: {
text,
overrides: withPlannedReplyTo(params.overrides, params.consumeReplyTo),
});
const planChunkedTextUnit = (text: string): OutboundMessageUnit => {
const unit = planTextUnit(text);
return {
...unit,
overrides: withChunkedTextFormatting(unit.overrides, params.chunkedTextFormatting),
};
};
if (!params.chunker || params.textLimit === undefined) {
return [planTextUnit(params.text)];
@@ -93,7 +111,7 @@ export function planOutboundTextMessageUnits(params: {
chunks.push(blockChunk);
}
for (const chunk of chunks) {
units.push(planTextUnit(chunk));
units.push(planChunkedTextUnit(chunk));
}
}
return units;
@@ -104,7 +122,7 @@ export function planOutboundTextMessageUnits(params: {
limit: params.textLimit,
chunker: params.chunker,
formatting: params.formatting,
}).map(planTextUnit);
}).map(planChunkedTextUnit);
}
export function planOutboundMediaMessageUnits(params: {

View File

@@ -321,6 +321,23 @@ describe("gateway url override hardening", () => {
},
},
},
{
name: "forwards gateway delivery options in send params",
params: {
threadId: "topic456",
forceDocument: true,
silent: true,
parseMode: "HTML" as const,
},
expected: {
params: {
threadId: "topic456",
forceDocument: true,
silent: true,
parseMode: "HTML",
},
},
},
])("$name", async ({ params, expected }) => {
expect(await sendThreadChatGatewayMessage(params)).toMatchObject(expected);
});

View File

@@ -91,6 +91,7 @@ type MessageSendParams = {
mirror?: OutboundMirror;
abortSignal?: AbortSignal;
silent?: boolean;
parseMode?: "HTML";
};
export type MessageSendResult = {
@@ -397,6 +398,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
signal: params.abortSignal,
silent: params.silent,
mediaAccess: params.mediaAccess,
formatting: params.parseMode ? { parseMode: params.parseMode } : undefined,
mirror: params.mirror
? {
...params.mirror,
@@ -435,6 +437,10 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
agentId: params.agentId,
channel,
replyToId: params.replyToId,
threadId: params.threadId != null ? String(params.threadId) : undefined,
forceDocument: params.forceDocument,
silent: params.silent,
parseMode: params.parseMode,
sessionKey: params.mirror?.sessionKey,
idempotencyKey: await resolveGatewayIdempotencyKey(params.idempotencyKey),
},

View File

@@ -48,6 +48,8 @@ export type {
OpenClawPluginServiceContext,
PluginCommandContext,
PluginCommandResult,
PluginAgentEventEmitParams,
PluginAgentEventEmitResult,
PluginAgentEventSubscriptionRegistration,
PluginAgentTurnPrepareEvent,
PluginAgentTurnPrepareResult,
@@ -61,8 +63,16 @@ export type {
PluginRunContextGetParams,
PluginRunContextPatch,
PluginRuntimeLifecycleRegistration,
PluginSessionActionContext,
PluginSessionActionRegistration,
PluginSessionActionResult,
PluginSessionAttachmentParams,
PluginSessionAttachmentResult,
PluginSessionSchedulerJobHandle,
PluginSessionSchedulerJobRegistration,
PluginSessionTurnScheduleParams,
PluginSessionTurnUnscheduleByTagParams,
PluginSessionTurnUnscheduleByTagResult,
PluginSessionExtensionRegistration,
PluginSessionExtensionProjection,
PluginToolMetadataRegistration,

View File

@@ -85,6 +85,8 @@ import type {
SpeechProviderPlugin,
PluginCommandContext,
PluginCommandResult,
PluginAgentEventEmitParams,
PluginAgentEventEmitResult,
PluginAgentEventSubscriptionRegistration,
PluginAgentTurnPrepareEvent,
PluginAgentTurnPrepareResult,
@@ -98,8 +100,16 @@ import type {
PluginRunContextGetParams,
PluginRunContextPatch,
PluginRuntimeLifecycleRegistration,
PluginSessionActionContext,
PluginSessionActionRegistration,
PluginSessionActionResult,
PluginSessionAttachmentParams,
PluginSessionAttachmentResult,
PluginSessionSchedulerJobHandle,
PluginSessionSchedulerJobRegistration,
PluginSessionTurnScheduleParams,
PluginSessionTurnUnscheduleByTagParams,
PluginSessionTurnUnscheduleByTagResult,
PluginSessionExtensionRegistration,
PluginSessionExtensionProjection,
PluginToolMetadataRegistration,
@@ -130,6 +140,8 @@ export type {
OpenClawPluginToolFactory,
PluginCommandContext,
PluginCommandResult,
PluginAgentEventEmitParams,
PluginAgentEventEmitResult,
PluginAgentEventSubscriptionRegistration,
PluginAgentTurnPrepareEvent,
PluginAgentTurnPrepareResult,
@@ -143,8 +155,16 @@ export type {
PluginRunContextGetParams,
PluginRunContextPatch,
PluginRuntimeLifecycleRegistration,
PluginSessionActionContext,
PluginSessionActionRegistration,
PluginSessionActionResult,
PluginSessionSchedulerJobHandle,
PluginSessionSchedulerJobRegistration,
PluginSessionAttachmentParams,
PluginSessionAttachmentResult,
PluginSessionTurnScheduleParams,
PluginSessionTurnUnscheduleByTagParams,
PluginSessionTurnUnscheduleByTagResult,
PluginSessionExtensionRegistration,
PluginSessionExtensionProjection,
PluginToolMetadataRegistration,

View File

@@ -1,9 +1,14 @@
import {
attachPluginApiFacades,
type OpenClawPluginApiWithoutFacades,
} from "../plugins/api-facades.js";
import type { OpenClawPluginApi } from "./plugin-runtime.js";
export type TestPluginApiInput = Partial<OpenClawPluginApi>;
export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPluginApi {
return {
const { agent, lifecycle, runContext, session, ...flatApi } = api;
const mergedApi = {
id: "test-plugin",
name: "test-plugin",
source: "test",
@@ -61,10 +66,15 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
registerControlUiDescriptor() {},
registerRuntimeLifecycle() {},
registerAgentEventSubscription() {},
emitAgentEvent: () => ({ emitted: false as const, reason: "test api" }),
setRunContext: () => false,
getRunContext: () => undefined,
clearRunContext() {},
registerSessionSchedulerJob: () => undefined,
registerSessionAction() {},
sendSessionAttachment: async () => ({ ok: false, error: "test plugin api" }),
scheduleSessionTurn: async () => undefined,
unscheduleSessionTurnsByTag: async () => ({ removed: 0, failed: 0 }),
registerMemoryCapability() {},
registerMemoryPromptSection() {},
registerMemoryPromptSupplement() {},
@@ -76,6 +86,14 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
return input;
},
on() {},
...api,
...flatApi,
} as OpenClawPluginApiWithoutFacades;
const withFacades = attachPluginApiFacades(mergedApi);
return {
...withFacades,
...(agent ? { agent } : {}),
...(lifecycle ? { lifecycle } : {}),
...(runContext ? { runContext } : {}),
...(session ? { session } : {}),
};
}

View File

@@ -0,0 +1,84 @@
import { emitAgentEvent } from "../infra/agent-events.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
isPluginJsonValue,
type PluginAgentEventEmitParams,
type PluginAgentEventEmitResult,
type PluginJsonValue,
} from "./host-hooks.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
const HOST_OWNED_AGENT_EVENT_STREAMS = new Set<string>([
"lifecycle",
"tool",
"assistant",
"error",
"item",
"plan",
"approval",
"command_output",
"patch",
"compaction",
"thinking",
"model",
]);
function isPluginOwnedAgentEventStream(pluginId: string, stream: string): boolean {
return stream === pluginId || stream.startsWith(`${pluginId}.`);
}
function normalizePluginEventData(params: {
pluginId: string;
pluginName?: string;
data: PluginJsonValue;
}): Record<string, unknown> {
if (params.data && typeof params.data === "object" && !Array.isArray(params.data)) {
return {
...params.data,
pluginId: params.pluginId,
...(params.pluginName ? { pluginName: params.pluginName } : {}),
};
}
return {
value: params.data,
pluginId: params.pluginId,
...(params.pluginName ? { pluginName: params.pluginName } : {}),
};
}
export function emitPluginAgentEvent(params: {
pluginId: string;
pluginName?: string;
origin: PluginOrigin;
event: PluginAgentEventEmitParams;
}): PluginAgentEventEmitResult {
const runId = normalizeOptionalString(params.event.runId);
const sessionKey = normalizeOptionalString(params.event.sessionKey);
const stream = normalizeOptionalString(params.event.stream);
if (!runId || !stream) {
return { emitted: false, reason: "runId and stream are required" };
}
if (!isPluginJsonValue(params.event.data)) {
return { emitted: false, reason: "event data must be JSON-compatible" };
}
if (params.origin !== "bundled" && HOST_OWNED_AGENT_EVENT_STREAMS.has(stream)) {
return { emitted: false, reason: `stream ${stream} is reserved for bundled plugins` };
}
if (params.origin !== "bundled" && !isPluginOwnedAgentEventStream(params.pluginId, stream)) {
return {
emitted: false,
reason: `stream ${stream} must be scoped to plugin ${params.pluginId}`,
};
}
emitAgentEvent({
runId,
stream,
...(sessionKey ? { sessionKey } : {}),
data: normalizePluginEventData({
pluginId: params.pluginId,
pluginName: params.pluginName,
data: params.event.data,
}),
});
return { emitted: true, stream };
}

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { attachPluginApiFacades, type OpenClawPluginApiWithoutFacades } from "./api-facades.js";
import type { PluginRuntime } from "./runtime/types.js";
import type { OpenClawPluginApi, PluginLogger } from "./types.js";
@@ -62,10 +63,15 @@ export type BuildPluginApiParams = {
| "registerControlUiDescriptor"
| "registerRuntimeLifecycle"
| "registerAgentEventSubscription"
| "emitAgentEvent"
| "setRunContext"
| "getRunContext"
| "clearRunContext"
| "registerSessionSchedulerJob"
| "registerSessionAction"
| "sendSessionAttachment"
| "scheduleSessionTurn"
| "unscheduleSessionTurnsByTag"
| "registerDetachedTaskRuntime"
| "registerMemoryCapability"
| "registerMemoryPromptSection"
@@ -138,11 +144,23 @@ const noopRegisterControlUiDescriptor: OpenClawPluginApi["registerControlUiDescr
const noopRegisterRuntimeLifecycle: OpenClawPluginApi["registerRuntimeLifecycle"] = () => {};
const noopRegisterAgentEventSubscription: OpenClawPluginApi["registerAgentEventSubscription"] =
() => {};
const noopEmitAgentEvent: OpenClawPluginApi["emitAgentEvent"] = () => ({
emitted: false,
reason: "not wired",
});
const noopSetRunContext: OpenClawPluginApi["setRunContext"] = () => false;
const noopGetRunContext: OpenClawPluginApi["getRunContext"] = () => undefined;
const noopClearRunContext: OpenClawPluginApi["clearRunContext"] = () => {};
const noopRegisterSessionSchedulerJob: OpenClawPluginApi["registerSessionSchedulerJob"] = () =>
undefined;
const noopRegisterSessionAction: OpenClawPluginApi["registerSessionAction"] = () => {};
const noopSendSessionAttachment: OpenClawPluginApi["sendSessionAttachment"] = async () => ({
ok: false,
error: "not wired",
});
const noopScheduleSessionTurn: OpenClawPluginApi["scheduleSessionTurn"] = async () => undefined;
const noopUnscheduleSessionTurnsByTag: OpenClawPluginApi["unscheduleSessionTurnsByTag"] =
async () => ({ removed: 0, failed: 0 });
const noopRegisterDetachedTaskRuntime: OpenClawPluginApi["registerDetachedTaskRuntime"] = () => {};
const noopRegisterMemoryCapability: OpenClawPluginApi["registerMemoryCapability"] = () => {};
const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {};
@@ -159,7 +177,7 @@ const noopOn: OpenClawPluginApi["on"] = () => {};
export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi {
const handlers = params.handlers ?? {};
const registerCli = handlers.registerCli ?? noopRegisterCli;
return {
const api: OpenClawPluginApiWithoutFacades = {
id: params.id,
name: params.name,
version: params.version,
@@ -237,11 +255,17 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
registerRuntimeLifecycle: handlers.registerRuntimeLifecycle ?? noopRegisterRuntimeLifecycle,
registerAgentEventSubscription:
handlers.registerAgentEventSubscription ?? noopRegisterAgentEventSubscription,
emitAgentEvent: handlers.emitAgentEvent ?? noopEmitAgentEvent,
setRunContext: handlers.setRunContext ?? noopSetRunContext,
getRunContext: handlers.getRunContext ?? noopGetRunContext,
clearRunContext: handlers.clearRunContext ?? noopClearRunContext,
registerSessionSchedulerJob:
handlers.registerSessionSchedulerJob ?? noopRegisterSessionSchedulerJob,
registerSessionAction: handlers.registerSessionAction ?? noopRegisterSessionAction,
sendSessionAttachment: handlers.sendSessionAttachment ?? noopSendSessionAttachment,
scheduleSessionTurn: handlers.scheduleSessionTurn ?? noopScheduleSessionTurn,
unscheduleSessionTurnsByTag:
handlers.unscheduleSessionTurnsByTag ?? noopUnscheduleSessionTurnsByTag,
registerDetachedTaskRuntime:
handlers.registerDetachedTaskRuntime ?? noopRegisterDetachedTaskRuntime,
registerMemoryCapability: handlers.registerMemoryCapability ?? noopRegisterMemoryCapability,
@@ -258,4 +282,5 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
resolvePath: params.resolvePath,
on: handlers.on ?? noopOn,
};
return attachPluginApiFacades(api);
}

View File

@@ -0,0 +1,60 @@
import type { OpenClawPluginApi } from "./types.js";
type PluginApiFacadeFields = Pick<
OpenClawPluginApi,
"agent" | "lifecycle" | "runContext" | "session"
>;
export type OpenClawPluginApiWithoutFacades = Omit<OpenClawPluginApi, keyof PluginApiFacadeFields>;
type PluginApiFacadeSource = Pick<
OpenClawPluginApi,
| "clearRunContext"
| "emitAgentEvent"
| "enqueueNextTurnInjection"
| "getRunContext"
| "registerAgentEventSubscription"
| "registerControlUiDescriptor"
| "registerRuntimeLifecycle"
| "registerSessionAction"
| "registerSessionExtension"
| "registerSessionSchedulerJob"
| "scheduleSessionTurn"
| "sendSessionAttachment"
| "setRunContext"
| "unscheduleSessionTurnsByTag"
>;
export function attachPluginApiFacades<T extends object>(
api: T & PluginApiFacadeSource & Partial<PluginApiFacadeFields>,
): T & PluginApiFacadeFields {
api.session = {
state: {
registerSessionExtension: (...args) => api.registerSessionExtension(...args),
},
workflow: {
enqueueNextTurnInjection: (...args) => api.enqueueNextTurnInjection(...args),
registerSessionSchedulerJob: (...args) => api.registerSessionSchedulerJob(...args),
sendSessionAttachment: (...args) => api.sendSessionAttachment(...args),
scheduleSessionTurn: (...args) => api.scheduleSessionTurn(...args),
unscheduleSessionTurnsByTag: (...args) => api.unscheduleSessionTurnsByTag(...args),
},
controls: {
registerSessionAction: (...args) => api.registerSessionAction(...args),
registerControlUiDescriptor: (...args) => api.registerControlUiDescriptor(...args),
},
};
api.agent = {
events: {
registerAgentEventSubscription: (...args) => api.registerAgentEventSubscription(...args),
emitAgentEvent: (...args) => api.emitAgentEvent(...args),
},
};
api.runContext = {
setRunContext: (...args) => api.setRunContext(...args),
getRunContext: (...args) => api.getRunContext(...args),
clearRunContext: (...args) => api.clearRunContext(...args),
};
api.lifecycle = {
registerRuntimeLifecycle: (...args) => api.registerRuntimeLifecycle(...args),
};
return api as T & PluginApiFacadeFields;
}

View File

@@ -0,0 +1,34 @@
import type { OpenClawPluginApi } from "./types.js";
type FunctionPropertyNames<T> = Extract<
{
[K in keyof T]-?: Exclude<T[K], undefined> extends (...args: unknown[]) => unknown ? K : never;
}[keyof T],
string
>;
export type PluginApiMethodName = FunctionPropertyNames<OpenClawPluginApi>;
export type PluginApiLifecyclePolicy = {
phase: "registration" | "runtime";
lateCallable: boolean;
};
const PLUGIN_API_METHOD_POLICIES: Partial<Record<PluginApiMethodName, PluginApiLifecyclePolicy>> = {
emitAgentEvent: { phase: "runtime", lateCallable: true },
sendSessionAttachment: { phase: "runtime", lateCallable: true },
scheduleSessionTurn: { phase: "runtime", lateCallable: true },
unscheduleSessionTurnsByTag: { phase: "runtime", lateCallable: true },
};
export function getPluginApiMethodLifecyclePolicy(
methodName: string,
): PluginApiLifecyclePolicy | undefined {
return PLUGIN_API_METHOD_POLICIES[methodName as PluginApiMethodName];
}
export function isLateCallablePluginApiMethod(
methodName: string,
): methodName is PluginApiMethodName {
return getPluginApiMethodLifecyclePolicy(methodName)?.lateCallable === true;
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { capturePluginRegistration } from "./captured-registration.js";
import type { AnyAgentTool } from "./types.js";
import type { AnyAgentTool, OpenClawPluginApi } from "./types.js";
describe("captured plugin registration", () => {
it("keeps a complete plugin API surface available while capturing supported capabilities", () => {
@@ -105,4 +105,50 @@ describe("captured plugin registration", () => {
expect(captured.agentToolResultMiddlewares[0]?.runtimes).toEqual(["codex"]);
expect(captured.api.registerMemoryEmbeddingProvider).toBeTypeOf("function");
});
it("returns synthetic scheduled-turn ids independent of human-readable names", async () => {
let scheduleSessionTurn: OpenClawPluginApi["scheduleSessionTurn"] | undefined;
let registerSessionSchedulerJob: OpenClawPluginApi["registerSessionSchedulerJob"] | undefined;
const captured = capturePluginRegistration({
id: "captured-custom-plugin",
name: "Captured Custom Plugin",
register(api) {
registerSessionSchedulerJob = api.session.workflow.registerSessionSchedulerJob;
scheduleSessionTurn = api.session.workflow.scheduleSessionTurn;
},
});
expect(
registerSessionSchedulerJob?.({
id: "captured-job",
sessionKey: "agent:main:main",
kind: "session-turn",
}),
).toEqual({
id: "captured-job",
pluginId: "captured-custom-plugin",
sessionKey: "agent:main:main",
kind: "session-turn",
});
await expect(
scheduleSessionTurn?.({
sessionKey: "agent:main:main",
message: "wake",
delayMs: 1_000,
name: "human-readable-name",
}),
).resolves.toEqual({
id: "captured-session-turn-1",
pluginId: "captured-custom-plugin",
sessionKey: "agent:main:main",
kind: "session-turn",
});
expect(captured.sessionSchedulerJobs).toEqual([
{
id: "captured-job",
sessionKey: "agent:main:main",
kind: "session-turn",
},
]);
});
});

View File

@@ -10,6 +10,7 @@ import type {
PluginAgentEventSubscriptionRegistration,
PluginControlUiDescriptor,
PluginRuntimeLifecycleRegistration,
PluginSessionActionRegistration,
PluginSessionSchedulerJobRegistration,
PluginSessionExtensionRegistration,
PluginToolMetadataRegistration,
@@ -74,6 +75,7 @@ export type CapturedPluginRegistration = {
runtimeLifecycles: PluginRuntimeLifecycleRegistration[];
agentEventSubscriptions: PluginAgentEventSubscriptionRegistration[];
sessionSchedulerJobs: PluginSessionSchedulerJobRegistration[];
sessionActions: PluginSessionActionRegistration[];
tools: AnyAgentTool[];
modelCatalogProviders: UnifiedModelCatalogProviderPlugin[];
};
@@ -110,6 +112,8 @@ export function createCapturedPluginRegistration(params?: {
const runtimeLifecycles: PluginRuntimeLifecycleRegistration[] = [];
const agentEventSubscriptions: PluginAgentEventSubscriptionRegistration[] = [];
const sessionSchedulerJobs: PluginSessionSchedulerJobRegistration[] = [];
const sessionActions: PluginSessionActionRegistration[] = [];
let capturedSessionTurnCount = 0;
const tools: AnyAgentTool[] = [];
const modelCatalogProviders: UnifiedModelCatalogProviderPlugin[] = [];
const pluginId = params?.id ?? "captured-plugin-registration";
@@ -148,6 +152,7 @@ export function createCapturedPluginRegistration(params?: {
runtimeLifecycles,
agentEventSubscriptions,
sessionSchedulerJobs,
sessionActions,
tools,
modelCatalogProviders,
api: buildPluginApi({
@@ -270,15 +275,30 @@ export function createCapturedPluginRegistration(params?: {
registerAgentEventSubscription(subscription: PluginAgentEventSubscriptionRegistration) {
agentEventSubscriptions.push(subscription);
},
emitAgentEvent: () => ({ emitted: false, reason: "captured registration" }),
registerSessionSchedulerJob(job: PluginSessionSchedulerJobRegistration) {
sessionSchedulerJobs.push(job);
return {
id: job.id,
pluginId: "captured-plugin-registration",
pluginId,
sessionKey: job.sessionKey,
kind: job.kind,
};
},
registerSessionAction(action: PluginSessionActionRegistration) {
sessionActions.push(action);
},
sendSessionAttachment: async () => ({ ok: false, error: "captured registration" }),
scheduleSessionTurn: async (schedule) => {
capturedSessionTurnCount += 1;
return {
id: `captured-session-turn-${capturedSessionTurnCount}`,
pluginId,
sessionKey: schedule.sessionKey,
kind: "session-turn",
};
},
unscheduleSessionTurnsByTag: async () => ({ removed: 0, failed: 0 }),
registerTool(tool) {
if (typeof tool !== "function") {
tools.push(tool);
@@ -289,10 +309,12 @@ export function createCapturedPluginRegistration(params?: {
};
}
export function capturePluginRegistration(params: {
register(api: OpenClawPluginApi): void;
}): CapturedPluginRegistration {
const captured = createCapturedPluginRegistration();
export function capturePluginRegistration(
params: NonNullable<Parameters<typeof createCapturedPluginRegistration>[0]> & {
register(api: OpenClawPluginApi): void;
},
): CapturedPluginRegistration {
const captured = createCapturedPluginRegistration(params);
params.register(captured.api);
return captured;
}

View File

@@ -33,7 +33,7 @@ const BUNDLED_EXTENSION_CONFIG_IMPORT_GUARDS = [
},
{
path: "extensions/googlechat/src/config-schema.ts",
allowedSpecifier: "../runtime-api.js",
allowedSpecifier: "../config-api.js",
},
// Teams keeps a package-local config barrel so production code does not
// reach through a public plugin-sdk helper seam from inside the same extension.

View File

@@ -1,7 +1,7 @@
import type { OpenClawPluginApi } from "../types.js";
export function registerHostHookFixture(api: OpenClawPluginApi) {
api.registerSessionExtension({
api.session.state.registerSessionExtension({
namespace: "workflow",
description: "Generic approval-workflow state projection",
});
@@ -12,18 +12,18 @@ export function registerHostHookFixture(api: OpenClawPluginApi) {
risk: "medium",
tags: ["fixture", "approval"],
});
api.registerControlUiDescriptor({
api.session.controls.registerControlUiDescriptor({
id: "workflow-card",
surface: "session",
label: "Workflow Card",
description: "Generic Control UI descriptor for workflow state",
placement: "session-sidebar",
});
api.registerRuntimeLifecycle({
api.lifecycle.registerRuntimeLifecycle({
id: "workflow-cleanup",
description: "Generic cleanup hook for plugin-owned workflow state",
});
api.registerAgentEventSubscription({
api.agent.events.registerAgentEventSubscription({
id: "workflow-events",
description: "Generic sanitized agent-event subscription for workflow plugins",
streams: ["lifecycle", "tool"],
@@ -36,7 +36,7 @@ export function registerHostHookFixture(api: OpenClawPluginApi) {
}
},
});
api.registerSessionSchedulerJob({
api.session.workflow.registerSessionSchedulerJob({
id: "workflow-nudge",
sessionKey: "agent:main:main",
kind: "nudge",

View File

@@ -2196,6 +2196,63 @@ describe("host-hook fixture plugin contract", () => {
]);
});
it("does not invoke old scheduler cleanup for a preserved newer generation", async () => {
const cleanupEvents: string[] = [];
const previousFixture = createPluginRegistryFixture();
registerTestPlugin({
registry: previousFixture.registry,
config: previousFixture.config,
record: createPluginRecord({
id: "scheduler-preserve",
name: "Scheduler Preserve",
}),
register(api) {
api.registerSessionSchedulerJob({
id: "shared-job",
sessionKey: "agent:main:main",
kind: "monitor",
cleanup: ({ reason, jobId }) => {
cleanupEvents.push(`${reason}:${jobId}`);
},
});
},
});
const replacementFixture = createPluginRegistryFixture();
registerTestPlugin({
registry: replacementFixture.registry,
config: replacementFixture.config,
record: createPluginRecord({
id: "scheduler-preserve",
name: "Scheduler Preserve",
}),
register(api) {
api.registerSessionSchedulerJob({
id: "shared-job",
sessionKey: "agent:main:main",
kind: "monitor",
});
},
});
await expect(
cleanupReplacedPluginHostRegistry({
cfg: previousFixture.config,
previousRegistry: previousFixture.registry.registry,
nextRegistry: replacementFixture.registry.registry,
}),
).resolves.toMatchObject({ failures: [] });
expect(cleanupEvents).toEqual([]);
expect(listPluginSessionSchedulerJobs("scheduler-preserve")).toEqual([
{
id: "shared-job",
pluginId: "scheduler-preserve",
sessionKey: "agent:main:main",
kind: "monitor",
},
]);
});
it("does not let stale scheduler cleanup delete a newer job generation", async () => {
let releaseCleanup: (() => void) | undefined;
let markCleanupStarted: (() => void) | undefined;

View File

@@ -70,7 +70,7 @@ describe("plugin run context lifecycle", () => {
setActivePluginRegistry(createEmptyPluginRegistry());
expect(
capturedApi?.setRunContext({
capturedApi?.runContext?.setRunContext({
runId: "stale-run",
namespace: "state",
value: { stale: true },
@@ -89,7 +89,7 @@ describe("plugin run context lifecycle", () => {
patch: { runId: "stale-run", namespace: "state", value: { live: true } },
}),
).toBe(true);
capturedApi?.clearRunContext({ runId: "stale-run", namespace: "state" });
capturedApi?.runContext?.clearRunContext({ runId: "stale-run", namespace: "state" });
expect(
getPluginRunContext({
pluginId: "stale-run-context-plugin",
@@ -117,16 +117,16 @@ describe("plugin run context lifecycle", () => {
setActivePluginRegistry(registry.registry);
expect(
capturedApi?.setRunContext({
capturedApi?.runContext?.setRunContext({
runId: "restored-run",
namespace: "state",
value: { restored: true },
}),
).toBe(true);
expect(
getPluginRunContext({
pluginId: "restored-run-context-plugin",
get: { runId: "restored-run", namespace: "state" },
capturedApi?.runContext?.getRunContext({
runId: "restored-run",
namespace: "state",
}),
).toEqual({ restored: true });
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,926 @@
import {
createPluginRegistryFixture,
registerTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { APPROVALS_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../../gateway/operator-scopes.js";
import { handleGatewayRequest } from "../../gateway/server-methods.js";
import { pluginHostHookHandlers } from "../../gateway/server-methods/plugin-host-hooks.js";
import type { GatewayClient, RespondFn } from "../../gateway/server-methods/types.js";
import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js";
import { createEmptyPluginRegistry } from "../registry-empty.js";
import { createPluginRegistry } from "../registry.js";
import { setActivePluginRegistry } from "../runtime.js";
import { createPluginRecord } from "../status.test-helpers.js";
import type { OpenClawPluginApi } from "../types.js";
const MAIN_SESSION_KEY = "agent:main:main";
function sessionActionBody(
pluginId: string,
actionId: string,
extra?: Record<string, unknown>,
): Record<string, unknown> {
return {
pluginId,
actionId,
...extra,
};
}
async function callPluginSessionActionForTest(params: {
body: Record<string, unknown>;
scopes?: string[];
}): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> {
let response: { ok: boolean; payload?: unknown; error?: unknown } | undefined;
const respond: RespondFn = (ok, payload, error) => {
response = { ok, payload, error };
};
await pluginHostHookHandlers["plugins.sessionAction"]({
req: { id: "test", type: "req", method: "plugins.sessionAction", params: params.body },
params: params.body,
client: {
connId: "test-client",
connect: { scopes: params.scopes ?? [WRITE_SCOPE] },
} as GatewayClient,
isWebchatConnect: () => false,
respond,
context: {} as never,
});
return response ?? { ok: false, error: new Error("handler did not respond") };
}
async function callRegisteredSessionActionForTest(params: {
pluginId: string;
actionId: string;
extra?: Record<string, unknown>;
scopes?: string[];
}): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> {
return callPluginSessionActionForTest({
body: sessionActionBody(params.pluginId, params.actionId, params.extra),
...(params.scopes ? { scopes: params.scopes } : {}),
});
}
async function callPluginSessionActionThroughGatewayForTest(params: {
body: Record<string, unknown>;
scopes?: string[];
}): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> {
let response: { ok: boolean; payload?: unknown; error?: unknown } | undefined;
const respond: RespondFn = (ok, payload, error) => {
response = { ok, payload, error };
};
await handleGatewayRequest({
req: { id: "test", type: "req", method: "plugins.sessionAction", params: params.body },
respond,
client: {
connId: "test-client",
connect: {
role: "operator",
scopes: params.scopes ?? [],
},
} as GatewayClient,
isWebchatConnect: () => false,
context: {
logGateway: {
warn() {},
},
} as unknown as Parameters<typeof handleGatewayRequest>[0]["context"],
});
return response ?? { ok: false, error: new Error("handler did not respond") };
}
async function callRegisteredSessionActionThroughGatewayForTest(params: {
pluginId: string;
actionId: string;
extra?: Record<string, unknown>;
scopes?: string[];
}): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> {
return callPluginSessionActionThroughGatewayForTest({
body: sessionActionBody(params.pluginId, params.actionId, params.extra),
...(params.scopes ? { scopes: params.scopes } : {}),
});
}
function registerActionFixture(params: {
id: string;
name?: string;
register: (api: OpenClawPluginApi) => void;
}) {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: params.id,
name: params.name ?? params.id,
}),
register: params.register,
});
return { config, registry };
}
describe("plugin session actions", () => {
afterEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
resetAgentEventsForTest();
});
it("initializes and registers typed session actions", () => {
expect(createEmptyPluginRegistry().sessionActions).toEqual([]);
const { registry } = registerActionFixture({
id: "session-action-fixture",
name: "Session Action Fixture",
register(api) {
api.registerSessionAction({
id: "approve",
description: "Approve the current workflow",
requiredScopes: [APPROVALS_SCOPE],
handler: () => ({ ok: true, result: { accepted: true } }),
});
},
});
expect(registry.registry.sessionActions).toHaveLength(1);
expect(registry.registry.sessionActions?.[0]).toMatchObject({
pluginId: "session-action-fixture",
pluginName: "Session Action Fixture",
action: {
id: "approve",
description: "Approve the current workflow",
requiredScopes: [APPROVALS_SCOPE],
},
});
});
it("rejects invalid or duplicate session action registrations", () => {
const { registry } = registerActionFixture({
id: "invalid-session-actions",
name: "Invalid Session Actions",
register(api) {
for (const action of [
{ id: "dup" },
{ id: "dup" },
{ id: "bad-scope", requiredScopes: ["not-a-scope"] as never },
{ id: "bad-schema-shape", schema: "not-an-object" as never },
{ id: "bad-schema-compile", schema: { type: "not-a-json-schema-type" } as never },
{ id: "" },
]) {
api.registerSessionAction({
...action,
handler: () => ({ ok: true }),
});
}
},
});
expect(registry.registry.sessionActions?.map((entry) => entry.action.id)).toEqual(["dup"]);
expect(registry.registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId: "invalid-session-actions",
message: "session action already registered: dup",
}),
expect.objectContaining({
pluginId: "invalid-session-actions",
message: "session action requiredScopes contains unknown operator scope: not-a-scope",
}),
expect.objectContaining({
pluginId: "invalid-session-actions",
message:
"session action schema must be a JSON schema object or boolean: bad-schema-shape",
}),
expect.objectContaining({
pluginId: "invalid-session-actions",
message: expect.stringContaining(
"session action schema is not valid JSON Schema: bad-schema-compile",
),
}),
expect.objectContaining({
pluginId: "invalid-session-actions",
message: "session action registration requires id, handler, and valid optional fields",
}),
]),
);
});
it("validates payload schemas and typed action results", async () => {
const callSchemaAction = (
actionId: string,
extra?: Record<string, unknown>,
): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> =>
callRegisteredSessionActionForTest({
pluginId: "schema-action-fixture",
actionId,
...(extra ? { extra } : {}),
});
const handlerCalls: unknown[] = [];
const { registry } = registerActionFixture({
id: "schema-action-fixture",
name: "Schema Action Fixture",
register(api) {
api.registerSessionAction({
id: "approve",
schema: {
type: "object",
additionalProperties: false,
required: ["version"],
properties: {
version: { type: "string" },
},
},
handler: ({ payload, sessionKey, client }) => {
handlerCalls.push({ payload, sessionKey, scopes: client?.scopes ?? [] });
return {
result: { accepted: true, ...(sessionKey ? { sessionKey } : {}) },
continueAgent: true,
reply: { text: "approved" },
};
},
});
api.registerSessionAction({
id: "typed-error",
handler: () => ({
ok: false,
error: "needs operator input",
code: "needs_input",
details: { field: "version" },
}),
});
api.registerSessionAction({
id: "allow-any",
schema: true,
handler: ({ payload }) => ({ result: { payload: payload ?? null } }),
});
api.registerSessionAction({
id: "deny-all",
schema: false,
handler: () => ({ result: { unreachable: true } }),
});
},
});
setActivePluginRegistry(registry.registry);
const rejected = await callPluginSessionActionForTest({
body: sessionActionBody("schema-action-fixture", "approve", { payload: { version: 1 } }),
});
expect(rejected.ok).toBe(false);
expect(rejected.error).toMatchObject({
code: "INVALID_REQUEST",
});
expect(String((rejected.error as { message?: unknown } | undefined)?.message)).toContain(
"plugin session action payload does not match schema",
);
expect(handlerCalls).toEqual([]);
await expect(
callSchemaAction("approve", {
sessionKey: MAIN_SESSION_KEY,
payload: { version: "2026.05.01" },
}),
).resolves.toEqual({
ok: true,
payload: {
ok: true,
result: { accepted: true, sessionKey: MAIN_SESSION_KEY },
continueAgent: true,
reply: { text: "approved" },
},
error: undefined,
});
expect(handlerCalls).toEqual([
{
payload: { version: "2026.05.01" },
sessionKey: MAIN_SESSION_KEY,
scopes: [WRITE_SCOPE],
},
]);
await expect(callSchemaAction("typed-error")).resolves.toMatchObject({
ok: true,
payload: {
ok: false,
error: "needs operator input",
code: "needs_input",
details: {
field: "version",
},
},
});
await expect(
callSchemaAction("allow-any", { payload: { any: ["json", true] } }),
).resolves.toMatchObject({
ok: true,
payload: {
ok: true,
result: { payload: { any: ["json", true] } },
},
});
await expect(
callSchemaAction("deny-all", { payload: { rejected: true } }),
).resolves.toMatchObject({
ok: false,
error: {
code: "INVALID_REQUEST",
},
});
});
it("validates plugin session action results before returning gateway payloads", async () => {
const callValidationAction = (
actionId: string,
): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> =>
callRegisteredSessionActionForTest({
pluginId: "session-action-validation-fixture",
actionId,
});
const { registry } = registerActionFixture({
id: "session-action-validation-fixture",
name: "Session Action Validation Fixture",
register(api) {
const handlers = {
"bad-result": () => ({ result: 1n as never }),
"bad-reply": () => ({ reply: { text: "ok", extra: () => undefined } as never }),
"primitive-result": () => "not-an-object" as never,
"typed-error": () => ({
ok: false,
error: "needs operator input",
code: "needs_input",
details: { field: "version" },
}),
"bad-ok": () =>
({
ok: "false",
error: "must not masquerade as success",
}) as never,
"error-shaped-success": () =>
({
error: "must declare ok false",
}) as never,
"bad-error-details": () => ({
ok: false,
error: "bad details",
details: { value: 1n } as never,
}),
"bad-continue-agent": () => ({ continueAgent: "yes" as never }),
"mixed-branch-fields": () =>
({
ok: false,
error: "stop",
continueAgent: true,
result: { leaked: true },
}) as never,
"unknown-success-field": () =>
({
result: { accepted: true },
extra: "unexpected",
}) as never,
"throws-secret": () => {
throw new Error("fixture action failed");
},
};
for (const [id, handler] of Object.entries(handlers)) {
api.registerSessionAction({ id, handler: handler as never });
}
},
});
setActivePluginRegistry(registry.registry);
const expectValidationError = async (actionId: string, message: unknown) => {
await expect(callValidationAction(actionId)).resolves.toMatchObject({
ok: false,
error: {
code: "INVALID_REQUEST",
message,
},
});
};
await expectValidationError(
"bad-result",
"plugin session action result must be JSON-compatible",
);
await expectValidationError("bad-reply", "plugin session action reply must be JSON-compatible");
await expect(callValidationAction("primitive-result")).resolves.toMatchObject({
ok: false,
error: {
code: "INVALID_REQUEST",
message: "plugin session action result must be an object",
},
});
await expect(callValidationAction("typed-error")).resolves.toMatchObject({
ok: true,
payload: {
ok: false,
error: "needs operator input",
details: {
field: "version",
},
},
});
await expectValidationError("bad-ok", expect.stringContaining("/ok: must be boolean"));
await expectValidationError(
"error-shaped-success",
expect.stringContaining("unexpected property 'error'"),
);
await expectValidationError(
"bad-error-details",
"plugin session action details must be JSON-compatible",
);
await expectValidationError(
"bad-continue-agent",
expect.stringContaining("/continueAgent: must be boolean"),
);
await expectValidationError(
"mixed-branch-fields",
expect.stringContaining("unexpected property 'continueAgent'"),
);
await expectValidationError(
"unknown-success-field",
expect.stringContaining("unexpected property 'extra'"),
);
await expect(callValidationAction("throws-secret")).resolves.toMatchObject({
ok: false,
error: {
code: "UNAVAILABLE",
message: "plugin session action failed",
},
});
});
it("authorizes session actions through the gateway by action-declared scopes", async () => {
const callApprovalAction = (params: {
actionId: string;
extra?: Record<string, unknown>;
scopes?: string[];
}): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> =>
callRegisteredSessionActionThroughGatewayForTest({
pluginId: "approval-action-fixture",
actionId: params.actionId,
...(params.extra ? { extra: params.extra } : {}),
...(params.scopes ? { scopes: params.scopes } : {}),
});
const handlerCalls: unknown[] = [];
const { registry } = registerActionFixture({
id: "approval-action-fixture",
name: "Approval Action Fixture",
register(api) {
api.registerSessionAction({
id: "approve",
requiredScopes: [APPROVALS_SCOPE],
handler: ({ client, sessionKey }) => {
handlerCalls.push({ scopes: client?.scopes ?? [], sessionKey });
return {
result: { approved: true, ...(sessionKey ? { sessionKey } : {}) },
continueAgent: true,
};
},
});
api.registerSessionAction({
id: "view",
requiredScopes: [READ_SCOPE],
handler: ({ client }) => {
handlerCalls.push({ scopes: client?.scopes ?? [], action: "view" });
return { result: { visible: true }, continueAgent: false };
},
});
},
});
setActivePluginRegistry(registry.registry);
await expect(
callApprovalAction({
actionId: "approve",
scopes: [APPROVALS_SCOPE],
}),
).resolves.toEqual({
ok: true,
payload: { ok: true, result: { approved: true }, continueAgent: true },
error: undefined,
});
await expect(
callApprovalAction({
actionId: "view",
scopes: [WRITE_SCOPE],
}),
).resolves.toEqual({
ok: true,
payload: { ok: true, result: { visible: true }, continueAgent: false },
error: undefined,
});
await expect(
callApprovalAction({
actionId: "approve",
scopes: [READ_SCOPE],
}),
).resolves.toMatchObject({
ok: false,
error: {
code: "INVALID_REQUEST",
message: `missing scope: ${APPROVALS_SCOPE}`,
},
});
expect(handlerCalls).toEqual([
{ scopes: [APPROVALS_SCOPE], sessionKey: undefined },
{ scopes: [WRITE_SCOPE], action: "view" },
]);
await expect(
callApprovalAction({
actionId: "approve",
extra: { sessionKey: ` ${MAIN_SESSION_KEY} ` },
scopes: [APPROVALS_SCOPE],
}),
).resolves.toEqual({
ok: true,
payload: {
ok: true,
result: { approved: true, sessionKey: MAIN_SESSION_KEY },
continueAgent: true,
},
error: undefined,
});
await expect(
callApprovalAction({
actionId: "view",
scopes: [READ_SCOPE],
}),
).resolves.toEqual({
ok: true,
payload: { ok: true, result: { visible: true }, continueAgent: false },
error: undefined,
});
await expect(
callPluginSessionActionThroughGatewayForTest({
body: {
pluginId: " ",
actionId: "approve",
},
scopes: [APPROVALS_SCOPE],
}),
).resolves.toMatchObject({
ok: false,
error: {
code: "INVALID_REQUEST",
message: "plugins.sessionAction pluginId and actionId must be non-empty",
},
});
expect(handlerCalls).toEqual([
{ scopes: [APPROVALS_SCOPE], sessionKey: undefined },
{ scopes: [WRITE_SCOPE], action: "view" },
{ scopes: [APPROVALS_SCOPE], sessionKey: MAIN_SESSION_KEY },
{ scopes: [READ_SCOPE], action: "view" },
]);
});
it("passes a defensive copy of client scopes to session action handlers", async () => {
const registry = createEmptyPluginRegistry();
let response: { ok: boolean; payload?: unknown; error?: unknown } | undefined;
let handlerScopes: string[] | undefined;
const originalScopes = [READ_SCOPE];
registry.sessionActions = [
{
pluginId: "scope-copy-fixture",
pluginName: "Scope Copy Fixture",
source: "test",
action: {
id: "mutate",
requiredScopes: [READ_SCOPE],
handler: ({ client }) => {
handlerScopes = client?.scopes;
client?.scopes.push(APPROVALS_SCOPE);
return { result: { ok: true } };
},
},
},
];
registry.plugins = [createPluginRecord({ id: "scope-copy-fixture" })];
setActivePluginRegistry(registry);
await pluginHostHookHandlers["plugins.sessionAction"]({
req: {
id: "scope-copy",
type: "req",
method: "plugins.sessionAction",
params: { pluginId: "scope-copy-fixture", actionId: "mutate" },
},
params: { pluginId: "scope-copy-fixture", actionId: "mutate" },
client: {
connId: "scope-copy-client",
connect: { scopes: originalScopes },
} as GatewayClient,
isWebchatConnect: () => false,
respond: (ok, payload, error) => {
response = { ok, payload, error };
},
context: {} as never,
});
expect(response).toEqual({
ok: true,
payload: { ok: true, result: { ok: true } },
error: undefined,
});
expect(handlerScopes).toEqual([READ_SCOPE, APPROVALS_SCOPE]);
expect(handlerScopes).not.toBe(originalScopes);
expect(originalScopes).toEqual([READ_SCOPE]);
});
it("does not dispatch session actions for plugins that are not loaded", async () => {
const handler = vi.fn(() => ({ result: { stale: true } }));
const registry = createEmptyPluginRegistry();
registry.sessionActions = [
{
pluginId: "failed-action-plugin",
pluginName: "Failed Action Plugin",
source: "test",
action: {
id: "stale",
requiredScopes: [READ_SCOPE],
handler,
},
},
];
registry.plugins = [
createPluginRecord({
id: "failed-action-plugin",
name: "Failed Action Plugin",
status: "error",
}),
];
setActivePluginRegistry(registry);
await expect(
callPluginSessionActionThroughGatewayForTest({
body: {
pluginId: "failed-action-plugin",
actionId: "stale",
},
scopes: [READ_SCOPE],
}),
).resolves.toMatchObject({
ok: false,
error: {
code: "UNAVAILABLE",
message: "unknown plugin session action: failed-action-plugin/stale",
},
});
expect(handler).not.toHaveBeenCalled();
});
it("emits plugin-attributed agent events through the plugin API", () => {
const observed: unknown[] = [];
const unsubscribe = onAgentEvent((event) => observed.push(event));
const { config, registry } = createPluginRegistryFixture();
let bundledApi: OpenClawPluginApi | undefined;
let workspaceApi: OpenClawPluginApi | undefined;
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "event-plugin",
name: "Event Plugin",
origin: "bundled",
}),
register(api) {
bundledApi = api;
},
});
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "workspace-event-plugin",
name: "Workspace Event Plugin",
origin: "workspace",
}),
register(api) {
workspaceApi = api;
},
});
setActivePluginRegistry(registry.registry);
try {
expect(
bundledApi?.agent?.events.emitAgentEvent({
runId: "run-emit",
sessionKey: " agent:main:main ",
stream: "approval",
data: { state: "queued" },
}),
).toEqual({ emitted: true, stream: "approval" });
expect(
workspaceApi?.emitAgentEvent({
runId: "run-emit",
stream: "lifecycle",
data: { phase: "end" },
}),
).toEqual({ emitted: false, reason: "stream lifecycle is reserved for bundled plugins" });
expect(
workspaceApi?.emitAgentEvent({
runId: "run-emit",
stream: "assistant",
data: { text: "spoofed assistant output" },
}),
).toEqual({ emitted: false, reason: "stream assistant is reserved for bundled plugins" });
expect(
workspaceApi?.emitAgentEvent({
runId: "run-emit",
stream: "other-plugin.workflow",
data: { state: "queued" },
}),
).toEqual({
emitted: false,
reason: "stream other-plugin.workflow must be scoped to plugin workspace-event-plugin",
});
expect(
workspaceApi?.emitAgentEvent({
runId: "run-emit",
stream: "workspace-event-plugin.workflow",
data: { state: "queued" },
}),
).toEqual({ emitted: true, stream: "workspace-event-plugin.workflow" });
expect(
bundledApi?.emitAgentEvent({
runId: "run-emit",
stream: "approval",
data: 1n as never,
}),
).toEqual({ emitted: false, reason: "event data must be JSON-compatible" });
} finally {
unsubscribe();
}
expect(observed).toEqual([
expect.objectContaining({
runId: "run-emit",
sessionKey: "agent:main:main",
stream: "approval",
data: {
state: "queued",
pluginId: "event-plugin",
pluginName: "Event Plugin",
},
}),
expect.objectContaining({
runId: "run-emit",
stream: "workspace-event-plugin.workflow",
data: {
state: "queued",
pluginId: "workspace-event-plugin",
pluginName: "Workspace Event Plugin",
},
}),
]);
});
it("blocks agent events from stale and non-activating plugin API closures", () => {
const observed: unknown[] = [];
const unsubscribe = onAgentEvent((event) => observed.push(event));
const { config, registry } = createPluginRegistryFixture();
let capturedApi: OpenClawPluginApi | undefined;
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "stale-event-plugin",
name: "Stale Event Plugin",
origin: "bundled",
}),
register(api) {
capturedApi = api;
},
});
setActivePluginRegistry(registry.registry);
setActivePluginRegistry(createEmptyPluginRegistry());
try {
expect(
capturedApi?.emitAgentEvent({
runId: "stale-run",
stream: "approval",
data: { stale: true },
}),
).toEqual({ emitted: false, reason: "plugin is not loaded" });
const neverActiveRegistry = createPluginRegistry({
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
runtime: {} as never,
});
let neverActiveApi: OpenClawPluginApi | undefined;
registerTestPlugin({
registry: neverActiveRegistry,
config,
record: createPluginRecord({
id: "never-active-event-plugin",
name: "Never Active Event Plugin",
origin: "bundled",
}),
register(api) {
neverActiveApi = api;
},
});
expect(
neverActiveApi?.emitAgentEvent({
runId: "never-active-run",
stream: "approval",
data: { inactive: true },
}),
).toEqual({ emitted: false, reason: "plugin is not loaded" });
const inactiveRegistry = createPluginRegistry({
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
runtime: {} as never,
activateGlobalSideEffects: false,
});
let inactiveApi: OpenClawPluginApi | undefined;
registerTestPlugin({
registry: inactiveRegistry,
config,
record: createPluginRecord({
id: "inactive-event-plugin",
name: "Inactive Event Plugin",
origin: "bundled",
}),
register(api) {
inactiveApi = api;
},
});
expect(
inactiveApi?.emitAgentEvent({
runId: "inactive-run",
stream: "approval",
data: { inactive: true },
}),
).toEqual({ emitted: false, reason: "global side effects disabled" });
} finally {
unsubscribe();
}
expect(observed).toEqual([]);
});
it("allows reactivated cached registries to emit agent events again", () => {
const observed: unknown[] = [];
const unsubscribe = onAgentEvent((event) => observed.push(event));
const { config, registry } = createPluginRegistryFixture();
let capturedApi: OpenClawPluginApi | undefined;
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "reactivated-event-plugin",
name: "Reactivated Event Plugin",
origin: "bundled",
}),
register(api) {
capturedApi = api;
},
});
setActivePluginRegistry(registry.registry);
setActivePluginRegistry(createEmptyPluginRegistry());
setActivePluginRegistry(registry.registry);
try {
expect(
capturedApi?.emitAgentEvent({
runId: "reactivated-run",
stream: "approval",
data: { active: true },
}),
).toEqual({ emitted: true, stream: "approval" });
} finally {
unsubscribe();
}
expect(observed).toEqual([
expect.objectContaining({
runId: "reactivated-run",
stream: "approval",
data: {
active: true,
pluginId: "reactivated-event-plugin",
pluginName: "Reactivated Event Plugin",
},
}),
]);
});
});

View File

@@ -0,0 +1,708 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
createPluginRegistryFixture,
registerTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
import { withTempConfig } from "../../gateway/test-temp-config.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { FILE_TYPE_SNIFF_MAX_BYTES } from "../../media/mime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { resolveAttachmentDelivery, sendPluginSessionAttachment } from "../host-hook-attachments.js";
import { clearPluginLoaderCache } from "../loader.js";
import { createEmptyPluginRegistry } from "../registry-empty.js";
import { createPluginRegistry } from "../registry.js";
import { setActivePluginRegistry } from "../runtime.js";
import type { PluginRuntime } from "../runtime/types.js";
import { createPluginRecord } from "../status.test-helpers.js";
import type { OpenClawPluginApi } from "../types.js";
const workflowMocks = vi.hoisted(() => ({
getChannelPlugin: vi.fn(),
sendMessage: vi.fn(),
}));
const MAIN_SESSION_KEY = "agent:main:main";
const DEFAULT_TELEGRAM_ROUTE = {
channel: "telegram",
to: "12345",
} as const;
type SessionAttachmentRequest = Parameters<typeof sendPluginSessionAttachment>[0];
type TestSessionEntry = {
sessionId?: string;
updatedAt?: number;
deliveryContext?: Record<string, unknown>;
};
vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: workflowMocks.getChannelPlugin,
}));
vi.mock("../../infra/outbound/message.js", () => ({
sendMessage: workflowMocks.sendMessage,
}));
function createSilentPluginLogger() {
return {
info() {},
warn() {},
error() {},
debug() {},
};
}
async function withSessionStore(
run: (params: { stateDir: string; storePath: string; filePath: string }) => Promise<void>,
) {
const stateDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-session-attachments-"),
);
const storePath = path.join(stateDir, "sessions.json");
const filePath = path.join(stateDir, "x.txt");
await fs.writeFile(filePath, "x", "utf8");
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = stateDir;
try {
await withTempConfig({
cfg: { session: { store: storePath } },
run: async () => await run({ stateDir, storePath, filePath }),
});
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
await fs.rm(stateDir, { recursive: true, force: true });
}
}
async function writeSessionEntry(
storePath: string,
entry: TestSessionEntry = { deliveryContext: DEFAULT_TELEGRAM_ROUTE },
key = MAIN_SESSION_KEY,
) {
await updateSessionStore(storePath, (store) => {
store[key] = {
sessionId: "session-id",
updatedAt: Date.now(),
...entry,
} as unknown as SessionEntry;
return undefined;
});
}
function mockSuccessfulAttachmentDelivery(messageId = "attachment-1") {
workflowMocks.sendMessage.mockImplementation(async (params: Record<string, unknown>) => ({
channel: params.channel,
to: params.to,
via: "direct" as const,
mediaUrl: null,
result: { channel: params.channel, messageId },
}));
}
async function sendBundledSessionAttachment(
params: Omit<SessionAttachmentRequest, "origin" | "sessionKey"> &
Partial<Pick<SessionAttachmentRequest, "sessionKey">>,
) {
return await sendPluginSessionAttachment({
origin: "bundled",
sessionKey: MAIN_SESSION_KEY,
...params,
});
}
describe("plugin session attachments", () => {
afterEach(() => {
workflowMocks.getChannelPlugin.mockReset();
workflowMocks.sendMessage.mockReset();
setActivePluginRegistry(createEmptyPluginRegistry());
clearPluginLoaderCache();
delete (globalThis as { __proofAttachmentApi?: OpenClawPluginApi }).__proofAttachmentApi;
delete (globalThis as { __proofAttachmentLog?: unknown[] }).__proofAttachmentLog;
});
it("resolves channel hint precedence for attachment delivery", () => {
expect(
resolveAttachmentDelivery({
channel: "telegram",
captionFormat: "html",
channelHints: { telegram: { parseMode: "HTML" } },
}),
).toEqual({ parseMode: "HTML" });
expect(resolveAttachmentDelivery({ channel: "telegram", captionFormat: "html" })).toEqual({
parseMode: "HTML",
});
expect(resolveAttachmentDelivery({ channel: "telegram", captionFormat: "plain" })).toEqual({
parseMode: "HTML",
escapePlainHtmlCaption: true,
});
expect(
resolveAttachmentDelivery({
channel: "telegram",
captionFormat: "plain",
channelHints: { telegram: { parseMode: "HTML" } },
}),
).toEqual({
parseMode: "HTML",
escapePlainHtmlCaption: true,
});
expect(
resolveAttachmentDelivery({
channel: "telegram",
channelHints: {
telegram: { disableNotification: true, forceDocumentMime: "application/pdf" },
},
}),
).toEqual({
disableNotification: true,
forceDocumentMime: "application/pdf",
});
expect(
resolveAttachmentDelivery({
channel: "slack",
channelHints: { slack: { threadTs: "1700000000.000100" } },
}),
).toEqual({ threadTs: "1700000000.000100" });
expect(
resolveAttachmentDelivery({
channel: "slack",
channelHints: { slack: { threadTs: " 1700000000.000100 " } },
}),
).toEqual({ threadTs: "1700000000.000100" });
expect(
resolveAttachmentDelivery({
channel: "slack",
channelHints: { slack: { threadTs: " " } },
}),
).toEqual({});
expect(resolveAttachmentDelivery({ channel: "discord", captionFormat: "markdown" })).toEqual(
{},
);
expect(
resolveAttachmentDelivery({
channel: "unknown",
channelHints: { telegram: { parseMode: "HTML" } },
}),
).toEqual({});
});
it("sends validated files through the session delivery route with channel hints", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath, {
deliveryContext: {
...DEFAULT_TELEGRAM_ROUTE,
accountId: "default",
threadId: 42,
},
});
mockSuccessfulAttachmentDelivery();
const result = await sendBundledSessionAttachment({
files: [{ path: filePath }],
channelHints: { telegram: { disableNotification: true, parseMode: "HTML" } },
});
expect(result).toEqual({
ok: true,
channel: "telegram",
deliveredTo: "12345",
count: 1,
});
expect(workflowMocks.sendMessage).toHaveBeenCalledTimes(1);
expect(workflowMocks.sendMessage.mock.calls[0]?.[0]).toMatchObject({
to: "12345",
channel: "telegram",
accountId: "default",
threadId: 42,
mediaUrls: [filePath],
bestEffort: false,
silent: true,
parseMode: "HTML",
});
});
});
it("does not use best-effort mode for attachment batches", async () => {
await withSessionStore(async ({ storePath, stateDir }) => {
const first = path.join(stateDir, "first.txt");
const second = path.join(stateDir, "second.txt");
await fs.writeFile(first, "1", "utf8");
await fs.writeFile(second, "2", "utf8");
await writeSessionEntry(storePath);
mockSuccessfulAttachmentDelivery();
await expect(
sendBundledSessionAttachment({
files: [{ path: first }, { path: second }],
}),
).resolves.toMatchObject({ ok: true, channel: "telegram", count: 2 });
expect(workflowMocks.sendMessage.mock.calls[0]?.[0]).toMatchObject({
mediaUrls: [first, second],
bestEffort: false,
});
});
});
it("escapes plain Telegram attachment captions before HTML delivery", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath);
mockSuccessfulAttachmentDelivery();
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
text: "1 < 2 & 3 > 2",
captionFormat: "plain",
channelHints: { telegram: { parseMode: "HTML" } },
}),
).resolves.toMatchObject({
ok: true,
channel: "telegram",
deliveredTo: "12345",
count: 1,
});
expect(workflowMocks.sendMessage.mock.calls[0]?.[0]).toMatchObject({
content: "1 &lt; 2 &amp; 3 &gt; 2",
parseMode: "HTML",
});
});
});
it("resolves relative attachment paths against the session agent workspace", async () => {
await withSessionStore(async ({ storePath, stateDir }) => {
const workspaceDir = path.join(stateDir, "workspace");
const relativeFilePath = "./report.txt";
const absoluteFilePath = path.join(workspaceDir, "report.txt");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(absoluteFilePath, "workspace report", "utf8");
await writeSessionEntry(storePath);
mockSuccessfulAttachmentDelivery();
await expect(
sendBundledSessionAttachment({
files: [{ path: relativeFilePath }],
config: {
session: { store: storePath },
agents: {
list: [{ id: "main", workspace: workspaceDir }],
},
},
}),
).resolves.toMatchObject({ ok: true, channel: "telegram", count: 1 });
expect(workflowMocks.sendMessage.mock.calls[0]?.[0]).toMatchObject({
mediaUrls: [absoluteFilePath],
});
});
});
it("prefers the thread encoded in a threaded session key over stale stored routes", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
const baseKey = "agent:main:telegram:group:12345";
const threadKey = `${baseKey}:thread:99`;
await writeSessionEntry(
storePath,
{
deliveryContext: {
channel: "telegram",
to: "group:12345",
threadId: 42,
},
},
threadKey,
);
mockSuccessfulAttachmentDelivery();
await expect(
sendBundledSessionAttachment({
sessionKey: threadKey,
files: [{ path: filePath }],
}),
).resolves.toMatchObject({ ok: true, channel: "telegram", count: 1 });
expect(workflowMocks.sendMessage.mock.calls[0]?.[0]).toMatchObject({
threadId: "99",
});
});
});
it("reports attachment delivery as failed when no delivery result is returned", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath);
workflowMocks.sendMessage.mockResolvedValue({
channel: "telegram",
to: "12345",
via: "direct",
mediaUrl: null,
});
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error: "attachment delivery failed: no delivery result returned",
});
});
});
it("rejects external plugins and sessions without delivery routes", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath, {});
await expect(
sendPluginSessionAttachment({
origin: "workspace",
sessionKey: MAIN_SESSION_KEY,
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error: "session attachments are restricted to bundled plugins",
});
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error: "session has no active delivery route: agent:main:main",
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
it("rejects malformed or oversized attachment inputs before delivery", async () => {
await withSessionStore(async ({ storePath, stateDir, filePath }) => {
await writeSessionEntry(storePath);
await expect(
sendBundledSessionAttachment({
files: Array.from({ length: 11 }, () => ({ path: path.join(stateDir, "missing.txt") })),
}),
).resolves.toEqual({
ok: false,
error: "at most 10 attachment files are allowed",
});
await expect(
sendBundledSessionAttachment({
files: [null as never],
}),
).resolves.toEqual({
ok: false,
error: "attachment file entry must be an object",
});
const first = path.join(stateDir, "first.txt");
const second = path.join(stateDir, "second.txt");
await fs.writeFile(first, "123", "utf8");
await fs.writeFile(second, "456", "utf8");
await expect(
sendBundledSessionAttachment({
files: [{ path: first }, { path: second }],
maxBytes: 5,
}),
).resolves.toEqual({
ok: false,
error: "attachment files exceed 5 bytes total",
});
const symlinkPath = path.join(stateDir, "linked.txt");
await fs.symlink(first, symlinkPath);
await expect(
sendBundledSessionAttachment({
files: [{ path: symlinkPath }],
}),
).resolves.toEqual({
ok: false,
error: `attachment file symlinks are not allowed: ${symlinkPath}`,
});
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
channelHints: { telegram: { forceDocumentMime: "application/pdf" } },
}),
).resolves.toEqual({
ok: false,
error: `attachment file MIME mismatch for ${filePath}: expected application/pdf, got unknown`,
});
const fakePdfPath = path.join(stateDir, "fake.pdf");
await fs.writeFile(fakePdfPath, "not a pdf", "utf8");
await expect(
sendBundledSessionAttachment({
files: [{ path: fakePdfPath }],
channelHints: { telegram: { forceDocumentMime: "application/pdf" } },
}),
).resolves.toEqual({
ok: false,
error: `attachment file MIME mismatch for ${fakePdfPath}: expected application/pdf, got unknown`,
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
it("returns validation errors for unreadable attachment MIME probes", async () => {
await withSessionStore(async ({ storePath, stateDir }) => {
const unreadablePath = path.join(stateDir, "unreadable.pdf");
await fs.writeFile(unreadablePath, "%PDF-1.7\n", "utf8");
await fs.chmod(unreadablePath, 0o000);
await writeSessionEntry(storePath);
try {
const result = await sendBundledSessionAttachment({
files: [{ path: unreadablePath }],
channelHints: { telegram: { forceDocumentMime: "application/pdf" } },
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected unreadable attachment MIME probe to fail");
}
expect(result.error).toContain(`attachment file MIME read failed for ${unreadablePath}`);
} finally {
await fs.chmod(unreadablePath, 0o600).catch(() => undefined);
}
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
it("validates force-document MIME using only the configured sniff window", async () => {
await withSessionStore(async ({ storePath, stateDir }) => {
const pdfPath = path.join(stateDir, "large.pdf");
await fs.writeFile(
pdfPath,
Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.alloc(FILE_TYPE_SNIFF_MAX_BYTES + 32)]),
);
await writeSessionEntry(storePath);
mockSuccessfulAttachmentDelivery();
await expect(
sendBundledSessionAttachment({
files: [{ path: pdfPath }],
forceDocument: false,
channelHints: { telegram: { forceDocumentMime: "application/pdf" } },
}),
).resolves.toMatchObject({ ok: true, channel: "telegram", count: 1 });
expect(workflowMocks.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrls: [pdfPath],
forceDocument: true,
}),
);
});
});
it("rejects gateway-mode channels before attempting host-local attachment delivery", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath);
workflowMocks.getChannelPlugin.mockReturnValue(
createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "gateway" },
}),
);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "gateway" },
}),
},
]),
);
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error:
"session attachments require direct outbound delivery for channel telegram; " +
"channel uses gateway delivery",
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
it("rejects unloaded bundled gateway-mode channels before attachment delivery", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath, {
deliveryContext: {
channel: "whatsapp",
to: "+15551234567",
},
});
setActivePluginRegistry(createEmptyPluginRegistry());
workflowMocks.getChannelPlugin.mockReturnValue(
createOutboundTestPlugin({
id: "whatsapp",
outbound: { deliveryMode: "gateway" },
}),
);
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error:
"session attachments require direct outbound delivery for channel whatsapp; " +
"channel uses gateway delivery",
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
it("returns structured errors when channel delivery lookup fails", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath);
workflowMocks.getChannelPlugin.mockImplementation(() => {
throw new Error("channel registry unavailable");
});
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error: "attachment delivery setup failed: channel registry unavailable",
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
it("wires sendSessionAttachment through the plugin API with stale-registry protection", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath);
mockSuccessfulAttachmentDelivery();
const { config, registry } = createPluginRegistryFixture({ session: { store: storePath } });
let capturedApi: OpenClawPluginApi | undefined;
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "attachment-plugin",
name: "Attachment Plugin",
origin: "bundled",
}),
register(api) {
capturedApi = api;
},
});
setActivePluginRegistry(registry.registry);
await expect(
capturedApi?.sendSessionAttachment({
sessionKey: MAIN_SESSION_KEY,
files: [{ path: filePath }],
}),
).resolves.toMatchObject({ ok: true, channel: "telegram", count: 1 });
setActivePluginRegistry(createEmptyPluginRegistry());
await expect(
capturedApi?.sendSessionAttachment({
sessionKey: MAIN_SESSION_KEY,
files: [{ path: filePath }],
}),
).resolves.toEqual({ ok: false, error: "plugin is not loaded" });
});
});
it("uses the live runtime config when a captured API sends an attachment", async () => {
await withSessionStore(async ({ stateDir, storePath, filePath }) => {
await writeSessionEntry(storePath);
mockSuccessfulAttachmentDelivery();
const staleStorePath = path.join(stateDir, "stale-sessions.json");
const registrationConfig = { session: { store: staleStorePath } };
const liveConfig = { session: { store: storePath } };
const registry = createPluginRegistry({
logger: createSilentPluginLogger(),
runtime: {
config: {
current: () => liveConfig,
},
} as unknown as PluginRuntime,
});
let capturedApi: OpenClawPluginApi | undefined;
registerTestPlugin({
registry,
config: registrationConfig,
record: createPluginRecord({
id: "live-config-attachment-plugin",
name: "Live Config Attachment Plugin",
origin: "bundled",
}),
register(api) {
capturedApi = api;
},
});
setActivePluginRegistry(registry.registry);
await expect(
capturedApi?.sendSessionAttachment({
sessionKey: MAIN_SESSION_KEY,
files: [{ path: filePath }],
}),
).resolves.toMatchObject({ ok: true, channel: "telegram", count: 1 });
expect(workflowMocks.sendMessage).toHaveBeenCalledTimes(1);
expect(workflowMocks.sendMessage.mock.calls[0]?.[0]).toMatchObject({
cfg: liveConfig,
});
});
});
it("returns structured errors when the captured API cannot read the live runtime config", async () => {
await withSessionStore(async ({ stateDir, storePath, filePath }) => {
await writeSessionEntry(storePath);
const registrationConfig = { session: { store: path.join(stateDir, "stale-sessions.json") } };
const registry = createPluginRegistry({
logger: createSilentPluginLogger(),
runtime: {
config: {
current: () => {
throw new Error("config runtime unavailable");
},
},
} as unknown as PluginRuntime,
});
let capturedApi: OpenClawPluginApi | undefined;
registerTestPlugin({
registry,
config: registrationConfig,
record: createPluginRecord({
id: "attachment-runtime-error-plugin",
name: "Attachment Runtime Error Plugin",
origin: "bundled",
}),
register(api) {
capturedApi = api;
},
});
setActivePluginRegistry(registry.registry);
await expect(
capturedApi?.sendSessionAttachment({
sessionKey: MAIN_SESSION_KEY,
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error: "attachment delivery setup failed: config runtime unavailable",
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,306 @@
import { lstat, open } from "node:fs/promises";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { resolvePathFromInput } from "../agents/path-policy.js";
import { resolveWorkspaceRoot } from "../agents/workspace-dir.js";
import { extractDeliveryInfo } from "../config/sessions/delivery-info.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { detectMime, FILE_TYPE_SNIFF_MAX_BYTES, normalizeMimeType } from "../media/mime.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
import type {
PluginAttachmentChannelHints,
PluginSessionAttachmentCaptionFormat,
PluginSessionAttachmentParams,
PluginSessionAttachmentResult,
} from "./host-hooks.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
const DEFAULT_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
const MAX_ATTACHMENT_FILES = 10;
type SendMessage = typeof import("../infra/outbound/message.js").sendMessage;
let sendMessagePromise: Promise<SendMessage> | undefined;
async function loadSendMessage(): Promise<SendMessage> {
sendMessagePromise ??= import("../infra/outbound/message.js").then(
(module) => module.sendMessage,
);
return sendMessagePromise;
}
type GetChannelPlugin = typeof import("../channels/plugins/index.js").getChannelPlugin;
let getChannelPluginPromise: Promise<GetChannelPlugin> | undefined;
type AttachmentDeliveryChannelPlugin = {
outbound?: {
deliveryMode?: string;
};
};
async function loadGetChannelPlugin(): Promise<GetChannelPlugin> {
getChannelPluginPromise ??= import("../channels/plugins/index.js").then(
(module) => module.getChannelPlugin,
);
return getChannelPluginPromise;
}
type ResolvedAttachmentDelivery = {
parseMode?: "HTML";
escapePlainHtmlCaption?: boolean;
disableNotification?: boolean;
forceDocumentMime?: string;
threadTs?: string;
};
function captionFormatToParseMode(
captionFormat: PluginSessionAttachmentCaptionFormat | undefined,
): "HTML" | undefined {
if (captionFormat === "html") {
return "HTML";
}
return undefined;
}
function escapeHtmlText(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
async function readMimeSniffBuffer(
filePath: string,
size: number,
): Promise<Buffer | { error: string }> {
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(filePath, "r");
const length = Math.min(Math.max(0, size), FILE_TYPE_SNIFF_MAX_BYTES);
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, length, 0);
return buffer.subarray(0, bytesRead);
} catch (error) {
return {
error: `attachment file MIME read failed for ${filePath}: ${formatErrorMessage(error)}`,
};
} finally {
await handle?.close().catch(() => undefined);
}
}
export function resolveAttachmentDelivery(params: {
channel: string;
captionFormat?: PluginSessionAttachmentCaptionFormat;
channelHints?: PluginAttachmentChannelHints;
}): ResolvedAttachmentDelivery {
const fallbackParseMode = captionFormatToParseMode(params.captionFormat);
const channel = params.channel.trim().toLowerCase();
if (channel === "telegram") {
const hint = params.channelHints?.telegram;
const parseMode =
hint?.parseMode ?? (params.captionFormat === "plain" ? "HTML" : fallbackParseMode);
const escapePlainHtmlCaption = params.captionFormat === "plain" && parseMode === "HTML";
const forceDocumentMime = normalizeMimeType(hint?.forceDocumentMime);
return {
...(parseMode ? { parseMode } : {}),
...(escapePlainHtmlCaption ? { escapePlainHtmlCaption: true } : {}),
...(hint?.disableNotification !== undefined
? { disableNotification: hint.disableNotification }
: {}),
...(forceDocumentMime ? { forceDocumentMime } : {}),
};
}
if (channel === "discord") {
return fallbackParseMode ? { parseMode: fallbackParseMode } : {};
}
if (channel === "slack") {
const hint = params.channelHints?.slack;
const threadTs = normalizeOptionalString(hint?.threadTs);
return {
...(fallbackParseMode ? { parseMode: fallbackParseMode } : {}),
...(threadTs ? { threadTs } : {}),
};
}
return fallbackParseMode ? { parseMode: fallbackParseMode } : {};
}
async function validateAttachmentFiles(
files: PluginSessionAttachmentParams["files"],
maxBytes: number,
options?: {
forceDocumentMime?: string;
config?: OpenClawConfig;
sessionKey?: string;
},
): Promise<string[] | { error: string }> {
if (files.length > MAX_ATTACHMENT_FILES) {
return { error: `at most ${MAX_ATTACHMENT_FILES} attachment files are allowed` };
}
const paths: string[] = [];
let totalBytes = 0;
for (const file of files) {
if (!file || typeof file !== "object" || Array.isArray(file)) {
return { error: "attachment file entry must be an object" };
}
const filePath = normalizeOptionalString((file as { path?: unknown }).path);
if (!filePath) {
return { error: "attachment file path is required" };
}
const resolvedPath = resolveAttachmentFilePath({
filePath,
config: options?.config,
sessionKey: options?.sessionKey,
});
const info = await lstat(resolvedPath).catch(() => undefined);
if (info?.isSymbolicLink()) {
return { error: `attachment file symlinks are not allowed: ${resolvedPath}` };
}
if (!info?.isFile()) {
return { error: `attachment file not found: ${resolvedPath}` };
}
if (info.size > maxBytes) {
return { error: `attachment file exceeds ${maxBytes} bytes: ${resolvedPath}` };
}
if (options?.forceDocumentMime) {
const fileBuffer = await readMimeSniffBuffer(resolvedPath, info.size);
if (!Buffer.isBuffer(fileBuffer)) {
return fileBuffer;
}
let detectedMime: string | undefined;
try {
detectedMime = normalizeMimeType(await detectMime({ buffer: fileBuffer }));
} catch (error) {
return {
error:
`attachment file MIME detection failed for ${filePath}: ` + formatErrorMessage(error),
};
}
if (detectedMime !== options.forceDocumentMime) {
return {
error:
`attachment file MIME mismatch for ${resolvedPath}: ` +
`expected ${options.forceDocumentMime}, got ${detectedMime ?? "unknown"}`,
};
}
}
totalBytes += info.size;
if (totalBytes > maxBytes) {
return { error: `attachment files exceed ${maxBytes} bytes total` };
}
paths.push(resolvedPath);
}
return paths;
}
function resolveAttachmentFilePath(params: {
filePath: string;
config?: OpenClawConfig;
sessionKey?: string;
}): string {
const workspaceDir =
params.sessionKey && params.config
? resolveAgentWorkspaceDir(params.config, resolveAgentIdFromSessionKey(params.sessionKey))
: undefined;
return resolvePathFromInput(params.filePath, resolveWorkspaceRoot(workspaceDir));
}
function normalizeOptionalThreadId(value: unknown): string | number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
return normalizeOptionalString(value);
}
export async function sendPluginSessionAttachment(
params: PluginSessionAttachmentParams & { config?: OpenClawConfig; origin?: PluginOrigin },
): Promise<PluginSessionAttachmentResult> {
if (params.origin !== "bundled") {
return { ok: false, error: "session attachments are restricted to bundled plugins" };
}
const sessionKey = normalizeOptionalString(params.sessionKey);
if (!sessionKey) {
return { ok: false, error: "sessionKey is required" };
}
if (!Array.isArray(params.files) || params.files.length === 0) {
return { ok: false, error: "at least one attachment file is required" };
}
const maxBytes =
typeof params.maxBytes === "number" && Number.isFinite(params.maxBytes)
? Math.min(DEFAULT_ATTACHMENT_MAX_BYTES, Math.max(1, Math.floor(params.maxBytes)))
: DEFAULT_ATTACHMENT_MAX_BYTES;
const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey, { cfg: params.config });
if (!deliveryContext?.channel || !deliveryContext.to) {
return { ok: false, error: `session has no active delivery route: ${sessionKey}` };
}
const normalizedChannel = normalizeMessageChannel(deliveryContext.channel);
try {
const deliveryPlugin =
normalizedChannel && isDeliverableMessageChannel(normalizedChannel)
? ((await loadGetChannelPlugin())(normalizedChannel) as
| AttachmentDeliveryChannelPlugin
| undefined)
: undefined;
if (deliveryPlugin?.outbound?.deliveryMode === "gateway") {
return {
ok: false,
error:
`session attachments require direct outbound delivery for channel ` +
`${deliveryContext.channel}; channel uses gateway delivery`,
};
}
} catch (error) {
return {
ok: false,
error: `attachment delivery setup failed: ${formatErrorMessage(error)}`,
};
}
const rawText = normalizeOptionalString(params.text) ?? "";
const explicitThreadId = normalizeOptionalThreadId(params.threadId);
const deliveryThreadId = normalizeOptionalThreadId(deliveryContext.threadId);
const fallbackThreadId = normalizeOptionalThreadId(threadId);
const resolvedDelivery = resolveAttachmentDelivery({
channel: deliveryContext.channel,
captionFormat: params.captionFormat,
channelHints: params.channelHints,
});
const validated = await validateAttachmentFiles(params.files, maxBytes, {
forceDocumentMime: resolvedDelivery.forceDocumentMime,
config: params.config,
sessionKey,
});
if (!Array.isArray(validated)) {
return { ok: false, error: validated.error };
}
const resolvedThreadId =
resolvedDelivery.threadTs ?? explicitThreadId ?? fallbackThreadId ?? deliveryThreadId;
let result: Awaited<ReturnType<SendMessage>>;
try {
const sendMessage = await loadSendMessage();
result = await sendMessage({
to: deliveryContext.to,
content: resolvedDelivery.escapePlainHtmlCaption ? escapeHtmlText(rawText) : rawText,
channel: deliveryContext.channel,
accountId: deliveryContext.accountId,
threadId: resolvedThreadId,
requesterSessionKey: sessionKey,
mediaUrls: validated,
forceDocument: resolvedDelivery.forceDocumentMime ? true : params.forceDocument,
bestEffort: false,
cfg: params.config,
...(resolvedDelivery.parseMode ? { parseMode: resolvedDelivery.parseMode } : {}),
...(resolvedDelivery.disableNotification !== undefined
? { silent: resolvedDelivery.disableNotification }
: {}),
});
} catch (error) {
return { ok: false, error: `attachment delivery failed: ${formatErrorMessage(error)}` };
}
if (!result.result) {
return { ok: false, error: "attachment delivery failed: no delivery result returned" };
}
return {
ok: true,
channel: result.channel,
deliveredTo: deliveryContext.to,
count: validated.length,
};
}

View File

@@ -305,6 +305,7 @@ export async function runPluginHostCleanup(params: {
preserveSchedulerJobIds?: ReadonlySet<string>;
shouldCleanup?: () => boolean;
restartPromotedSessionEntrySlotKeys?: ReadonlySet<string>;
preserveSchedulerOwnerRegistry?: PluginRegistry | null;
}): Promise<PluginHostCleanupResult> {
const failures: PluginHostCleanupFailure[] = [];
const shouldCleanup = params.shouldCleanup ?? (() => true);
@@ -408,6 +409,7 @@ export async function runPluginHostCleanup(params: {
sessionKey: params.sessionKey,
records: registry.sessionSchedulerJobs,
preserveJobIds: params.preserveSchedulerJobIds,
preserveOwnerRegistry: params.preserveSchedulerOwnerRegistry,
shouldCleanup,
});
for (const failure of schedulerFailures) {
@@ -538,6 +540,7 @@ export async function cleanupReplacedPluginHostRegistry(params: {
pluginId,
)
: undefined,
preserveSchedulerOwnerRegistry: restarted ? params.nextRegistry : undefined,
});
cleanupCount += result.cleanupCount;
failures.push(...result.failures);

View File

@@ -22,6 +22,7 @@ type SchedulerJobRecord = {
pluginName?: string;
job: PluginSessionSchedulerJobRegistration;
generation: number;
ownerRegistry?: PluginRegistry;
};
type PluginHostRuntimeState = {
@@ -356,6 +357,7 @@ export function registerPluginSessionSchedulerJob(params: {
pluginId: string;
pluginName?: string;
job: PluginSessionSchedulerJobRegistration;
ownerRegistry?: PluginRegistry;
}): PluginSessionSchedulerJobHandle | undefined {
const id = normalizeOptionalString(params.job.id);
const sessionKey = normalizeOptionalString(params.job.sessionKey);
@@ -371,12 +373,13 @@ export function registerPluginSessionSchedulerJob(params: {
pluginName: params.pluginName,
job: { ...params.job, id, sessionKey, kind },
generation,
...(params.ownerRegistry ? { ownerRegistry: params.ownerRegistry } : {}),
});
state.schedulerJobsByPlugin.set(params.pluginId, jobs);
return { id, pluginId: params.pluginId, sessionKey, kind };
}
function deletePluginSessionSchedulerJob(params: {
export function deletePluginSessionSchedulerJob(params: {
pluginId: string;
jobId: string;
sessionKey?: string;
@@ -450,6 +453,7 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
preserveJobIds?: ReadonlySet<string>;
excludeJobKeys?: ReadonlySet<string>;
shouldCleanup?: () => boolean;
preserveOwnerRegistry?: PluginRegistry | null;
}): Promise<Array<{ pluginId: string; hookId: string; error: unknown }>> {
const state = getPluginHostRuntimeState();
const failures: Array<{ pluginId: string; hookId: string; error: unknown }> = [];
@@ -457,6 +461,9 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
if (!shouldCleanup()) {
return failures;
}
const registryRecordKeys = new Set<string>();
const schedulerJobKey = (pluginId: string, jobId: string, sessionKey: string) =>
`${pluginId}\0${jobId}\0${sessionKey}`;
if (params.records) {
for (const record of params.records) {
if (!shouldCleanup()) {
@@ -473,6 +480,7 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
if (params.sessionKey && sessionKey !== params.sessionKey) {
continue;
}
registryRecordKeys.add(schedulerJobKey(record.pluginId, jobId, sessionKey));
const liveGeneration = getPluginSessionSchedulerJobGeneration({
pluginId: record.pluginId,
jobId,
@@ -530,7 +538,6 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
expectedGeneration: record.generation,
});
}
return failures;
}
const pluginIds = params.pluginId ? [params.pluginId] : [...state.schedulerJobsByPlugin.keys()];
for (const pluginId of pluginIds) {
@@ -548,6 +555,15 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
if (params.sessionKey && record.job.sessionKey !== params.sessionKey) {
continue;
}
if (registryRecordKeys.has(schedulerJobKey(pluginId, jobId, record.job.sessionKey))) {
continue;
}
if (
params.preserveOwnerRegistry !== undefined &&
record.ownerRegistry === params.preserveOwnerRegistry
) {
continue;
}
if (params.excludeJobKeys?.has(makePluginSessionSchedulerJobKey(pluginId, jobId))) {
continue;
}

View File

@@ -0,0 +1,482 @@
import { randomUUID } from "node:crypto";
import { ADMIN_SCOPE } from "../gateway/operator-scopes.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
deletePluginSessionSchedulerJob,
registerPluginSessionSchedulerJob,
} from "./host-hook-runtime.js";
import type {
PluginSessionSchedulerJobHandle,
PluginSessionTurnScheduleParams,
PluginSessionTurnUnscheduleByTagParams,
PluginSessionTurnUnscheduleByTagResult,
} from "./host-hooks.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import type { PluginRegistry } from "./registry-types.js";
const log = createSubsystemLogger("plugins/host-scheduled-turns");
const PLUGIN_CRON_NAME_PREFIX = "plugin:";
const PLUGIN_CRON_TAG_MARKER = ":tag:";
type CallGatewayTool = typeof import("../agents/tools/gateway.js").callGatewayTool;
let callGatewayToolPromise: Promise<CallGatewayTool> | undefined;
type ResolvedSessionTurnSchedule =
| {
kind: "cron";
expr: string;
tz?: string;
}
| {
kind: "at";
at: string;
};
async function callGatewayToolLazy(
...args: Parameters<CallGatewayTool>
): Promise<Awaited<ReturnType<CallGatewayTool>>> {
callGatewayToolPromise ??= import("../agents/tools/gateway.js").then(
(module) => module.callGatewayTool,
);
const callGatewayTool = await callGatewayToolPromise;
return callGatewayTool(...args);
}
function resolveSchedule(
params: PluginSessionTurnScheduleParams,
): ResolvedSessionTurnSchedule | undefined {
const cron = normalizeOptionalString((params as { cron?: unknown }).cron);
if (cron) {
const tz = normalizeOptionalString((params as { tz?: unknown }).tz);
return {
kind: "cron",
expr: cron,
...(tz ? { tz } : {}),
};
}
if ("delayMs" in params) {
if (!Number.isFinite(params.delayMs) || params.delayMs < 0) {
return undefined;
}
const timestamp = Date.now() + Math.max(1, Math.floor(params.delayMs));
if (!Number.isFinite(timestamp)) {
return undefined;
}
const at = new Date(timestamp);
if (!Number.isFinite(at.getTime())) {
return undefined;
}
return { kind: "at", at: at.toISOString() };
}
const rawAt = (params as { at?: unknown }).at;
const at = rawAt instanceof Date ? rawAt : new Date(rawAt as string | number | Date);
if (!Number.isFinite(at.getTime())) {
return undefined;
}
return { kind: "at", at: at.toISOString() };
}
function resolveSessionTurnDeliveryMode(deliveryMode: unknown): "none" | "announce" | undefined {
if (deliveryMode === undefined) {
return undefined;
}
if (deliveryMode === "none" || deliveryMode === "announce") {
return deliveryMode;
}
return undefined;
}
function formatScheduleLogContext(params: {
pluginId: string;
sessionKey?: string;
name?: string;
jobId?: string;
}): string {
const parts = [`pluginId=${params.pluginId}`];
if (params.sessionKey) {
parts.push(`sessionKey=${params.sessionKey}`);
}
if (params.name) {
parts.push(`name=${params.name}`);
}
if (params.jobId) {
parts.push(`jobId=${params.jobId}`);
}
return parts.join(" ");
}
async function removeScheduledSessionTurn(params: {
jobId: string;
pluginId: string;
sessionKey?: string;
name?: string;
}): Promise<boolean> {
try {
const result = await callGatewayToolLazy(
"cron.remove",
{},
{ id: params.jobId },
{ scopes: [ADMIN_SCOPE] },
);
return didCronCleanupJob(result);
} catch (error) {
log.warn(
`plugin session turn cleanup failed (${formatScheduleLogContext(params)}): ${formatErrorMessage(error)}`,
);
return false;
}
}
function unwrapGatewayPayload(value: unknown): unknown {
if (!isCronJobRecord(value)) {
return value;
}
const payload = value.payload;
return isCronJobRecord(payload) ? payload : value;
}
function didCronRemoveJob(value: unknown): boolean {
const result = unwrapGatewayPayload(value);
if (!isCronJobRecord(result)) {
return false;
}
return result.ok !== false && result.removed === true;
}
function didCronCleanupJob(value: unknown): boolean {
const result = unwrapGatewayPayload(value);
if (!isCronJobRecord(result) || result.ok === false) {
return false;
}
return result.removed === true || result.removed === false;
}
function normalizeCronJobId(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function extractCronJobId(value: unknown): string | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
const topLevelId = normalizeCronJobId(record.jobId ?? record.id);
if (topLevelId) {
return topLevelId;
}
const payload =
record.payload && typeof record.payload === "object"
? (record.payload as Record<string, unknown>)
: record;
return normalizeCronJobId(payload.jobId ?? payload.id);
}
const PLUGIN_CRON_RESERVED_DELIMITER = ":";
function resolvePluginSessionTurnTag(value: unknown): {
tag?: string;
invalid: boolean;
} {
const tag = normalizeOptionalString(value);
if (!tag) {
return { invalid: false };
}
if (tag.includes(PLUGIN_CRON_RESERVED_DELIMITER)) {
return { invalid: true };
}
return { tag, invalid: false };
}
export function buildPluginSchedulerCronName(params: {
pluginId: string;
sessionKey: string;
tag?: string;
uniqueId?: string;
}): string {
const uniqueId = params.uniqueId ?? randomUUID();
if (!params.tag) {
return `${PLUGIN_CRON_NAME_PREFIX}${params.pluginId}:${params.sessionKey}:${uniqueId}`;
}
return `${PLUGIN_CRON_NAME_PREFIX}${params.pluginId}${PLUGIN_CRON_TAG_MARKER}${params.tag}:${params.sessionKey}:${uniqueId}`;
}
function buildPluginSchedulerTagPrefix(params: {
pluginId: string;
tag: string;
sessionKey: string;
}): string {
return `${PLUGIN_CRON_NAME_PREFIX}${params.pluginId}${PLUGIN_CRON_TAG_MARKER}${params.tag}:${params.sessionKey}:`;
}
function isCronJobRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readCronListJobs(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) {
return value.filter(isCronJobRecord);
}
if (isCronJobRecord(value)) {
const jobs = (value as { jobs?: unknown }).jobs;
if (Array.isArray(jobs)) {
return jobs.filter(isCronJobRecord);
}
}
return [];
}
function readCronListNextOffset(value: unknown): number | undefined {
if (!isCronJobRecord(value)) {
return undefined;
}
const nextOffset = value.nextOffset;
return typeof nextOffset === "number" && Number.isInteger(nextOffset) && nextOffset >= 0
? nextOffset
: undefined;
}
function readCronListHasMore(value: unknown): boolean {
return isCronJobRecord(value) && value.hasMore === true;
}
async function listAllCronJobsForPluginTagCleanup(
query: string,
): Promise<Record<string, unknown>[]> {
const jobs: Record<string, unknown>[] = [];
let offset = 0;
for (;;) {
const listResult = await callGatewayToolLazy(
"cron.list",
{},
{
includeDisabled: true,
limit: 200,
query,
sortBy: "name",
sortDir: "asc",
...(offset > 0 ? { offset } : {}),
},
{ scopes: [ADMIN_SCOPE] },
);
jobs.push(...readCronListJobs(listResult));
if (!readCronListHasMore(listResult)) {
return jobs;
}
const nextOffset = readCronListNextOffset(listResult);
if (nextOffset === undefined || nextOffset <= offset) {
return jobs;
}
offset = nextOffset;
}
}
export async function schedulePluginSessionTurn(params: {
pluginId: string;
pluginName?: string;
origin?: PluginOrigin;
schedule: PluginSessionTurnScheduleParams;
shouldCommit?: () => boolean;
ownerRegistry?: PluginRegistry;
}): Promise<PluginSessionSchedulerJobHandle | undefined> {
if (params.origin !== "bundled") {
return undefined;
}
const sessionKey = normalizeOptionalString(params.schedule.sessionKey);
const message = normalizeOptionalString(params.schedule.message);
if (!sessionKey || !message) {
return undefined;
}
const cronSchedule = resolveSchedule(params.schedule);
if (!cronSchedule) {
return undefined;
}
const rawDeliveryMode = (params.schedule as { deliveryMode?: unknown }).deliveryMode;
const deliveryMode = resolveSessionTurnDeliveryMode(rawDeliveryMode);
const scheduleName = normalizeOptionalString(params.schedule.name);
if (rawDeliveryMode !== undefined && !deliveryMode) {
log.warn(
`plugin session turn scheduling failed (${formatScheduleLogContext({
pluginId: params.pluginId,
sessionKey,
...(scheduleName ? { name: scheduleName } : {}),
})}): unsupported deliveryMode`,
);
return undefined;
}
if (cronSchedule.kind === "cron" && params.schedule.deleteAfterRun === true) {
log.warn(
`plugin session turn scheduling failed (${formatScheduleLogContext({
pluginId: params.pluginId,
sessionKey,
...(scheduleName ? { name: scheduleName } : {}),
})}): deleteAfterRun requires a one-shot schedule`,
);
return undefined;
}
const { tag, invalid: invalidTag } = resolvePluginSessionTurnTag(params.schedule.tag);
if (invalidTag) {
log.warn(
`plugin session turn scheduling failed (${formatScheduleLogContext({
pluginId: params.pluginId,
sessionKey,
...(scheduleName ? { name: scheduleName } : {}),
})}): tag contains reserved delimiter ":"`,
);
return undefined;
}
const cronDeliveryMode = deliveryMode ?? "announce";
if (params.shouldCommit && !params.shouldCommit()) {
return undefined;
}
const cronJobName = buildPluginSchedulerCronName({
pluginId: params.pluginId,
sessionKey,
...(tag !== undefined ? { tag } : {}),
...(scheduleName ? { uniqueId: scheduleName } : {}),
});
const cronPayload: Record<string, unknown> = {
kind: "agentTurn",
message,
};
let result: unknown;
try {
result = await callGatewayToolLazy(
"cron.add",
{},
{
name: cronJobName,
schedule: cronSchedule,
sessionTarget: `session:${sessionKey}`,
payload: cronPayload,
...(params.schedule.agentId ? { agentId: params.schedule.agentId } : {}),
deleteAfterRun: params.schedule.deleteAfterRun ?? cronSchedule.kind === "at",
wakeMode: "now",
delivery: {
mode: cronDeliveryMode,
...(cronDeliveryMode === "announce" ? { channel: "last" } : {}),
},
},
{ scopes: [ADMIN_SCOPE] },
);
} catch (error) {
log.warn(
`plugin session turn scheduling failed (${formatScheduleLogContext({
pluginId: params.pluginId,
sessionKey,
name: cronJobName,
})}): ${formatErrorMessage(error)}`,
);
return undefined;
}
const jobId = extractCronJobId(result);
if (!jobId) {
return undefined;
}
if (params.shouldCommit && !params.shouldCommit()) {
const removed = await removeScheduledSessionTurn({
jobId,
pluginId: params.pluginId,
sessionKey,
name: cronJobName,
});
if (!removed) {
log.warn(
`plugin session turn scheduling rollback failed (${formatScheduleLogContext({
pluginId: params.pluginId,
sessionKey,
name: cronJobName,
jobId,
})}): failed to remove stale scheduled session turn`,
);
}
return undefined;
}
const handle = registerPluginSessionSchedulerJob({
pluginId: params.pluginId,
pluginName: params.pluginName,
ownerRegistry: params.ownerRegistry,
job: {
id: jobId,
sessionKey,
kind: "session-turn",
cleanup: async () => {
const removed = await removeScheduledSessionTurn({
jobId,
pluginId: params.pluginId,
sessionKey,
name: cronJobName,
});
if (!removed) {
throw new Error(`failed to remove scheduled session turn: ${jobId}`);
}
},
},
});
return handle;
}
export async function unschedulePluginSessionTurnsByTag(params: {
pluginId: string;
origin?: PluginOrigin;
request: PluginSessionTurnUnscheduleByTagParams;
}): Promise<PluginSessionTurnUnscheduleByTagResult> {
if (params.origin !== "bundled") {
return { removed: 0, failed: 0 };
}
const sessionKey = normalizeOptionalString(params.request.sessionKey);
const { tag, invalid: invalidTag } = resolvePluginSessionTurnTag(params.request.tag);
if (!sessionKey || !tag || invalidTag) {
return { removed: 0, failed: 0 };
}
const namePrefix = buildPluginSchedulerTagPrefix({
pluginId: params.pluginId,
tag,
sessionKey,
});
let jobs: Record<string, unknown>[];
try {
jobs = await listAllCronJobsForPluginTagCleanup(namePrefix);
} catch (error) {
log.warn(`plugin session turn untag-list failed: ${formatErrorMessage(error)}`);
return { removed: 0, failed: 1 };
}
const candidates = jobs.filter((job) => {
const name = typeof job.name === "string" ? job.name : "";
const target = typeof job.sessionTarget === "string" ? job.sessionTarget : "";
return name.startsWith(namePrefix) && target === `session:${sessionKey}`;
});
let removed = 0;
let failed = 0;
for (const job of candidates) {
const id = typeof job.id === "string" ? job.id.trim() : "";
if (!id) {
continue;
}
try {
const result = await callGatewayToolLazy(
"cron.remove",
{},
{ id },
{ scopes: [ADMIN_SCOPE] },
);
if (didCronRemoveJob(result)) {
removed += 1;
deletePluginSessionSchedulerJob({
pluginId: params.pluginId,
jobId: id,
sessionKey,
});
} else {
failed += 1;
}
} catch (error) {
log.warn(
`plugin session turn untag-remove failed: id=${id} error=${formatErrorMessage(error)}`,
);
failed += 1;
}
}
return { removed, failed };
}

View File

@@ -110,6 +110,41 @@ export type PluginControlUiDescriptor = {
requiredScopes?: OperatorScope[];
};
export type PluginSessionActionContext = {
pluginId: string;
actionId: string;
sessionKey?: string;
payload?: PluginJsonValue;
client?: {
connId?: string;
scopes: string[];
};
};
export type PluginSessionActionResult =
| {
ok?: true;
result?: PluginJsonValue;
reply?: PluginJsonValue;
continueAgent?: boolean;
}
| {
ok: false;
error: string;
code?: string;
details?: PluginJsonValue;
};
export type PluginSessionActionRegistration = {
id: string;
description?: string;
schema?: PluginJsonValue;
requiredScopes?: OperatorScope[];
handler: (
ctx: PluginSessionActionContext,
) => PluginSessionActionResult | void | Promise<PluginSessionActionResult | void>;
};
export type PluginRuntimeLifecycleRegistration = {
id: string;
description?: string;
@@ -137,6 +172,17 @@ export type PluginAgentEventSubscriptionRegistration = {
) => void | Promise<void>;
};
export type PluginAgentEventEmitParams = {
runId: string;
stream: AgentEventStream;
data: PluginJsonValue;
sessionKey?: string;
};
export type PluginAgentEventEmitResult =
| { emitted: true; stream: AgentEventStream }
| { emitted: false; reason: string };
export type PluginRunContextPatch = {
runId: string;
namespace: string;
@@ -168,6 +214,87 @@ export type PluginSessionSchedulerJobHandle = {
kind: string;
};
export type PluginSessionAttachmentFile = {
path: string;
};
export type PluginAttachmentChannelHints = {
telegram?: {
parseMode?: "HTML";
disableNotification?: boolean;
/**
* Require host-side detection to match this MIME before forcing document delivery.
* Mismatched files are rejected before the outbound adapter is called.
*/
forceDocumentMime?: string;
};
slack?: {
threadTs?: string;
};
};
export type PluginSessionAttachmentCaptionFormat = "plain" | "html" | "markdown";
export type PluginSessionAttachmentParams = {
sessionKey: string;
files: PluginSessionAttachmentFile[];
text?: string;
threadId?: string | number;
forceDocument?: boolean;
maxBytes?: number;
captionFormat?: PluginSessionAttachmentCaptionFormat;
channelHints?: PluginAttachmentChannelHints;
};
export type PluginSessionAttachmentResult =
| {
ok: true;
channel: string;
deliveredTo: string;
count: number;
}
| { ok: false; error: string };
export type PluginSessionTurnSchedule =
| { at: string | number | Date }
| { delayMs: number }
| { cron: string; tz?: string };
type PluginSessionTurnScheduleCommonParams = {
sessionKey: string;
message: string;
agentId?: string;
deliveryMode?: "none" | "announce";
name?: string;
/** Optional cleanup tag. Reserved cron-name delimiters like `:` are rejected. */
tag?: string;
};
export type PluginSessionTurnScheduleParams =
| ({
at: string | number | Date;
deleteAfterRun?: boolean;
} & PluginSessionTurnScheduleCommonParams)
| ({
delayMs: number;
deleteAfterRun?: boolean;
} & PluginSessionTurnScheduleCommonParams)
| ({
cron: string;
tz?: string;
deleteAfterRun?: false;
} & PluginSessionTurnScheduleCommonParams);
export type PluginSessionTurnUnscheduleByTagParams = {
sessionKey: string;
tag: string;
};
export type PluginSessionTurnUnscheduleByTagResult = {
removed: number;
failed: number;
};
export function normalizePluginHostHookId(value: string | undefined): string {
return (value ?? "").trim();
}

View File

@@ -22,6 +22,7 @@ import {
type DetachedTaskLifecycleRuntime,
} from "../tasks/detached-task-runtime-state.js";
import { withEnv } from "../test-utils/env.js";
import { buildPluginApi } from "./api-builder.js";
import { clearPluginCommands } from "./command-registry-state.js";
import { getPluginCommandSpecs } from "./command-specs.js";
import { listCompactionProviderIds } from "./compaction-provider.js";
@@ -1470,6 +1471,98 @@ describe("loadOpenClawPlugins", () => {
expect(Object.keys(registry.gatewayHandlers)).not.toContain("async-register.ping");
},
},
{
label:
"keeps sendSessionAttachment callable after register closes while blocking registration-only APIs",
run: () => {
const registerGatewayMethod = vi.fn();
const registerSessionExtension = vi.fn();
const sendSessionAttachment = vi.fn(async () => ({
ok: true as const,
channel: "proofchat",
deliveredTo: "12345",
count: 1,
}));
const emitAgentEvent = vi.fn(() => ({
emitted: true as const,
stream: "late-attachment-plugin.workflow",
}));
const api = buildPluginApi({
id: "late-attachment-plugin",
name: "Late Attachment Plugin",
source: "/tmp/late-attachment-plugin/index.cjs",
registrationMode: "full",
config: {},
runtime: {} as never,
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
resolvePath: (input) => input,
handlers: {
emitAgentEvent,
registerGatewayMethod,
registerSessionExtension,
sendSessionAttachment,
},
});
let capturedApi: typeof api | undefined;
__testing.runPluginRegisterSync((guardedApi) => {
capturedApi = guardedApi;
// Host-hook delivery remains callable after registration closes; only registration-only APIs lock.
guardedApi.registerGatewayMethod("proofchat.ping", vi.fn() as never);
}, api);
expect(registerGatewayMethod).toHaveBeenCalledTimes(1);
expect(
capturedApi?.registerGatewayMethod("proofchat.late-ping", vi.fn() as never),
).toBeUndefined();
expect(registerGatewayMethod).toHaveBeenCalledTimes(1);
const attachmentParams = {
sessionKey: "agent:main:main",
files: [{ path: "./proof-report.txt" }],
text: "attachment ready",
};
const lateResult = capturedApi?.sendSessionAttachment(attachmentParams);
const lateWorkflowResult =
capturedApi?.session?.workflow.sendSessionAttachment(attachmentParams);
const eventParams = {
runId: "run-late",
stream: "late-attachment-plugin.workflow",
data: { phase: "done" },
};
const lateEventResult = capturedApi?.emitAgentEvent(eventParams);
const lateNamespacedEventResult = capturedApi?.agent?.events.emitAgentEvent(eventParams);
capturedApi?.session?.state.registerSessionExtension({
namespace: "late",
description: "late extension should stay blocked",
});
expect(lateResult).toBe(sendSessionAttachment.mock.results[0]?.value);
expect(lateWorkflowResult).toBe(sendSessionAttachment.mock.results[1]?.value);
expect(sendSessionAttachment).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
files: [{ path: "./proof-report.txt" }],
text: "attachment ready",
});
expect(sendSessionAttachment).toHaveBeenCalledTimes(2);
expect(lateEventResult).toEqual({
emitted: true,
stream: "late-attachment-plugin.workflow",
});
expect(lateNamespacedEventResult).toEqual({
emitted: true,
stream: "late-attachment-plugin.workflow",
});
expect(emitAgentEvent).toHaveBeenCalledTimes(2);
expect(emitAgentEvent).toHaveBeenCalledWith(eventParams);
expect(registerSessionExtension).not.toHaveBeenCalled();
},
},
{
label: "limits imports to the requested plugin ids",
run: () => {
@@ -5451,7 +5544,7 @@ module.exports = {
id: "next-turn-policy",
filename: "next-turn-policy.cjs",
body: `module.exports = { id: "next-turn-policy", register(api) {
void api.enqueueNextTurnInjection({
void api.session.workflow.enqueueNextTurnInjection({
sessionKey: "agent:main:main",
text: "blocked context",
});

View File

@@ -26,6 +26,8 @@ import {
import { resolveUserPath } from "../utils.js";
import { resolvePluginActivationSourceConfig } from "./activation-source-config.js";
import { buildPluginApi } from "./api-builder.js";
import { attachPluginApiFacades } from "./api-facades.js";
import { isLateCallablePluginApiMethod } from "./api-lifecycle.js";
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
import {
clearPluginCommands,
@@ -349,6 +351,7 @@ type PluginRegistrySnapshot = {
securityAuditCollectors: NonNullable<PluginRegistry["securityAuditCollectors"]>;
services: PluginRegistry["services"];
commands: PluginRegistry["commands"];
sessionActions: NonNullable<PluginRegistry["sessionActions"]>;
conversationBindingResolvedHandlers: PluginRegistry["conversationBindingResolvedHandlers"];
diagnostics: PluginRegistry["diagnostics"];
};
@@ -391,6 +394,7 @@ function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapsho
securityAuditCollectors: [...(registry.securityAuditCollectors ?? [])],
services: [...registry.services],
commands: [...registry.commands],
sessionActions: [...(registry.sessionActions ?? [])],
conversationBindingResolvedHandlers: [...registry.conversationBindingResolvedHandlers],
diagnostics: [...registry.diagnostics],
},
@@ -432,6 +436,7 @@ function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistr
registry.securityAuditCollectors = snapshot.arrays.securityAuditCollectors;
registry.services = snapshot.arrays.services;
registry.commands = snapshot.arrays.commands;
registry.sessionActions = snapshot.arrays.sessionActions;
registry.conversationBindingResolvedHandlers =
snapshot.arrays.conversationBindingResolvedHandlers;
registry.diagnostics = snapshot.arrays.diagnostics;
@@ -445,21 +450,29 @@ function createGuardedPluginRegistrationApi(api: OpenClawPluginApi): {
close: () => void;
} {
let closed = false;
return {
api: new Proxy(api, {
const guardedApi = attachPluginApiFacades(
new Proxy(api, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value !== "function") {
return value;
}
if (typeof prop === "string" && isLateCallablePluginApiMethod(prop)) {
return (...args: unknown[]) => Reflect.apply(value, target, args);
}
return (...args: unknown[]) => {
if (closed) {
const isLateCallableMethod =
typeof prop === "string" && isLateCallablePluginApiMethod(prop);
if (closed && !isLateCallableMethod) {
return undefined;
}
return Reflect.apply(value, target, args);
};
},
}),
);
return {
api: guardedApi,
close: () => {
closed = true;
},
@@ -573,6 +586,8 @@ export const __testing = {
shouldLoadChannelPluginInSetupRuntime,
shouldPreferNativeModuleLoad,
toSafeImportPath,
createGuardedPluginRegistrationApi,
runPluginRegisterSync,
getCompatibleActivePluginRegistry,
resolvePluginLoadCacheContext,
get maxPluginRegistryCacheEntries() {

View File

@@ -46,6 +46,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
runtimeLifecycles: [],
agentEventSubscriptions: [],
sessionSchedulerJobs: [],
sessionActions: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],
};

View File

@@ -1,6 +1,7 @@
import type { PluginRegistry } from "./registry-types.js";
const retiredRegistries = new WeakSet<PluginRegistry>();
const activatedRegistries = new WeakSet<PluginRegistry>();
export function markPluginRegistryRetired(registry: PluginRegistry | null | undefined): void {
if (registry) {
@@ -10,10 +11,15 @@ export function markPluginRegistryRetired(registry: PluginRegistry | null | unde
export function markPluginRegistryActive(registry: PluginRegistry | null | undefined): void {
if (registry) {
activatedRegistries.add(registry);
retiredRegistries.delete(registry);
}
}
export function isPluginRegistryActivated(registry: PluginRegistry): boolean {
return activatedRegistries.has(registry);
}
export function isPluginRegistryRetired(registry: PluginRegistry): boolean {
return retiredRegistries.has(registry);
}

View File

@@ -15,6 +15,7 @@ import type {
PluginAgentEventSubscriptionRegistration,
PluginControlUiDescriptor,
PluginRuntimeLifecycleRegistration,
PluginSessionActionRegistration,
PluginSessionSchedulerJobRegistration,
PluginSessionExtensionRegistration,
PluginToolMetadataRegistration,
@@ -340,6 +341,14 @@ export type PluginSessionSchedulerJobRegistryRegistration = {
rootDir?: string;
};
export type PluginSessionActionRegistryRegistration = {
pluginId: string;
pluginName?: string;
action: PluginSessionActionRegistration;
source: string;
rootDir?: string;
};
export type PluginConversationBindingResolvedHandlerRegistration = {
pluginId: string;
pluginName?: string;
@@ -454,6 +463,7 @@ export type PluginRegistry = {
runtimeLifecycles?: PluginRuntimeLifecycleRegistryRegistration[];
agentEventSubscriptions?: PluginAgentEventSubscriptionRegistryRegistration[];
sessionSchedulerJobs?: PluginSessionSchedulerJobRegistryRegistration[];
sessionActions?: PluginSessionActionRegistryRegistration[];
conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[];
diagnostics: PluginDiagnostic[];
};

View File

@@ -19,6 +19,7 @@ import { isOperatorScope, type OperatorScope } from "../gateway/operator-scopes.
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { registerInternalHook, unregisterInternalHook } from "../hooks/internal-hooks.js";
import type { HookEntry } from "../hooks/types.js";
import { formatErrorMessage } from "../infra/errors.js";
import {
NODE_EXEC_APPROVALS_COMMANDS,
NODE_SYSTEM_NOTIFY_COMMAND,
@@ -40,6 +41,7 @@ import {
registerDetachedTaskLifecycleRuntime,
} from "../tasks/detached-task-runtime-state.js";
import { resolveUserPath } from "../utils.js";
import { emitPluginAgentEvent } from "./agent-event-emission.js";
import type { AgentToolResultMiddleware } from "./agent-tool-result-middleware-types.js";
import {
normalizeAgentToolResultMiddlewareRuntimeIds,
@@ -59,6 +61,7 @@ import {
getRegisteredCompactionProvider,
registerCompactionProvider,
} from "./compaction-provider.js";
import { sendPluginSessionAttachment } from "./host-hook-attachments.js";
import {
clearPluginRunContext,
getPluginRunContext,
@@ -66,6 +69,10 @@ import {
registerPluginSessionSchedulerJob,
setPluginRunContext,
} from "./host-hook-runtime.js";
import {
schedulePluginSessionTurn,
unschedulePluginSessionTurnsByTag,
} from "./host-hook-scheduled-turns.js";
import { enqueuePluginNextTurnInjection } from "./host-hook-state.js";
import {
isPluginJsonValue,
@@ -73,6 +80,7 @@ import {
type PluginAgentEventSubscriptionRegistration,
type PluginControlUiDescriptor,
type PluginRuntimeLifecycleRegistration,
type PluginSessionActionRegistration,
type PluginSessionSchedulerJobRegistration,
type PluginSessionExtensionRegistration,
type PluginToolMetadataRegistration,
@@ -100,15 +108,25 @@ import {
import { createModelCatalogRegistrationHandlers } from "./model-catalog-registration.js";
import { normalizeRegisteredProvider } from "./provider-validation.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { isPluginRegistryRetired } from "./registry-lifecycle.js";
import { isPluginRegistryActivated, isPluginRegistryRetired } from "./registry-lifecycle.js";
import type {
PluginHttpRouteRegistration as RegistryTypesPluginHttpRouteRegistration,
PluginRecord,
PluginRegistryParams,
PluginSessionActionRegistryRegistration,
PluginTextTransformsRegistration,
} from "./registry-types.js";
export type {
PluginReloadRegistration,
PluginRuntimeLifecycleRegistryRegistration,
PluginSecurityAuditCollectorRegistration,
PluginServiceRegistration,
PluginSessionExtensionRegistryRegistration,
} from "./registry-types.js";
import { getActivePluginRegistry } from "./runtime.js";
import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js";
import type { PluginRuntime } from "./runtime/types.js";
import { validateJsonSchemaValue, type JsonSchemaValue } from "./schema-validator.js";
import { normalizeSessionEntrySlotKey } from "./session-entry-slot-keys.js";
import { defaultSlotIdForKey, hasKind } from "./slots.js";
import {
@@ -185,14 +203,9 @@ export type {
PluginProviderRegistration,
PluginControlUiDescriptorRegistryRegistration,
PluginHostedMediaResolverRegistration,
PluginRuntimeLifecycleRegistryRegistration,
PluginRecord,
PluginRegistry,
PluginRegistryParams,
PluginReloadRegistration,
PluginSecurityAuditCollectorRegistration,
PluginServiceRegistration,
PluginSessionExtensionRegistryRegistration,
PluginTextTransformsRegistration,
PluginToolMetadataRegistryRegistration,
PluginTrustedToolPolicyRegistryRegistration,
@@ -1678,6 +1691,54 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
return normalized as string[];
};
const validateSessionActionSchema = (
record: PluginRecord,
id: string,
schema: unknown,
): schema is JsonSchemaValue => {
if (schema === undefined) {
return true;
}
if (!isPluginJsonValue(schema)) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `session action schema must be JSON-compatible: ${id}`,
});
return false;
}
if (
typeof schema !== "boolean" &&
(!schema || typeof schema !== "object" || Array.isArray(schema))
) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `session action schema must be a JSON schema object or boolean: ${id}`,
});
return false;
}
try {
validateJsonSchemaValue({
schema,
cacheKey: `plugin-session-action-registration:${record.id}:${id}`,
value: undefined,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `session action schema is not valid JSON Schema: ${id}: ${message}`,
});
return false;
}
return true;
};
const controlUiSurfaces = new Set<PluginControlUiDescriptor["surface"]>([
"session",
"tool",
@@ -2114,6 +2175,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
const handle = registerPluginSessionSchedulerJob({
pluginId: record.id,
pluginName: record.name,
ownerRegistry: registry,
job: { ...job, id: jobId, sessionKey, kind },
});
if (!handle) {
@@ -2140,6 +2202,67 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
return handle;
};
const registerSessionAction = (record: PluginRecord, action: PluginSessionActionRegistration) => {
const id = normalizeHostHookString(action.id);
const description = normalizeOptionalHostHookString(action.description);
const requiredScopes = normalizeHostHookStringList(action.requiredScopes);
if (
!id ||
description === "" ||
requiredScopes === null ||
typeof action.handler !== "function"
) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "session action registration requires id, handler, and valid optional fields",
});
return;
}
if (requiredScopes !== undefined) {
const unknownScope = requiredScopes.find((scope) => !isOperatorScope(scope));
if (unknownScope !== undefined) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `session action requiredScopes contains unknown operator scope: ${unknownScope}`,
});
return;
}
}
if (!validateSessionActionSchema(record, id, action.schema)) {
return;
}
const existing = (registry.sessionActions ?? []).find(
(entry) => entry.pluginId === record.id && entry.action.id === id,
);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `session action already registered: ${id}`,
});
return;
}
(registry.sessionActions ??= []).push({
pluginId: record.id,
pluginName: record.name,
action: {
...action,
id,
...(description !== undefined ? { description } : {}),
...(requiredScopes !== undefined
? { requiredScopes: requiredScopes as OperatorScope[] }
: {}),
},
source: record.source,
rootDir: record.rootDir,
} satisfies PluginSessionActionRegistryRegistration);
};
const registerTypedHook = <K extends PluginHookName>(
record: PluginRecord,
hookName: K,
@@ -2303,6 +2426,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
const sideEffectGuard = createPluginSideEffectGuard(record.id);
const isLoadedRecordInRegistry = () =>
registry.plugins.some((plugin) => plugin.id === record.id && plugin.status === "loaded");
const isLoadedRecordInActiveRegistry = () =>
getActivePluginRegistry() === registry && isLoadedRecordInRegistry();
const isActivatingLoadedRecord = () =>
registryParams.activateGlobalSideEffects !== false &&
record.enabled &&
@@ -2311,7 +2436,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
const shouldCommitWorkflowSideEffect = () =>
sideEffectGuard.active &&
!isPluginRegistryRetired(registry) &&
(isLoadedRecordInRegistry() || isActivatingLoadedRecord());
(isActivatingLoadedRecord() ||
(isPluginRegistryActivated(registry) && isLoadedRecordInRegistry()));
return buildPluginApi({
id: record.id,
name: record.name,
@@ -2525,6 +2651,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerRuntimeLifecycle: (lifecycle) => registerRuntimeLifecycle(record, lifecycle),
registerAgentEventSubscription: (subscription) =>
registerAgentEventSubscription(record, subscription),
emitAgentEvent: (event) => {
if (registryParams.activateGlobalSideEffects === false) {
return { emitted: false, reason: "global side effects disabled" };
}
if (!shouldCommitWorkflowSideEffect()) {
return { emitted: false, reason: "plugin is not loaded" };
}
return emitPluginAgentEvent({
pluginId: record.id,
pluginName: record.name,
origin: record.origin,
event,
});
},
setRunContext: (patch) =>
registryParams.activateGlobalSideEffects !== false &&
shouldCommitWorkflowSideEffect()
@@ -2545,6 +2685,58 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
},
registerSessionSchedulerJob: (job) => registerSessionSchedulerJob(record, job),
registerSessionAction: (action) => registerSessionAction(record, action),
sendSessionAttachment: async (attachment) => {
if (registryParams.activateGlobalSideEffects === false) {
return { ok: false, error: "global side effects disabled" };
}
try {
if (!isLoadedRecordInActiveRegistry()) {
return { ok: false, error: "plugin is not loaded" };
}
const runtimeConfig =
(registryParams.runtime.config?.current?.() as OpenClawConfig | undefined) ??
params.config;
return await sendPluginSessionAttachment({
...attachment,
config: runtimeConfig,
origin: record.origin,
});
} catch (error) {
return {
ok: false,
error: `attachment delivery setup failed: ${formatErrorMessage(error)}`,
};
}
},
scheduleSessionTurn: async (schedule) => {
if (registryParams.activateGlobalSideEffects === false) {
return undefined;
}
await Promise.resolve();
return schedulePluginSessionTurn({
pluginId: record.id,
pluginName: record.name,
origin: record.origin,
schedule,
shouldCommit: isLoadedRecordInActiveRegistry,
ownerRegistry: registry,
});
},
unscheduleSessionTurnsByTag: async (request) => {
if (registryParams.activateGlobalSideEffects === false) {
return { removed: 0, failed: 0 };
}
await Promise.resolve();
if (!isLoadedRecordInActiveRegistry()) {
return { removed: 0, failed: 0 };
}
return unschedulePluginSessionTurnsByTag({
pluginId: record.id,
origin: record.origin,
request,
});
},
registerMemoryCapability: (capability) => {
if (!hasKind(record.kind, "memory")) {
throwRegistrationError("only memory plugins can register a memory capability");
@@ -2771,6 +2963,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerRuntimeLifecycle,
registerAgentEventSubscription,
registerSessionSchedulerJob,
registerSessionAction,
registerHook,
registerTypedHook,
};

View File

@@ -15,6 +15,7 @@ export type RegistryState = {
activeVersion: number;
httpRoute: RegistrySurfaceState;
channel: RegistrySurfaceState;
agentEventBridgeUnsubscribe?: (() => void) | undefined;
key: string | null;
workspaceDir: string | null;
runtimeSubagentMode: "default" | "explicit" | "gateway-bindable";

View File

@@ -1,7 +1,9 @@
import { afterEach, describe, expect, it } from "vitest";
import { loadChannelOutboundAdapter } from "../channels/plugins/outbound/load.js";
import { getChannelPlugin } from "../channels/plugins/registry.js";
import { emitAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { isPluginRegistryRetired } from "./registry-lifecycle.js";
import {
getActivePluginChannelRegistryVersion,
getActivePluginRegistryVersion,
@@ -87,6 +89,7 @@ function expectChannelRegistrySwap(params: {
describe("channel registry pinning", () => {
afterEach(() => {
resetAgentEventsForTest();
resetPluginRuntimeStateForTest();
});
@@ -104,6 +107,23 @@ describe("channel registry pinning", () => {
expect(getActivePluginChannelRegistry()!.channels).toHaveLength(1);
});
it("keeps pinned channel registries live until they are released", () => {
const { registry: startup } = createRegistryWithChannel();
const replacement = createEmptyPluginRegistry();
setActivePluginRegistry(startup);
pinActivePluginChannelRegistry(startup);
setActivePluginRegistry(replacement);
expect(getActivePluginChannelRegistry()).toBe(startup);
expect(isPluginRegistryRetired(startup)).toBe(false);
releasePinnedPluginChannelRegistry(startup);
expect(getActivePluginChannelRegistry()).toBe(replacement);
expect(isPluginRegistryRetired(startup)).toBe(true);
});
it("re-pin invalidates cached channel lookups", () => {
const { first, second } = createChannelRegistryPair();
const { registry: setup, plugin: setupPlugin } = first;
@@ -195,4 +215,94 @@ describe("channel registry pinning", () => {
const adapter = await loadChannelOutboundAdapter("telegram");
expect(adapter).toBe(outboundAdapter);
});
it("keeps pinned channel registry agent-event subscriptions live after active registry replacement", () => {
const observed: string[] = [];
const startup = createEmptyPluginRegistry();
startup.agentEventSubscriptions = [
{
pluginId: "startup-plugin",
pluginName: "Startup Plugin",
source: "test",
subscription: {
id: "startup-subscription",
handle: (event) => {
observed.push(`startup:${event.stream}`);
},
},
},
];
const replacement = createEmptyPluginRegistry();
replacement.agentEventSubscriptions = [
{
pluginId: "replacement-plugin",
pluginName: "Replacement Plugin",
source: "test",
subscription: {
id: "replacement-subscription",
handle: (event) => {
observed.push(`replacement:${event.stream}`);
},
},
},
];
setActivePluginRegistry(startup);
pinActivePluginChannelRegistry(startup);
setActivePluginRegistry(replacement);
emitAgentEvent({
runId: "run-pinned-agent-events",
stream: "approval",
data: { state: "queued" },
});
expect(observed.toSorted()).toEqual(["replacement:approval", "startup:approval"]);
});
it("dedupes the agent-event bridge across multiple runtime module instances", async () => {
const observed: string[] = [];
const runtimeA = await import(new URL("./runtime.ts?runtimeA", import.meta.url).href);
const runtimeB = await import(new URL("./runtime.ts?runtimeB", import.meta.url).href);
const startup = createEmptyPluginRegistry();
startup.agentEventSubscriptions = [
{
pluginId: "startup-plugin",
pluginName: "Startup Plugin",
source: "test",
subscription: {
id: "startup-subscription",
handle: (event) => {
observed.push(`startup:${event.stream}`);
},
},
},
];
const replacement = createEmptyPluginRegistry();
replacement.agentEventSubscriptions = [
{
pluginId: "replacement-plugin",
pluginName: "Replacement Plugin",
source: "test",
subscription: {
id: "replacement-subscription",
handle: (event) => {
observed.push(`replacement:${event.stream}`);
},
},
},
];
runtimeA.setActivePluginRegistry(startup);
runtimeA.pinActivePluginChannelRegistry(startup);
runtimeB.setActivePluginRegistry(replacement);
emitAgentEvent({
runId: "run-cross-module-pinned-agent-events",
stream: "approval",
data: { state: "queued" },
});
expect(observed.toSorted()).toEqual(["replacement:approval", "startup:approval"]);
});
});

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isPluginRegistryRetired } from "./registry-lifecycle.js";
import { createEmptyPluginRegistry } from "./registry.js";
import type { PluginHttpRouteRegistration } from "./registry.js";
import {
@@ -131,6 +132,22 @@ describe("plugin runtime route registry", () => {
});
});
it("keeps pinned route registries live until they are released", () => {
const { startupRegistry, laterRegistry } = createRuntimeRegistryPair();
setActivePluginRegistry(startupRegistry);
pinActivePluginHttpRouteRegistry(startupRegistry);
setActivePluginRegistry(laterRegistry);
expect(resolveActivePluginHttpRouteRegistry(laterRegistry)).toBe(startupRegistry);
expect(isPluginRegistryRetired(startupRegistry)).toBe(false);
releasePinnedPluginHttpRouteRegistry(startupRegistry);
expect(resolveActivePluginHttpRouteRegistry(laterRegistry)).toBe(laterRegistry);
expect(isPluginRegistryRetired(startupRegistry)).toBe(true);
});
it.each([
{
name: "keeps an explicitly pinned empty route registry authoritative",

View File

@@ -38,6 +38,7 @@ const state: RegistryState = (() => {
pinned: false,
version: 0,
},
agentEventBridgeUnsubscribe: undefined,
key: null,
workspaceDir: null,
runtimeSubagentMode: "default",
@@ -48,8 +49,6 @@ const state: RegistryState = (() => {
return registryState;
})();
let pluginAgentEventUnsubscribe: (() => void) | undefined;
function registryHasPluginHostCleanupWork(registry: PluginRegistry | null): boolean {
if (!registry) {
return false;
@@ -63,6 +62,17 @@ function registryHasPluginHostCleanupWork(registry: PluginRegistry | null): bool
);
}
function isRegistryPinned(registry: PluginRegistry): boolean {
return (
(state.httpRoute.pinned && state.httpRoute.registry === registry) ||
(state.channel.pinned && state.channel.registry === registry)
);
}
function isRegistryLive(registry: PluginRegistry): boolean {
return state.activeRegistry === registry || isRegistryPinned(registry);
}
async function cleanupPreviousPluginHostRegistry(params: {
previousRegistry: PluginRegistry;
}): Promise<void> {
@@ -85,14 +95,51 @@ async function cleanupPreviousPluginHostRegistry(params: {
});
}
function syncPluginAgentEventBridge(registry: PluginRegistry | null): void {
pluginAgentEventUnsubscribe?.();
pluginAgentEventUnsubscribe = undefined;
if (!registry) {
function cleanupRetiredPluginHostRegistry(previousRegistry: PluginRegistry): void {
if (!registryHasPluginHostCleanupWork(previousRegistry)) {
return;
}
pluginAgentEventUnsubscribe = onAgentEvent((event) => {
dispatchPluginAgentEventSubscriptions({ registry: state.activeRegistry, event });
void cleanupPreviousPluginHostRegistry({
previousRegistry,
}).catch((error) => {
log.warn(`plugin host registry cleanup failed: ${String(error)}`);
});
}
function retirePluginRegistryIfUnused(registry: PluginRegistry | null): boolean {
if (!registry || isRegistryLive(registry)) {
return false;
}
markPluginRegistryRetired(registry);
return true;
}
function collectLivePluginAgentEventRegistries(): PluginRegistry[] {
const registries: PluginRegistry[] = [];
const seen = new Set<PluginRegistry>();
const addRegistry = (registry: PluginRegistry | null) => {
if (!registry || seen.has(registry)) {
return;
}
seen.add(registry);
registries.push(registry);
};
addRegistry(asPluginRegistry(state.activeRegistry));
addRegistry(asPluginRegistry(state.httpRoute.registry));
addRegistry(asPluginRegistry(state.channel.registry));
return registries;
}
function syncPluginAgentEventBridge(): void {
state.agentEventBridgeUnsubscribe?.();
state.agentEventBridgeUnsubscribe = undefined;
if (collectLivePluginAgentEventRegistries().length === 0) {
return;
}
state.agentEventBridgeUnsubscribe = onAgentEvent((event) => {
for (const registry of collectLivePluginAgentEventRegistries()) {
dispatchPluginAgentEventSubscriptions({ registry, event });
}
});
}
@@ -137,30 +184,22 @@ export function setActivePluginRegistry(
workspaceDir?: string,
) {
const previousRegistry = asPluginRegistry(state.activeRegistry);
if (previousRegistry && previousRegistry !== registry) {
markPluginRegistryRetired(previousRegistry);
}
markPluginRegistryActive(registry);
state.activeRegistry = registry;
markPluginRegistryActive(registry);
state.activeVersion += 1;
syncTrackedSurface(state.httpRoute, registry, true);
syncTrackedSurface(state.channel, registry, true);
state.key = cacheKey ?? null;
state.workspaceDir = workspaceDir ?? null;
state.runtimeSubagentMode = runtimeSubagentMode;
syncPluginAgentEventBridge(registry);
if (
!previousRegistry ||
previousRegistry === registry ||
!registryHasPluginHostCleanupWork(previousRegistry)
) {
syncPluginAgentEventBridge();
if (!previousRegistry || previousRegistry === registry) {
return;
}
void cleanupPreviousPluginHostRegistry({
previousRegistry,
}).catch((error) => {
log.warn(`plugin host registry cleanup failed: ${String(error)}`);
});
if (!retirePluginRegistryIfUnused(previousRegistry)) {
return;
}
cleanupRetiredPluginHostRegistry(previousRegistry);
}
export function getActivePluginRegistry(): PluginRegistry | null {
@@ -174,6 +213,7 @@ export function getActivePluginRegistryWorkspaceDir(): string | undefined {
export function requireActivePluginRegistry(): PluginRegistry {
if (!state.activeRegistry) {
state.activeRegistry = createEmptyPluginRegistry();
markPluginRegistryActive(state.activeRegistry);
state.activeVersion += 1;
syncTrackedSurface(state.httpRoute, state.activeRegistry);
syncTrackedSurface(state.channel, state.activeRegistry);
@@ -182,14 +222,25 @@ export function requireActivePluginRegistry(): PluginRegistry {
}
export function pinActivePluginHttpRouteRegistry(registry: PluginRegistry) {
const previousRegistry = asPluginRegistry(state.httpRoute.registry);
installSurfaceRegistry(state.httpRoute, registry, true);
markPluginRegistryActive(registry);
syncPluginAgentEventBridge();
if (retirePluginRegistryIfUnused(previousRegistry)) {
cleanupRetiredPluginHostRegistry(previousRegistry!);
}
}
export function releasePinnedPluginHttpRouteRegistry(registry?: PluginRegistry) {
if (registry && state.httpRoute.registry !== registry) {
return;
}
const previousRegistry = asPluginRegistry(state.httpRoute.registry);
installSurfaceRegistry(state.httpRoute, state.activeRegistry, false);
syncPluginAgentEventBridge();
if (retirePluginRegistryIfUnused(previousRegistry)) {
cleanupRetiredPluginHostRegistry(previousRegistry!);
}
}
export function getActivePluginHttpRouteRegistry(): PluginRegistry | null {
@@ -231,14 +282,25 @@ export function resolveActivePluginHttpRouteRegistry(fallback: PluginRegistry):
* gateway startup after the initial plugin load so that config-schema reads
* and other non-primary registry loads cannot evict channel plugins. */
export function pinActivePluginChannelRegistry(registry: PluginRegistry) {
const previousRegistry = asPluginRegistry(state.channel.registry);
installSurfaceRegistry(state.channel, registry, true);
markPluginRegistryActive(registry);
syncPluginAgentEventBridge();
if (retirePluginRegistryIfUnused(previousRegistry)) {
cleanupRetiredPluginHostRegistry(previousRegistry!);
}
}
export function releasePinnedPluginChannelRegistry(registry?: PluginRegistry) {
if (registry && state.channel.registry !== registry) {
return;
}
const previousRegistry = asPluginRegistry(state.channel.registry);
installSurfaceRegistry(state.channel, state.activeRegistry, false);
syncPluginAgentEventBridge();
if (retirePluginRegistryIfUnused(previousRegistry)) {
cleanupRetiredPluginHostRegistry(previousRegistry!);
}
}
/** Return the registry that should be used for channel plugin resolution.
@@ -316,7 +378,7 @@ export function resetPluginRuntimeStateForTest(): void {
state.workspaceDir = null;
state.runtimeSubagentMode = "default";
state.importedPluginIds.clear();
syncPluginAgentEventBridge(null);
syncPluginAgentEventBridge();
// Also clear the plugin host-hook runtime singleton (run context map,
// scheduler-job records, pending agent-event handlers, closedRunIds set).
// Otherwise per-test bleed-over of those globals can cause flaky behavior

View File

@@ -16,7 +16,7 @@ type AjvLike = {
validate: (value: string) => boolean;
},
) => AjvLike;
compile: (schema: JsonSchemaObject) => ValidateFunction;
compile: (schema: JsonSchemaValue) => ValidateFunction;
};
const ajvSingletons = new Map<"default" | "defaults", AjvLike>();
@@ -56,13 +56,15 @@ function getAjv(mode: "default" | "defaults"): AjvLike {
type CachedValidator = {
hasDefaults: boolean;
validate: ValidateFunction;
schema: JsonSchemaObject;
schema: JsonSchemaValue;
schemaFingerprint: string;
};
export type JsonSchemaValue = JsonSchemaObject | boolean;
const schemaCache = new PluginLruCache<CachedValidator>(512);
function fingerprintSchema(schema: JsonSchemaObject): string {
function fingerprintSchema(schema: JsonSchemaValue): string {
return JSON.stringify(schema);
}
@@ -198,7 +200,7 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaVa
}
export function validateJsonSchemaValue(params: {
schema: JsonSchemaObject;
schema: JsonSchemaValue;
cacheKey: string;
value: unknown;
applyDefaults?: boolean;

View File

@@ -94,6 +94,8 @@ import type {
} from "./conversation-binding.types.js";
import type { PluginHookHandlerMap, PluginHookName } from "./hook-types.js";
import type {
PluginAgentEventEmitParams,
PluginAgentEventEmitResult,
PluginAgentEventSubscriptionRegistration,
PluginControlUiDescriptor,
PluginJsonValue,
@@ -102,9 +104,15 @@ import type {
PluginRunContextGetParams,
PluginRunContextPatch,
PluginRuntimeLifecycleRegistration,
PluginSessionActionRegistration,
PluginSessionAttachmentParams,
PluginSessionAttachmentResult,
PluginSessionSchedulerJobHandle,
PluginSessionSchedulerJobRegistration,
PluginSessionExtensionRegistration,
PluginSessionTurnScheduleParams,
PluginSessionTurnUnscheduleByTagParams,
PluginSessionTurnUnscheduleByTagResult,
PluginToolMetadataRegistration,
PluginTrustedToolPolicyRegistration,
} from "./host-hooks.js";
@@ -192,6 +200,8 @@ export type {
} from "./cli-backend.types.js";
export * from "./hook-types.js";
export type {
PluginAgentEventEmitParams,
PluginAgentEventEmitResult,
PluginAgentEventSubscriptionRegistration,
PluginAgentTurnPrepareEvent,
PluginAgentTurnPrepareResult,
@@ -205,10 +215,18 @@ export type {
PluginRunContextGetParams,
PluginRunContextPatch,
PluginRuntimeLifecycleRegistration,
PluginSessionAttachmentParams,
PluginSessionAttachmentResult,
PluginSessionSchedulerJobHandle,
PluginSessionSchedulerJobRegistration,
PluginSessionExtensionRegistration,
PluginSessionExtensionProjection,
PluginSessionActionContext,
PluginSessionActionRegistration,
PluginSessionActionResult,
PluginSessionTurnScheduleParams,
PluginSessionTurnUnscheduleByTagParams,
PluginSessionTurnUnscheduleByTagResult,
PluginToolMetadataRegistration,
PluginTrustedToolPolicyRegistration,
} from "./host-hooks.js";
@@ -2432,6 +2450,79 @@ export type PluginSetupAutoEnableProbe = (
ctx: PluginSetupAutoEnableContext,
) => string | string[] | null | undefined;
export type OpenClawPluginSessionStateApi = {
/** Register plugin-owned session state projected into Gateway session rows. */
registerSessionExtension: (extension: PluginSessionExtensionRegistration) => void;
};
export type OpenClawPluginSessionWorkflowApi = {
/** Queue one plugin-owned context injection for the next agent turn in a session. */
enqueueNextTurnInjection: (
injection: PluginNextTurnInjection,
) => Promise<PluginNextTurnInjectionEnqueueResult>;
/**
* Register cleanup metadata for a plugin-owned session scheduler job.
* This does not schedule work or create task records; it only lets the host
* clean external scheduler state during reset/delete/disable.
*/
registerSessionSchedulerJob: (
job: PluginSessionSchedulerJobRegistration,
) => PluginSessionSchedulerJobHandle | undefined;
/** Send host-validated files to the active direct-outbound route for a session. */
sendSessionAttachment: (
params: PluginSessionAttachmentParams,
) => Promise<PluginSessionAttachmentResult>;
/**
* Schedule a future agent turn in a session through Cron.
* Cron owns timing and creates the task ledger entry when the turn runs.
*/
scheduleSessionTurn: (
params: PluginSessionTurnScheduleParams,
) => Promise<PluginSessionSchedulerJobHandle | undefined>;
/** Remove Cron-backed scheduled session turns that share a plugin-owned tag. */
unscheduleSessionTurnsByTag: (
params: PluginSessionTurnUnscheduleByTagParams,
) => Promise<PluginSessionTurnUnscheduleByTagResult>;
};
export type OpenClawPluginSessionControlsApi = {
/** Register a typed session action that clients can dispatch through the Gateway. */
registerSessionAction: (action: PluginSessionActionRegistration) => void;
/** Register a generic Control UI contribution descriptor. */
registerControlUiDescriptor: (descriptor: PluginControlUiDescriptor) => void;
};
export type OpenClawPluginSessionApi = {
state: OpenClawPluginSessionStateApi;
workflow: OpenClawPluginSessionWorkflowApi;
controls: OpenClawPluginSessionControlsApi;
};
export type OpenClawPluginAgentEventsApi = {
/** Subscribe to sanitized agent events through the host-owned plugin lifecycle. */
registerAgentEventSubscription: (subscription: PluginAgentEventSubscriptionRegistration) => void;
/** Emit a host-routed, plugin-attributed event for workflow/UI subscribers. */
emitAgentEvent: (params: PluginAgentEventEmitParams) => PluginAgentEventEmitResult;
};
export type OpenClawPluginAgentApi = {
events: OpenClawPluginAgentEventsApi;
};
export type OpenClawPluginRunContextApi = {
/** Store namespaced, JSON-compatible data for the active run. Cleared on run end/error. */
setRunContext: (patch: PluginRunContextPatch) => boolean;
/** Read namespaced plugin data for a run. */
getRunContext: (params: PluginRunContextGetParams) => PluginJsonValue | undefined;
/** Clear one namespace or all namespaces this plugin owns for a run. */
clearRunContext: (params: { runId: string; namespace?: string }) => void;
};
export type OpenClawPluginLifecycleApi = {
/** Register cleanup hooks for plugin-owned host state and background work. */
registerRuntimeLifecycle: (lifecycle: PluginRuntimeLifecycleRegistration) => void;
};
/** Main registration API injected into native plugin entry files. */
export type OpenClawPluginApi = {
id: string;
@@ -2451,6 +2542,17 @@ export type OpenClawPluginApi = {
*/
runtime: PluginRuntime;
logger: PluginLogger;
/**
* Grouped facade over the existing flat session-related plugin API.
* Flat methods remain supported for compatibility.
*/
session: OpenClawPluginSessionApi;
/** Grouped facade for agent-event workflow seams. */
agent: OpenClawPluginAgentApi;
/** Grouped facade for run-scoped plugin scratch state. */
runContext: OpenClawPluginRunContextApi;
/** Grouped facade for plugin-owned lifecycle cleanup hooks. */
lifecycle: OpenClawPluginLifecycleApi;
registerTool: (
tool: AnyAgentTool | OpenClawPluginToolFactory,
opts?: OpenClawPluginToolOptions,
@@ -2578,9 +2680,15 @@ export type OpenClawPluginApi = {
handler: AgentToolResultMiddleware,
options?: AgentToolResultMiddlewareOptions,
) => void;
/** Register plugin-owned session state that can be projected into Gateway session rows. */
/**
* Register plugin-owned session state that can be projected into Gateway session rows.
* @deprecated Use `api.session.state.registerSessionExtension(...)`.
*/
registerSessionExtension: (extension: PluginSessionExtensionRegistration) => void;
/** Queue one plugin-owned context injection for the next agent turn in a session. */
/**
* Queue one plugin-owned context injection for the next agent turn in a session.
* @deprecated Use `api.session.workflow.enqueueNextTurnInjection(...)`.
*/
enqueueNextTurnInjection: (
injection: PluginNextTurnInjection,
) => Promise<PluginNextTurnInjectionEnqueueResult>;
@@ -2595,22 +2703,88 @@ export type OpenClawPluginApi = {
* cannot decorate other plugins' tools or core tools through this surface.
*/
registerToolMetadata: (metadata: PluginToolMetadataRegistration) => void;
/** Register a generic Control UI contribution descriptor. */
/**
* Register a generic Control UI contribution descriptor.
* @deprecated Use `api.session.controls.registerControlUiDescriptor(...)`.
*/
registerControlUiDescriptor: (descriptor: PluginControlUiDescriptor) => void;
/** Register cleanup hooks for plugin-owned host state and background work. */
/**
* Register cleanup hooks for plugin-owned host state and background work.
* @deprecated Use `api.lifecycle.registerRuntimeLifecycle(...)`.
*/
registerRuntimeLifecycle: (lifecycle: PluginRuntimeLifecycleRegistration) => void;
/** Subscribe to sanitized agent events through the host-owned plugin lifecycle. */
/**
* Subscribe to sanitized agent events through the host-owned plugin lifecycle.
* @deprecated Use `api.agent.events.registerAgentEventSubscription(...)`.
*/
registerAgentEventSubscription: (subscription: PluginAgentEventSubscriptionRegistration) => void;
/** Store namespaced, JSON-compatible data for the active run. Cleared on run end/error. */
/**
* Emit a host-routed, plugin-attributed agent event for workflow/UI subscribers.
* @deprecated Use `api.agent.events.emitAgentEvent(...)`.
*/
emitAgentEvent: (params: PluginAgentEventEmitParams) => PluginAgentEventEmitResult;
/**
* Store namespaced, JSON-compatible data for the active run. Cleared on run end/error.
* @deprecated Use `api.runContext.setRunContext(...)`.
*/
setRunContext: (patch: PluginRunContextPatch) => boolean;
/** Read namespaced plugin data for a run. */
/**
* Read namespaced plugin data for a run.
* @deprecated Use `api.runContext.getRunContext(...)`.
*/
getRunContext: (params: PluginRunContextGetParams) => PluginJsonValue | undefined;
/** Clear one namespace or all namespaces this plugin owns for a run. */
/**
* Clear one namespace or all namespaces this plugin owns for a run.
* @deprecated Use `api.runContext.clearRunContext(...)`.
*/
clearRunContext: (params: { runId: string; namespace?: string }) => void;
/** Register a plugin-owned session job so reset/delete/disable can clean it deterministically. */
/**
* Register cleanup metadata for a plugin-owned session scheduler job.
* This does not schedule work or create task records; it only lets the host
* clean external scheduler state during reset/delete/disable.
*
* @deprecated Use `api.session.workflow.registerSessionSchedulerJob(...)`.
*/
registerSessionSchedulerJob: (
job: PluginSessionSchedulerJobRegistration,
) => PluginSessionSchedulerJobHandle | undefined;
/**
* Register a typed session action that clients can dispatch through the Gateway.
* @deprecated Use `api.session.controls.registerSessionAction(...)`.
*/
registerSessionAction: (action: PluginSessionActionRegistration) => void;
/**
* Send one or more host-validated files to the active direct-outbound channel for a session.
*
* This API is intended for bundled plugins running with the host channel/session
* integration available. Calls may resolve to `{ ok: false }` instead of attaching
* files when global side effects are disabled or when the required plugin/channel
* runtime is not loaded, so callers must handle rejection via the returned result.
*
* @deprecated Use `api.session.workflow.sendSessionAttachment(...)`.
*/
sendSessionAttachment: (
params: PluginSessionAttachmentParams,
) => Promise<PluginSessionAttachmentResult>;
/**
* Schedule a future agent turn in a session through Cron.
* Cron owns timing and creates the task ledger entry when the turn runs.
* Bundled plugins only; workspace plugins receive undefined.
*
* @deprecated Use `api.session.workflow.scheduleSessionTurn(...)`.
*/
scheduleSessionTurn: (
params: PluginSessionTurnScheduleParams,
) => Promise<PluginSessionSchedulerJobHandle | undefined>;
/**
* Remove Cron-backed scheduled session turns that share the same plugin-owned tag.
* Bundled plugins only; workspace plugins receive a zero-count result.
*
* @deprecated Use `api.session.workflow.unscheduleSessionTurnsByTag(...)`.
*/
unscheduleSessionTurnsByTag: (
params: PluginSessionTurnUnscheduleByTagParams,
) => Promise<PluginSessionTurnUnscheduleByTagResult>;
/** Register the active detached task runtime for this plugin (exclusive slot). */
registerDetachedTaskRuntime: (
runtime: import("./runtime/runtime-tasks.types.js").DetachedTaskLifecycleRuntime,