diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 82f5f8577f2..eb29ada009a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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) diff --git a/extensions/googlechat/config-api.ts b/extensions/googlechat/config-api.ts new file mode 100644 index 00000000000..5a0ee2a703e --- /dev/null +++ b/extensions/googlechat/config-api.ts @@ -0,0 +1,2 @@ +export { GoogleChatConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema"; +export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives"; diff --git a/extensions/googlechat/src/config-schema.ts b/extensions/googlechat/src/config-schema.ts index 93c43b2e25c..b7bf4ce0eda 100644 --- a/extensions/googlechat/src/config-schema.ts +++ b/extensions/googlechat/src/config-schema.ts @@ -1,3 +1,3 @@ -import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../runtime-api.js"; +import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../config-api.js"; export const GoogleChatChannelConfigSchema = buildChannelConfigSchema(GoogleChatConfigSchema); diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index b75ad343ed3..b806ed2b723 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -27,6 +27,7 @@ type MatrixHandlerTestHarnessOptions = { client?: Partial; 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: { diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 4252b298ab5..666ecead118 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -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", diff --git a/extensions/telegram/api.test.ts b/extensions/telegram/api.test.ts new file mode 100644 index 00000000000..741d3821314 --- /dev/null +++ b/extensions/telegram/api.test.ts @@ -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(""); + expect(rendered).toContain("plain"); + }); + + it("re-exports escapeTelegramHtml that escapes Telegram-reserved characters", () => { + expect(typeof escapeTelegramHtml).toBe("function"); + expect(escapeTelegramHtml("x & y")).toBe("<b>x & y</b>"); + }); +}); diff --git a/extensions/telegram/api.ts b/extensions/telegram/api.ts index 677f599ae2e..b1c2585fc37 100644 --- a/extensions/telegram/api.ts +++ b/extensions/telegram/api.ts @@ -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"; diff --git a/extensions/telegram/src/channel.gateway.test.ts b/extensions/telegram/src/channel.gateway.test.ts index 0422958a6f6..d74205861d4 100644 --- a/extensions/telegram/src/channel.gateway.test.ts +++ b/extensions/telegram/src/channel.gateway.test.ts @@ -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: "hi boss", + 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: "report", + mediaUrl: "https://example.com/report.png", + }, + formatting: { parseMode: "HTML" }, + }); + + expect(sendMessageTelegram.mock.calls[0]?.[2]).toMatchObject({ textMode: "html" }); + }); +}); diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index 370ef8f4cce..44a0ef8bcef 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -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, "&").replace(//g, ">"); } +function escapeHtml(text: string): string { + return escapeTelegramHtml(text); +} + function escapeHtmlAttr(text: string): string { return escapeHtml(text).replace(/"/g, """); } diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index 1f56ff16d6d..cbca9db33b0 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -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"); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 6d706d89834..a15e07a47f3 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -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["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["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, diff --git a/extensions/telegram/src/telegram-outbound.test.ts b/extensions/telegram/src/telegram-outbound.test.ts index 1fb2011fa4a..1a243e88ada 100644 --- a/extensions/telegram/src/telegram-outbound.test.ts +++ b/extensions/telegram/src/telegram-outbound.test.ts @@ -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 = "hi"; + + expect(telegramOutbound.chunker?.(text, 4000, { formatting: { parseMode: "HTML" } })).toEqual( + splitTelegramHtmlChunks(text, 4000), + ); + expect(telegramOutbound.chunker?.(text, 4000)).toEqual( + markdownToTelegramHtmlChunks(text, 4000), + ); + }); }); diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index c35837398a6..86962a56fa5 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -10,11 +10,15 @@ import { type JsonSchema = { type?: string | string[]; + const?: boolean | number | string | null; properties?: Record; required?: string[]; items?: JsonSchema; enum?: string[]; patternProperties?: Record; + 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 = { @@ -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()); diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 39cc5432da3..384623f21fd 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -2172,9 +2172,9 @@ describe("AcpSessionManager", () => { }); const internals = manager as unknown as { - actorTailBySession: Map>; + actorQueue: { getTailMapForTesting(): Map> }; }; - expect(internals.actorTailBySession.size).toBe(0); + expect(internals.actorQueue.getTailMapForTesting().size).toBe(0); }); it("surfaces backend failures raised after a done event", async () => { diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index 6e9abe47b9b..4694f42b7d6 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -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( diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index 9141db26bbe..38005aa4a4f 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -154,7 +154,7 @@ export async function callGatewayTool>( const gateway = resolveGatewayOptions(opts); const scopes = Array.isArray(extra?.scopes) ? extra.scopes - : resolveLeastPrivilegeOperatorScopesForMethod(method); + : resolveLeastPrivilegeOperatorScopesForMethod(method, params); return await callGateway({ url: gateway.url, token: gateway.token, diff --git a/src/channels/plugins/outbound.types.ts b/src/channels/plugins/outbound.types.ts index 4a182929dcf..030ab2bab4e 100644 --- a/src/channels/plugins/outbound.types.ts +++ b/src/channels/plugins/outbound.types.ts @@ -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; diff --git a/src/config/sessions/delivery-info.test.ts b/src/config/sessions/delivery-info.test.ts index d3e4f58d64f..da400a77473 100644 --- a/src/config/sessions/delivery-info.test.ts +++ b/src/config/sessions/delivery-info.test.ts @@ -5,6 +5,7 @@ import type { SessionEntry } from "./types.js"; const storeState = vi.hoisted(() => ({ store: {} as Record, + stores: {} as Record>, })); 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`; diff --git a/src/config/sessions/delivery-info.ts b/src/config/sessions/delivery-info.ts index 5dfdec05ff6..8ce80c42a11 100644 --- a/src/config/sessions/delivery-info.ts +++ b/src/config/sessions/delivery-info.ts @@ -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(); + 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, + keys: readonly string[], +) { + let normalizedIndex: Map | 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, +): Map { + const index = new Map(); + 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; + baseEntry: ReturnType; + } + | 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 }; +} diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 5a6630fd224..931cbe8dda3 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -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(); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 220b67699e6..7a5703a94df 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -779,7 +779,7 @@ export async function callGatewayCli>( 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>( export async function callGatewayLeastPrivilege>( opts: CallGatewayBaseOptions, ): Promise { - const scopes = resolveLeastPrivilegeOperatorScopesForMethod(opts.method); + const scopes = resolveLeastPrivilegeOperatorScopesForMethod(opts.method, opts.params); return await callGatewayWithScopes(opts, scopes); } diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 28f22916329..537953c99ad 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -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( [], diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index b3184de0960..f942973d459 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -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 = { [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; } diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 69e4a576bc3..46f8dc17d14 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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( PluginsUiDescriptorsParamsSchema, ); +export const validatePluginsSessionActionParams = ajv.compile( + PluginsSessionActionParamsSchema, +); +export const validatePluginsSessionActionResult = ajv.compile( + PluginsSessionActionResultSchema, +); export const validateExecApprovalsNodeGetParams = ajv.compile( 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, diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index b10cbda06e7..3bc19753bd6 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -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, diff --git a/src/gateway/protocol/schema/plugins.ts b/src/gateway/protocol/schema/plugins.ts index 6adb0e120e6..99455940256 100644 --- a/src/gateway/protocol/schema/plugins.ts +++ b/src/gateway/protocol/schema/plugins.ts @@ -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, +]); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 4d209c82f87..18034b94ba4 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -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, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index e512dd08442..6b149c19721 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -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">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index b87a0afa799..18ae9426b16 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -52,6 +52,7 @@ const BASE_METHODS = [ "plugin.approval.waitDecision", "plugin.approval.resolve", "plugins.uiDescriptors", + "plugins.sessionAction", "wizard.start", "wizard.next", "wizard.cancel", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 07331491458..210b4a418b6 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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 { 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; diff --git a/src/gateway/server-methods/plugin-host-hooks.ts b/src/gateway/server-methods/plugin-host-hooks.ts index ce1e6639ec3..091e90e4c40 100644 --- a/src/gateway/server-methods/plugin-host-hooks.ts +++ b/src/gateway/server-methods/plugin-host-hooks.ts @@ -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 { + 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 | 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")); + } + }, }; diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 54594ec8714..b01dfba0697 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -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: "report", + 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({ diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 5bfb4bdf085..6f163709375 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -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, diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 001822967f5..8e0efa74ec9 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -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(() => ["bold"]); + 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: "bold", + formatting: { parseMode: "HTML" }, + }), + ); + }); + it("passes config through for plugin media sends", async () => { const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-media", roomId: "!room" }); setActivePluginRegistry( diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 1c26b49cf31..410147e0565 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -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 => ({ ...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, diff --git a/src/infra/outbound/formatting.ts b/src/infra/outbound/formatting.ts index 6f035e40336..f64811b6c8c 100644 --- a/src/infra/outbound/formatting.ts +++ b/src/infra/outbound/formatting.ts @@ -6,4 +6,5 @@ export type OutboundDeliveryFormattingOptions = { maxLinesPerMessage?: number; tableMode?: MarkdownTableMode; chunkMode?: ChunkMode; + parseMode?: "HTML"; }; diff --git a/src/infra/outbound/message-plan.test.ts b/src/infra/outbound/message-plan.test.ts index 19a001ad578..c6232256a07 100644 --- a/src/infra/outbound/message-plan.test.ts +++ b/src/infra/outbound/message-plan.test.ts @@ -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: () => ["bold"], + chunkedTextFormatting: { parseMode: "HTML" }, + overrides: {}, + }); + + expect(units).toEqual([ + { + kind: "text", + text: "bold", + overrides: { formatting: { parseMode: "HTML" } }, + }, + ]); + }); }); diff --git a/src/infra/outbound/message-plan.ts b/src/infra/outbound/message-plan.ts index 1f95747a5aa..cadff0c98d2 100644 --- a/src/infra/outbound/message-plan.ts +++ b/src/infra/outbound/message-plan.ts @@ -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: { diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index 3c1998cdff6..30b84398bd3 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -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); }); diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 81a9cd5705d..772236b6d45 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -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; 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 } : {}), }; } diff --git a/src/plugins/agent-event-emission.ts b/src/plugins/agent-event-emission.ts new file mode 100644 index 00000000000..5505b53daf1 --- /dev/null +++ b/src/plugins/agent-event-emission.ts @@ -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([ + "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 { + 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 }; +} diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index dc93c1a22ee..a2256a505d2 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -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); } diff --git a/src/plugins/api-facades.ts b/src/plugins/api-facades.ts new file mode 100644 index 00000000000..9a7ab56c889 --- /dev/null +++ b/src/plugins/api-facades.ts @@ -0,0 +1,60 @@ +import type { OpenClawPluginApi } from "./types.js"; + +type PluginApiFacadeFields = Pick< + OpenClawPluginApi, + "agent" | "lifecycle" | "runContext" | "session" +>; +export type OpenClawPluginApiWithoutFacades = Omit; +type PluginApiFacadeSource = Pick< + OpenClawPluginApi, + | "clearRunContext" + | "emitAgentEvent" + | "enqueueNextTurnInjection" + | "getRunContext" + | "registerAgentEventSubscription" + | "registerControlUiDescriptor" + | "registerRuntimeLifecycle" + | "registerSessionAction" + | "registerSessionExtension" + | "registerSessionSchedulerJob" + | "scheduleSessionTurn" + | "sendSessionAttachment" + | "setRunContext" + | "unscheduleSessionTurnsByTag" +>; + +export function attachPluginApiFacades( + api: T & PluginApiFacadeSource & Partial, +): 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; +} diff --git a/src/plugins/api-lifecycle.ts b/src/plugins/api-lifecycle.ts new file mode 100644 index 00000000000..597f83ae6ab --- /dev/null +++ b/src/plugins/api-lifecycle.ts @@ -0,0 +1,34 @@ +import type { OpenClawPluginApi } from "./types.js"; + +type FunctionPropertyNames = Extract< + { + [K in keyof T]-?: Exclude extends (...args: unknown[]) => unknown ? K : never; + }[keyof T], + string +>; + +export type PluginApiMethodName = FunctionPropertyNames; + +export type PluginApiLifecyclePolicy = { + phase: "registration" | "runtime"; + lateCallable: boolean; +}; + +const PLUGIN_API_METHOD_POLICIES: Partial> = { + 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; +} diff --git a/src/plugins/captured-registration.test.ts b/src/plugins/captured-registration.test.ts index 6caaa22c00f..5213faaeba8 100644 --- a/src/plugins/captured-registration.test.ts +++ b/src/plugins/captured-registration.test.ts @@ -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", + }, + ]); + }); }); diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index 956e3b3b33f..edcec6aa21e 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -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[0]> & { + register(api: OpenClawPluginApi): void; + }, +): CapturedPluginRegistration { + const captured = createCapturedPluginRegistration(params); params.register(captured.api); return captured; } diff --git a/src/plugins/contracts/bundled-extension-config-api-guardrails.test.ts b/src/plugins/contracts/bundled-extension-config-api-guardrails.test.ts index a24b456907c..1c49089b91b 100644 --- a/src/plugins/contracts/bundled-extension-config-api-guardrails.test.ts +++ b/src/plugins/contracts/bundled-extension-config-api-guardrails.test.ts @@ -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. diff --git a/src/plugins/contracts/host-hook-fixture.ts b/src/plugins/contracts/host-hook-fixture.ts index 2658bcc7bda..ba0ac9c0f10 100644 --- a/src/plugins/contracts/host-hook-fixture.ts +++ b/src/plugins/contracts/host-hook-fixture.ts @@ -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", diff --git a/src/plugins/contracts/host-hooks.contract.test.ts b/src/plugins/contracts/host-hooks.contract.test.ts index f59045e9d60..1623db31580 100644 --- a/src/plugins/contracts/host-hooks.contract.test.ts +++ b/src/plugins/contracts/host-hooks.contract.test.ts @@ -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; diff --git a/src/plugins/contracts/run-context-lifecycle.contract.test.ts b/src/plugins/contracts/run-context-lifecycle.contract.test.ts index bb196ca5dba..5b317fd3bf6 100644 --- a/src/plugins/contracts/run-context-lifecycle.contract.test.ts +++ b/src/plugins/contracts/run-context-lifecycle.contract.test.ts @@ -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 }); }); diff --git a/src/plugins/contracts/scheduled-turns.contract.test.ts b/src/plugins/contracts/scheduled-turns.contract.test.ts new file mode 100644 index 00000000000..70c6ad89cb9 --- /dev/null +++ b/src/plugins/contracts/scheduled-turns.contract.test.ts @@ -0,0 +1,1122 @@ +import { + createPluginRegistryFixture, + registerTestPlugin, +} from "openclaw/plugin-sdk/plugin-test-contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + GatewayRequestHandler, + GatewayRequestHandlerOptions, +} from "../../gateway/server-methods/types.js"; +import { withEnv } from "../../test-utils/env.js"; +import { cleanupReplacedPluginHostRegistry } from "../host-hook-cleanup.js"; +import { + clearPluginHostRuntimeState, + cleanupPluginSessionSchedulerJobs, + listPluginSessionSchedulerJobs, +} from "../host-hook-runtime.js"; +import { + buildPluginSchedulerCronName, + schedulePluginSessionTurn, + unschedulePluginSessionTurnsByTag, +} from "../host-hook-scheduled-turns.js"; +import { clearPluginLoaderCache, loadOpenClawPlugins } from "../loader.js"; +import { makeTempDir, writePlugin } from "../loader.test-fixtures.js"; +import { createEmptyPluginRegistry } from "../registry-empty.js"; +import { setActivePluginRegistry } from "../runtime.js"; +import { createPluginRecord } from "../status.test-helpers.js"; +import type { OpenClawPluginApi } from "../types.js"; + +const workflowMocks = vi.hoisted(() => ({ + callGatewayTool: vi.fn(), +})); + +const WORKFLOW_PLUGIN_ID = "workflow-plugin"; +const MAIN_SESSION_KEY = "agent:main:main"; +const DEFAULT_TURN_SCHEDULE = { + sessionKey: MAIN_SESSION_KEY, + message: "wake", + delayMs: 1_000, +} as const; + +type ScheduleSessionTurnRequest = Parameters[0]; +type SessionTurnSchedule = ScheduleSessionTurnRequest["schedule"]; + +vi.mock("../../agents/tools/gateway.js", () => ({ + callGatewayTool: workflowMocks.callGatewayTool, +})); + +async function invokePluginGatewayHandler(params: { + handler: GatewayRequestHandler; + method: string; + params?: Record; +}): Promise { + return await new Promise((resolve, reject) => { + const handlerParams = params.params ?? {}; + const respond = ( + ok: boolean, + payload?: unknown, + error?: { message?: string }, + meta?: Record, + ) => { + void meta; + if (ok) { + resolve(payload); + return; + } + reject(new Error(error?.message ?? `gateway handler failed: ${params.method}`)); + }; + // Keep this helper pinned to the live request-frame contract so gateway typing drift breaks here first. + const handlerOptions: GatewayRequestHandlerOptions = { + req: { + type: "req", + id: "test-request", + method: params.method, + params: handlerParams, + }, + params: handlerParams, + client: null, + isWebchatConnect: () => false, + respond, + context: {} as GatewayRequestHandlerOptions["context"], + }; + Promise.resolve(params.handler(handlerOptions)).catch(reject); + }); +} + +function mockCronAdd(response: unknown) { + workflowMocks.callGatewayTool.mockImplementation(async (method: string) => { + if (method === "cron.add") { + return response; + } + return { ok: true }; + }); +} + +function getCronAddBody() { + const addCall = workflowMocks.callGatewayTool.mock.calls.find((args) => args[0] === "cron.add"); + expect(addCall).toBeDefined(); + return addCall?.[2] as Record; +} + +async function scheduleWorkflowTurn( + params: Omit & { + origin?: ScheduleSessionTurnRequest["origin"]; + schedule?: Partial; + } = {}, +) { + const { origin = "bundled", schedule, ...rest } = params; + return await schedulePluginSessionTurn({ + pluginId: WORKFLOW_PLUGIN_ID, + origin, + schedule: { ...DEFAULT_TURN_SCHEDULE, ...schedule } as SessionTurnSchedule, + ...rest, + }); +} + +async function unscheduleWorkflowTurnsByTag( + request: Parameters[0]["request"] = { + sessionKey: MAIN_SESSION_KEY, + tag: "nudge", + }, + origin: Parameters[0]["origin"] = "bundled", +) { + return await unschedulePluginSessionTurnsByTag({ + pluginId: WORKFLOW_PLUGIN_ID, + origin, + request, + }); +} + +describe("plugin scheduled turns", () => { + afterEach(() => { + vi.useRealTimers(); + workflowMocks.callGatewayTool.mockReset(); + clearPluginLoaderCache(); + clearPluginHostRuntimeState(); + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("builds tagged and untagged cron names", () => { + expect( + buildPluginSchedulerCronName({ + pluginId: WORKFLOW_PLUGIN_ID, + sessionKey: MAIN_SESSION_KEY, + tag: "nudge", + uniqueId: "abc", + }), + ).toBe("plugin:workflow-plugin:tag:nudge:agent:main:main:abc"); + expect( + buildPluginSchedulerCronName({ + pluginId: WORKFLOW_PLUGIN_ID, + sessionKey: MAIN_SESSION_KEY, + uniqueId: "xyz", + }), + ).toBe("plugin:workflow-plugin:agent:main:main:xyz"); + }); + + it("schedules session turns with cron-compatible tagged cleanup metadata", async () => { + mockCronAdd({ payload: { jobId: "job-tagged" } }); + + const handle = await scheduleWorkflowTurn({ + pluginName: "Workflow Plugin", + schedule: { + tag: "nudge", + name: "custom-nudge-name", + deliveryMode: "announce", + }, + }); + + expect(handle).toEqual({ + id: "job-tagged", + pluginId: WORKFLOW_PLUGIN_ID, + sessionKey: MAIN_SESSION_KEY, + kind: "session-turn", + }); + const job = getCronAddBody(); + expect(job.name).toBe("plugin:workflow-plugin:tag:nudge:agent:main:main:custom-nudge-name"); + expect(job.sessionTarget).toBe("session:agent:main:main"); + expect(job.deleteAfterRun).toBe(true); + expect(job.delivery).toEqual({ mode: "announce", channel: "last" }); + expect(job.payload).toMatchObject({ + kind: "agentTurn", + message: "wake", + }); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toHaveLength(1); + }); + + it("prefixes explicit untagged schedule names with plugin ownership metadata", async () => { + mockCronAdd({ id: "job-untagged" }); + + await expect( + scheduleWorkflowTurn({ + schedule: { + name: "daily-nudge", + }, + }), + ).resolves.toMatchObject({ id: "job-untagged" }); + + expect(getCronAddBody().name).toBe("plugin:workflow-plugin:agent:main:main:daily-nudge"); + }); + + it("builds payloads accepted by the real cron.add protocol validator", async () => { + const { validateCronAddParams } = await import("../../gateway/protocol/index.js"); + workflowMocks.callGatewayTool.mockImplementation(async (method: string, _opts, body) => { + if (method === "cron.add") { + expect(validateCronAddParams(body)).toBe(true); + expect((body as { delivery?: unknown }).delivery).toEqual({ + mode: "announce", + channel: "last", + }); + return { id: "cron-compatible-job" }; + } + return { ok: true }; + }); + + await expect( + scheduleWorkflowTurn({ + schedule: { + tag: "nudge", + }, + }), + ).resolves.toMatchObject({ id: "cron-compatible-job" }); + }); + + it("pages through cron.list when unscheduling tagged turns", async () => { + const removed: string[] = []; + const listRequests: unknown[] = []; + workflowMocks.callGatewayTool.mockImplementation( + async (method: string, _opts: unknown, body: unknown) => { + if (method === "cron.list") { + const offset = (body as { offset?: unknown }).offset; + listRequests.push(body); + if (offset === undefined) { + return { + jobs: [ + { + id: "job-page-1", + name: "plugin:workflow-plugin:tag:nudge:agent:main:main:1", + sessionTarget: "session:agent:main:main", + }, + ], + hasMore: true, + nextOffset: 200, + }; + } + return { + jobs: [ + { + id: "job-page-2", + name: "plugin:workflow-plugin:tag:nudge:agent:main:main:2", + sessionTarget: "session:agent:main:main", + }, + ], + hasMore: false, + nextOffset: null, + }; + } + if (method === "cron.remove") { + removed.push((body as { id?: string }).id ?? ""); + return { ok: true, removed: true }; + } + return { ok: true }; + }, + ); + + await expect(unscheduleWorkflowTurnsByTag()).resolves.toEqual({ removed: 2, failed: 0 }); + expect(listRequests).toEqual([ + { + includeDisabled: true, + limit: 200, + query: "plugin:workflow-plugin:tag:nudge:agent:main:main:", + sortBy: "name", + sortDir: "asc", + }, + { + includeDisabled: true, + limit: 200, + offset: 200, + query: "plugin:workflow-plugin:tag:nudge:agent:main:main:", + sortBy: "name", + sortDir: "asc", + }, + ]); + expect(removed.toSorted()).toEqual(["job-page-1", "job-page-2"]); + }); + + it("tracks scheduled session turns using cron.add's top-level job id", async () => { + workflowMocks.callGatewayTool.mockResolvedValueOnce({ + id: "cron-top-level-id", + payload: { + id: "payload-body-id", + kind: "agentTurn", + }, + }); + + await expect( + scheduleWorkflowTurn({ + pluginName: "Workflow Plugin", + }), + ).resolves.toEqual({ + id: "cron-top-level-id", + pluginId: WORKFLOW_PLUGIN_ID, + sessionKey: MAIN_SESSION_KEY, + kind: "session-turn", + }); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toEqual([ + { + id: "cron-top-level-id", + pluginId: WORKFLOW_PLUGIN_ID, + sessionKey: MAIN_SESSION_KEY, + kind: "session-turn", + }, + ]); + }); + + it("keeps one-shot scheduled-turn records until cleanup confirms the job is gone", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z")); + const removed: string[] = []; + workflowMocks.callGatewayTool.mockImplementation(async (method: string, _opts, body) => { + if (method === "cron.add") { + return { id: "one-shot-job" }; + } + if (method === "cron.remove") { + removed.push((body as { id?: string }).id ?? ""); + return { ok: true, removed: false }; + } + return { ok: true }; + }); + + await expect( + scheduleWorkflowTurn({ + pluginName: "Workflow Plugin", + }), + ).resolves.toEqual({ + id: "one-shot-job", + pluginId: WORKFLOW_PLUGIN_ID, + sessionKey: MAIN_SESSION_KEY, + kind: "session-turn", + }); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(60_999); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toHaveLength(1); + + await expect( + cleanupPluginSessionSchedulerJobs({ + pluginId: WORKFLOW_PLUGIN_ID, + reason: "disable", + }), + ).resolves.toEqual([]); + expect(removed).toEqual(["one-shot-job"]); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toEqual([]); + }); + + it("rejects invalid schedules, unsupported delivery modes, and ambiguous tags before cron.add", async () => { + await expect( + schedulePluginSessionTurn({ + pluginId: "workflow-plugin", + origin: "bundled", + schedule: { + sessionKey: "agent:main:main", + message: "wake", + delayMs: -1, + }, + }), + ).resolves.toBeUndefined(); + + await expect( + schedulePluginSessionTurn({ + pluginId: "workflow-plugin", + origin: "bundled", + schedule: { + sessionKey: "agent:main:main", + message: "wake", + delayMs: 1_000, + deliveryMode: "unsupported" as never, + }, + }), + ).resolves.toBeUndefined(); + await expect( + schedulePluginSessionTurn({ + pluginId: "workflow-plugin", + origin: "bundled", + schedule: { + sessionKey: "agent:main:main", + message: "wake", + cron: "*/5 * * * *", + deleteAfterRun: true, + } as never, + }), + ).resolves.toBeUndefined(); + await expect( + schedulePluginSessionTurn({ + pluginId: "workflow-plugin", + origin: "bundled", + schedule: { + sessionKey: "agent:main:main", + message: "wake", + delayMs: 1_000, + tag: "nudge:followup", + }, + }), + ).resolves.toBeUndefined(); + expect(workflowMocks.callGatewayTool).not.toHaveBeenCalled(); + }); + + it("falls back to a valid delay schedule when a malformed cron value is absent", async () => { + mockCronAdd({ id: "delay-job" }); + + await expect( + scheduleWorkflowTurn({ + schedule: { + cron: undefined, + } as never, + }), + ).resolves.toMatchObject({ id: "delay-job" }); + + expect((getCronAddBody() as { schedule?: { kind?: string } }).schedule?.kind).toBe("at"); + }); + + it("removes a stale cron job when the plugin unloads after cron.add", async () => { + let commit = true; + const removed: string[] = []; + workflowMocks.callGatewayTool.mockImplementation( + async (method: string, _opts: unknown, body: unknown) => { + if (method === "cron.add") { + commit = false; + return { payload: { jobId: "job-stale" } }; + } + if (method === "cron.remove") { + removed.push((body as { id?: string }).id ?? ""); + return { ok: true, removed: true }; + } + return { ok: true }; + }, + ); + + await expect( + scheduleWorkflowTurn({ + schedule: { delayMs: 1 }, + shouldCommit: () => commit, + }), + ).resolves.toBeUndefined(); + expect(removed).toEqual(["job-stale"]); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toEqual([]); + }); + + it("allows bundled plugins to schedule turns during real plugin registration", async () => { + const bundledDir = makeTempDir(); + writePlugin({ + id: "loader-scheduler", + dir: bundledDir, + filename: "index.cjs", + body: `module.exports = { + id: "loader-scheduler", + register(api) { + void api.session.workflow.scheduleSessionTurn({ + sessionKey: "agent:main:main", + message: "wake", + delayMs: 1 + }); + } +};`, + }); + workflowMocks.callGatewayTool.mockImplementation(async (method: string) => { + if (method === "cron.add") { + return { id: "loader-scheduled-job" }; + } + if (method === "cron.remove") { + return { ok: true, removed: true }; + } + return { ok: true }; + }); + + const registry = withEnv( + { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + }, + () => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + entries: { + "loader-scheduler": { + enabled: true, + }, + }, + }, + }, + }), + ); + + expect(registry.plugins.find((plugin) => plugin.id === "loader-scheduler")?.status).toBe( + "loaded", + ); + await vi.waitFor(() => + expect(workflowMocks.callGatewayTool).toHaveBeenCalledWith( + "cron.add", + {}, + expect.objectContaining({ + sessionTarget: "session:agent:main:main", + payload: { kind: "agentTurn", message: "wake" }, + }), + { scopes: ["operator.admin"] }, + ), + ); + expect(listPluginSessionSchedulerJobs("loader-scheduler")).toEqual([ + { + id: "loader-scheduled-job", + pluginId: "loader-scheduler", + sessionKey: "agent:main:main", + kind: "session-turn", + }, + ]); + }); + + it("keeps late scheduled-turn helpers callable from real plugin gateway handlers", async () => { + const bundledDir = makeTempDir(); + writePlugin({ + id: "loader-scheduler-runtime", + dir: bundledDir, + filename: "index.cjs", + body: `module.exports = { + id: "loader-scheduler-runtime", + register(api) { + const scheduleSessionTurn = api.session.workflow.scheduleSessionTurn; + const unscheduleSessionTurnsByTag = api.session.workflow.unscheduleSessionTurnsByTag; + api.registerGatewayMethod("loader-scheduler-runtime.exercise", async ({ respond }) => { + const first = await scheduleSessionTurn({ + sessionKey: "agent:main:main", + message: "wake one", + delayMs: 1, + tag: "nudge", + }); + const second = await scheduleSessionTurn({ + sessionKey: "agent:main:main", + message: "wake two", + delayMs: 1, + tag: "nudge", + deliveryMode: "none", + }); + const badTag = await scheduleSessionTurn({ + sessionKey: "agent:main:main", + message: "bad tag", + delayMs: 1, + tag: "bad:tag", + }); + const badDelete = await scheduleSessionTurn({ + sessionKey: "agent:main:main", + message: "bad delete", + cron: "0 * * * *", + deleteAfterRun: true, + tag: "nudge", + }); + const removed = await unscheduleSessionTurnsByTag({ + sessionKey: "agent:main:main", + tag: "nudge", + }); + respond(true, { + first, + second, + badTag: badTag ?? null, + badDelete: badDelete ?? null, + removed: removed ?? null, + }); + }); + }, +};`, + }); + const addedJobs: Array> = []; + const removedJobIds = new Set(); + workflowMocks.callGatewayTool.mockImplementation(async (method: string, _opts, body) => { + if (method === "cron.add") { + const id = `loader-scheduled-job-${addedJobs.length + 1}`; + addedJobs.push({ + id, + ...(body as Record), + }); + return { id }; + } + if (method === "cron.list") { + return { + jobs: addedJobs.filter((job) => { + const id = typeof job.id === "string" ? job.id : ""; + return id && !removedJobIds.has(id); + }), + }; + } + if (method === "cron.remove") { + const id = + typeof (body as { id?: unknown })?.id === "string" ? (body as { id: string }).id : ""; + if (id) { + removedJobIds.add(id); + } + return { ok: true, removed: true }; + } + return { ok: true }; + }); + + const registry = withEnv( + { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + }, + () => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + entries: { + "loader-scheduler-runtime": { + enabled: true, + }, + }, + }, + }, + }), + ); + + expect( + registry.plugins.find((plugin) => plugin.id === "loader-scheduler-runtime")?.status, + ).toBe("loaded"); + const handler = registry.gatewayHandlers["loader-scheduler-runtime.exercise"]; + expect(handler).toBeTypeOf("function"); + if (!handler) { + throw new Error("missing loader-scheduler-runtime.exercise gateway handler"); + } + + await expect( + invokePluginGatewayHandler({ + handler, + method: "loader-scheduler-runtime.exercise", + }), + ).resolves.toEqual({ + first: { + id: "loader-scheduled-job-1", + pluginId: "loader-scheduler-runtime", + sessionKey: "agent:main:main", + kind: "session-turn", + }, + second: { + id: "loader-scheduled-job-2", + pluginId: "loader-scheduler-runtime", + sessionKey: "agent:main:main", + kind: "session-turn", + }, + badTag: null, + badDelete: null, + removed: { removed: 2, failed: 0 }, + }); + expect(addedJobs.map((job) => job.name)).toEqual([ + expect.stringContaining("plugin:loader-scheduler-runtime:tag:nudge:agent:main:main:"), + expect.stringContaining("plugin:loader-scheduler-runtime:tag:nudge:agent:main:main:"), + ]); + expect(addedJobs.map((job) => job.delivery)).toEqual([ + { mode: "announce", channel: "last" }, + { mode: "none" }, + ]); + expect(listPluginSessionSchedulerJobs("loader-scheduler-runtime")).toEqual([]); + }); + + it("keeps stale scheduled-turn rollback non-throwing when cron cleanup fails", async () => { + let commit = true; + workflowMocks.callGatewayTool.mockImplementation( + async (method: string, _opts: unknown, body: unknown) => { + if (method === "cron.add") { + commit = false; + return { id: "job-stale" }; + } + if (method === "cron.remove") { + throw new Error(`remove failed for ${(body as { id?: string }).id}`); + } + return { ok: true }; + }, + ); + + await expect( + scheduleWorkflowTurn({ + schedule: { delayMs: 1 }, + shouldCommit: () => commit, + }), + ).resolves.toBeUndefined(); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toEqual([]); + }); + + it("keeps scheduled-turn records when cleanup fails", async () => { + workflowMocks.callGatewayTool.mockImplementation( + async (method: string, _opts: unknown, body: unknown) => { + if (method === "cron.add") { + return { id: "cleanup-failure-job" }; + } + if (method === "cron.remove") { + throw new Error(`remove failed for ${(body as { id?: string }).id}`); + } + return { ok: true }; + }, + ); + + await expect( + scheduleWorkflowTurn({ + pluginName: "Workflow Plugin", + }), + ).resolves.toMatchObject({ id: "cleanup-failure-job" }); + + await expect( + cleanupPluginSessionSchedulerJobs({ + pluginId: WORKFLOW_PLUGIN_ID, + reason: "disable", + }), + ).resolves.toEqual([ + expect.objectContaining({ + pluginId: WORKFLOW_PLUGIN_ID, + hookId: "scheduler:cleanup-failure-job", + }), + ]); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toEqual([ + { + id: "cleanup-failure-job", + pluginId: WORKFLOW_PLUGIN_ID, + sessionKey: MAIN_SESSION_KEY, + kind: "session-turn", + }, + ]); + }); + + it("cleans live dynamic scheduled turns when registry cleanup records are empty", async () => { + const removed: string[] = []; + workflowMocks.callGatewayTool.mockImplementation( + async (method: string, _opts: unknown, body: unknown) => { + if (method === "cron.add") { + return { id: "dynamic-cleanup-job" }; + } + if (method === "cron.remove") { + removed.push((body as { id?: string }).id ?? ""); + return { ok: true, removed: true }; + } + return { ok: true }; + }, + ); + + await expect(scheduleWorkflowTurn()).resolves.toMatchObject({ id: "dynamic-cleanup-job" }); + + await expect( + cleanupPluginSessionSchedulerJobs({ + pluginId: WORKFLOW_PLUGIN_ID, + reason: "restart", + records: [], + }), + ).resolves.toEqual([]); + expect(removed).toEqual(["dynamic-cleanup-job"]); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toEqual([]); + }); + + it("preserves replacement-generation runtime scheduled turns during restart cleanup", async () => { + const removed: string[] = []; + const scheduledIds = ["old-runtime-job", "new-runtime-job"]; + workflowMocks.callGatewayTool.mockImplementation( + async (method: string, _opts: unknown, body: unknown) => { + if (method === "cron.add") { + const id = scheduledIds.shift() ?? "unexpected-job"; + return { id }; + } + if (method === "cron.remove") { + removed.push((body as { id?: string }).id ?? ""); + return { ok: true, removed: true }; + } + return { ok: true }; + }, + ); + + const previousFixture = createPluginRegistryFixture(); + previousFixture.registry.registry.plugins.push( + createPluginRecord({ + id: WORKFLOW_PLUGIN_ID, + name: "Workflow Plugin", + origin: "bundled", + }), + ); + await scheduleWorkflowTurn({ + pluginName: "Workflow Plugin", + ownerRegistry: previousFixture.registry.registry, + schedule: { + message: "old wake", + }, + }); + + const replacementFixture = createPluginRegistryFixture(); + replacementFixture.registry.registry.plugins.push( + createPluginRecord({ + id: WORKFLOW_PLUGIN_ID, + name: "Workflow Plugin", + origin: "bundled", + }), + ); + await scheduleWorkflowTurn({ + pluginName: "Workflow Plugin", + ownerRegistry: replacementFixture.registry.registry, + schedule: { + message: "new wake", + }, + }); + + await expect( + cleanupReplacedPluginHostRegistry({ + cfg: previousFixture.config, + previousRegistry: previousFixture.registry.registry, + nextRegistry: replacementFixture.registry.registry, + }), + ).resolves.toMatchObject({ failures: [] }); + expect(removed).toEqual(["old-runtime-job"]); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toEqual([ + { + id: "new-runtime-job", + pluginId: WORKFLOW_PLUGIN_ID, + sessionKey: MAIN_SESSION_KEY, + kind: "session-turn", + }, + ]); + }); + + it("treats already-missing cron jobs as successful scheduled-turn cleanup", async () => { + const removed: string[] = []; + workflowMocks.callGatewayTool.mockImplementation( + async (method: string, _opts: unknown, body: unknown) => { + if (method === "cron.add") { + return { id: "already-missing-job" }; + } + if (method === "cron.remove") { + removed.push((body as { id?: string }).id ?? ""); + return { ok: true, removed: false }; + } + return { ok: true }; + }, + ); + + await expect(scheduleWorkflowTurn()).resolves.toMatchObject({ id: "already-missing-job" }); + + await expect( + cleanupPluginSessionSchedulerJobs({ + pluginId: WORKFLOW_PLUGIN_ID, + reason: "disable", + }), + ).resolves.toEqual([]); + expect(removed).toEqual(["already-missing-job"]); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toEqual([]); + }); + + it("removes only matching plugin tag jobs in the requested session", async () => { + const removed: string[] = []; + const listQueries: unknown[] = []; + workflowMocks.callGatewayTool.mockImplementation( + async (method: string, _opts: unknown, body: unknown) => { + if (method === "cron.list") { + listQueries.push((body as { query?: unknown }).query); + return { + jobs: [ + { + id: "job-a", + name: "plugin:workflow-plugin:tag:nudge:agent:main:main:1", + sessionTarget: "session:agent:main:main", + }, + { + id: "job-b", + name: "plugin:workflow-plugin:tag:nudge:agent:main:main:2", + sessionTarget: "session:agent:main:main", + }, + { + id: "job-c", + name: "plugin:other-plugin:tag:nudge:agent:main:main:1", + sessionTarget: "session:agent:main:main", + }, + { + id: "job-d", + name: "plugin:workflow-plugin:tag:nudge:agent:other:main:1", + sessionTarget: "session:agent:other:main", + }, + ], + }; + } + if (method === "cron.remove") { + removed.push((body as { id?: string }).id ?? ""); + return { ok: true, removed: true }; + } + return { ok: true }; + }, + ); + + await expect(unscheduleWorkflowTurnsByTag()).resolves.toEqual({ removed: 2, failed: 0 }); + expect(listQueries).toEqual(["plugin:workflow-plugin:tag:nudge:agent:main:main:"]); + expect(removed.toSorted()).toEqual(["job-a", "job-b"]); + }); + + it("prunes runtime scheduler records after tagged unschedule removes jobs", async () => { + let addCount = 0; + workflowMocks.callGatewayTool.mockImplementation( + async (method: string, _opts: unknown, body: unknown) => { + if (method === "cron.add") { + addCount += 1; + return { id: `job-${addCount}` }; + } + if (method === "cron.list") { + return { + jobs: [ + { + id: "job-1", + name: "plugin:workflow-plugin:tag:nudge:agent:main:main:first", + sessionTarget: "session:agent:main:main", + }, + { + id: "job-2", + name: "plugin:workflow-plugin:tag:nudge:agent:main:main:second", + sessionTarget: "session:agent:main:main", + }, + ], + }; + } + if (method === "cron.remove") { + expect(["job-1", "job-2"]).toContain((body as { id?: unknown }).id); + return { ok: true, removed: true }; + } + return { ok: true }; + }, + ); + + await scheduleWorkflowTurn({ + schedule: { + message: "first", + tag: "nudge", + name: "first", + }, + }); + await scheduleWorkflowTurn({ + schedule: { + message: "second", + tag: "nudge", + name: "second", + }, + }); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toHaveLength(2); + + await expect(unscheduleWorkflowTurnsByTag()).resolves.toEqual({ removed: 2, failed: 0 }); + expect(listPluginSessionSchedulerJobs(WORKFLOW_PLUGIN_ID)).toEqual([]); + }); + + it("counts cron.list and cron.remove failures when unscheduling by tag", async () => { + workflowMocks.callGatewayTool.mockRejectedValueOnce(new Error("cron list unavailable")); + await expect(unscheduleWorkflowTurnsByTag()).resolves.toEqual({ removed: 0, failed: 1 }); + + workflowMocks.callGatewayTool.mockReset(); + workflowMocks.callGatewayTool.mockImplementation( + async (method: string, _opts: unknown, body: unknown) => { + if (method === "cron.list") { + return { + jobs: [ + { + id: "job-ok", + name: "plugin:workflow-plugin:tag:nudge:agent:main:main:1", + sessionTarget: "session:agent:main:main", + }, + { + id: "job-fail", + name: "plugin:workflow-plugin:tag:nudge:agent:main:main:2", + sessionTarget: "session:agent:main:main", + }, + ], + }; + } + if (method === "cron.remove" && (body as { id?: string }).id === "job-fail") { + throw new Error("remove failed"); + } + if (method === "cron.remove") { + return { ok: true, removed: true }; + } + return { ok: true }; + }, + ); + + await expect(unscheduleWorkflowTurnsByTag()).resolves.toEqual({ removed: 1, failed: 1 }); + + workflowMocks.callGatewayTool.mockReset(); + workflowMocks.callGatewayTool.mockImplementation( + async (method: string, _opts: unknown, body: unknown) => { + if (method === "cron.list") { + return { + jobs: [ + { + id: "job-missing", + name: "plugin:workflow-plugin:tag:nudge:agent:main:main:1", + sessionTarget: "session:agent:main:main", + }, + ], + }; + } + if (method === "cron.remove") { + expect((body as { id?: string }).id).toBe("job-missing"); + return { ok: true, removed: false }; + } + return { ok: true }; + }, + ); + + await expect(unscheduleWorkflowTurnsByTag()).resolves.toEqual({ removed: 0, failed: 1 }); + }); + + it("does not unschedule turns for non-bundled plugins or invalid tag requests", async () => { + await expect(unscheduleWorkflowTurnsByTag(undefined, "workspace")).resolves.toEqual({ + removed: 0, + failed: 0, + }); + await expect( + unscheduleWorkflowTurnsByTag({ sessionKey: MAIN_SESSION_KEY, tag: " " }), + ).resolves.toEqual({ removed: 0, failed: 0 }); + await expect( + unscheduleWorkflowTurnsByTag({ sessionKey: MAIN_SESSION_KEY, tag: "nudge:followup" }), + ).resolves.toEqual({ removed: 0, failed: 0 }); + expect(workflowMocks.callGatewayTool).not.toHaveBeenCalled(); + }); + + it("wires schedule and unschedule through the plugin API with stale-registry protection", async () => { + workflowMocks.callGatewayTool.mockImplementation(async (method: string) => { + if (method === "cron.add") { + return { payload: { jobId: "job-live" } }; + } + if (method === "cron.list") { + return { jobs: [] }; + } + if (method === "cron.remove") { + return { ok: true, removed: true }; + } + return { ok: true }; + }); + const { config, registry } = createPluginRegistryFixture(); + let capturedApi: OpenClawPluginApi | undefined; + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "scheduler-plugin", + name: "Scheduler Plugin", + origin: "bundled", + }), + register(api) { + capturedApi = api; + }, + }); + setActivePluginRegistry(registry.registry); + + await expect( + capturedApi?.session.workflow.scheduleSessionTurn({ + sessionKey: "agent:main:main", + message: "wake", + delayMs: 10, + }), + ).resolves.toMatchObject({ id: "job-live", pluginId: "scheduler-plugin" }); + await expect( + capturedApi?.session.workflow.unscheduleSessionTurnsByTag({ + sessionKey: "agent:main:main", + tag: "nudge", + }), + ).resolves.toEqual({ removed: 0, failed: 0 }); + + setActivePluginRegistry(createEmptyPluginRegistry()); + await expect( + capturedApi?.session.workflow.scheduleSessionTurn({ + sessionKey: "agent:main:main", + message: "wake", + delayMs: 10, + }), + ).resolves.toBeUndefined(); + await expect( + capturedApi?.session.workflow.unscheduleSessionTurnsByTag({ + sessionKey: "agent:main:main", + tag: "nudge", + }), + ).resolves.toEqual({ removed: 0, failed: 0 }); + }); + + it("blocks registration-time schedule and unschedule calls before activation", async () => { + // Drain any cleanup microtasks queued by the previous test's + // setActivePluginRegistry calls; setActivePluginRegistry schedules + // cleanup via fire-and-forget dynamic imports that may resolve and + // invoke callGatewayTool after this test's mockReset. + for (let i = 0; i < 8; i++) { + await Promise.resolve(); + } + workflowMocks.callGatewayTool.mockReset(); + workflowMocks.callGatewayTool.mockResolvedValue({ ok: true }); + const activeFixture = createPluginRegistryFixture(); + setActivePluginRegistry(activeFixture.registry.registry); + + const loadingFixture = createPluginRegistryFixture(); + const loadingApi = loadingFixture.registry.createApi( + createPluginRecord({ + id: "preactivation-scheduler", + name: "Preactivation Scheduler", + origin: "bundled", + }), + { config: loadingFixture.config }, + ); + + await expect( + loadingApi.session.workflow.scheduleSessionTurn({ + sessionKey: "agent:main:main", + message: "wake", + delayMs: 10, + }), + ).resolves.toBeUndefined(); + await expect( + loadingApi.session.workflow.unscheduleSessionTurnsByTag({ + sessionKey: "agent:main:main", + tag: "nudge", + }), + ).resolves.toEqual({ removed: 0, failed: 0 }); + expect(workflowMocks.callGatewayTool).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/contracts/session-actions.contract.test.ts b/src/plugins/contracts/session-actions.contract.test.ts new file mode 100644 index 00000000000..f1c398e16c9 --- /dev/null +++ b/src/plugins/contracts/session-actions.contract.test.ts @@ -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, +): Record { + return { + pluginId, + actionId, + ...extra, + }; +} + +async function callPluginSessionActionForTest(params: { + body: Record; + 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; + 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; + 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[0]["context"], + }); + return response ?? { ok: false, error: new Error("handler did not respond") }; +} + +async function callRegisteredSessionActionThroughGatewayForTest(params: { + pluginId: string; + actionId: string; + extra?: Record; + 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, + ): 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; + 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", + }, + }), + ]); + }); +}); diff --git a/src/plugins/contracts/session-attachments.contract.test.ts b/src/plugins/contracts/session-attachments.contract.test.ts new file mode 100644 index 00000000000..aec8d1ceb21 --- /dev/null +++ b/src/plugins/contracts/session-attachments.contract.test.ts @@ -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[0]; +type TestSessionEntry = { + sessionId?: string; + updatedAt?: number; + deliveryContext?: Record; +}; + +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, +) { + 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) => ({ + channel: params.channel, + to: params.to, + via: "direct" as const, + mediaUrl: null, + result: { channel: params.channel, messageId }, + })); +} + +async function sendBundledSessionAttachment( + params: Omit & + Partial>, +) { + 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 < 2 & 3 > 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(); + }); + }); +}); diff --git a/src/plugins/host-hook-attachments.ts b/src/plugins/host-hook-attachments.ts new file mode 100644 index 00000000000..65717b940ab --- /dev/null +++ b/src/plugins/host-hook-attachments.ts @@ -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 | undefined; + +async function loadSendMessage(): Promise { + sendMessagePromise ??= import("../infra/outbound/message.js").then( + (module) => module.sendMessage, + ); + return sendMessagePromise; +} + +type GetChannelPlugin = typeof import("../channels/plugins/index.js").getChannelPlugin; +let getChannelPluginPromise: Promise | undefined; + +type AttachmentDeliveryChannelPlugin = { + outbound?: { + deliveryMode?: string; + }; +}; + +async function loadGetChannelPlugin(): Promise { + 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, "&").replace(//g, ">"); +} + +async function readMimeSniffBuffer( + filePath: string, + size: number, +): Promise { + let handle: Awaited> | 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 { + 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 { + 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>; + 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, + }; +} diff --git a/src/plugins/host-hook-cleanup.ts b/src/plugins/host-hook-cleanup.ts index 6f5d9ce4774..2e1590bb855 100644 --- a/src/plugins/host-hook-cleanup.ts +++ b/src/plugins/host-hook-cleanup.ts @@ -305,6 +305,7 @@ export async function runPluginHostCleanup(params: { preserveSchedulerJobIds?: ReadonlySet; shouldCleanup?: () => boolean; restartPromotedSessionEntrySlotKeys?: ReadonlySet; + preserveSchedulerOwnerRegistry?: PluginRegistry | null; }): Promise { 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); diff --git a/src/plugins/host-hook-runtime.ts b/src/plugins/host-hook-runtime.ts index 0a14c290b51..92323b4f5ee 100644 --- a/src/plugins/host-hook-runtime.ts +++ b/src/plugins/host-hook-runtime.ts @@ -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; excludeJobKeys?: ReadonlySet; shouldCleanup?: () => boolean; + preserveOwnerRegistry?: PluginRegistry | null; }): Promise> { 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(); + 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; } diff --git a/src/plugins/host-hook-scheduled-turns.ts b/src/plugins/host-hook-scheduled-turns.ts new file mode 100644 index 00000000000..bc1f18aeb58 --- /dev/null +++ b/src/plugins/host-hook-scheduled-turns.ts @@ -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 | undefined; + +type ResolvedSessionTurnSchedule = + | { + kind: "cron"; + expr: string; + tz?: string; + } + | { + kind: "at"; + at: string; + }; + +async function callGatewayToolLazy( + ...args: Parameters +): Promise>> { + 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 { + 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; + const topLevelId = normalizeCronJobId(record.jobId ?? record.id); + if (topLevelId) { + return topLevelId; + } + const payload = + record.payload && typeof record.payload === "object" + ? (record.payload as Record) + : 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 { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function readCronListJobs(value: unknown): Record[] { + 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[]> { + const jobs: Record[] = []; + 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 { + 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 = { + 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 { + 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[]; + 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 }; +} diff --git a/src/plugins/host-hooks.ts b/src/plugins/host-hooks.ts index 78aa3891fd6..6b29a2bc853 100644 --- a/src/plugins/host-hooks.ts +++ b/src/plugins/host-hooks.ts @@ -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; +}; + export type PluginRuntimeLifecycleRegistration = { id: string; description?: string; @@ -137,6 +172,17 @@ export type PluginAgentEventSubscriptionRegistration = { ) => void | Promise; }; +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(); } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 6f0687b7a83..583f5db9884 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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", }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index b793ed7552b..b3e00a4d58e 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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; services: PluginRegistry["services"]; commands: PluginRegistry["commands"]; + sessionActions: NonNullable; 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() { diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index d3496c51d33..1bbddd8fa18 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -46,6 +46,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { runtimeLifecycles: [], agentEventSubscriptions: [], sessionSchedulerJobs: [], + sessionActions: [], conversationBindingResolvedHandlers: [], diagnostics: [], }; diff --git a/src/plugins/registry-lifecycle.ts b/src/plugins/registry-lifecycle.ts index 18dbdcbf48d..5c664841163 100644 --- a/src/plugins/registry-lifecycle.ts +++ b/src/plugins/registry-lifecycle.ts @@ -1,6 +1,7 @@ import type { PluginRegistry } from "./registry-types.js"; const retiredRegistries = new WeakSet(); +const activatedRegistries = new WeakSet(); 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); } diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index f82c652109f..f24b18c3175 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -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[]; }; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 61f5c6f7f2c..9bd563ee18b 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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([ "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 = ( 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, }; diff --git a/src/plugins/runtime-state.ts b/src/plugins/runtime-state.ts index 7cb7a949ff1..4513d62fdc2 100644 --- a/src/plugins/runtime-state.ts +++ b/src/plugins/runtime-state.ts @@ -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"; diff --git a/src/plugins/runtime.channel-pin.test.ts b/src/plugins/runtime.channel-pin.test.ts index 7cbd8a51ff2..ea5a6d69fc7 100644 --- a/src/plugins/runtime.channel-pin.test.ts +++ b/src/plugins/runtime.channel-pin.test.ts @@ -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"]); + }); }); diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts index e3003f15ea7..057ce86119c 100644 --- a/src/plugins/runtime.test.ts +++ b/src/plugins/runtime.test.ts @@ -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", diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index cc93ee54d6e..9147f951fec 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -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 { @@ -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(); + 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 diff --git a/src/plugins/schema-validator.ts b/src/plugins/schema-validator.ts index 9fc18345297..ab7867b9ded 100644 --- a/src/plugins/schema-validator.ts +++ b/src/plugins/schema-validator.ts @@ -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(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; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 9446a4d62ad..383054c19d2 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; + /** + * 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; + /** + * 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; + /** Remove Cron-backed scheduled session turns that share a plugin-owned tag. */ + unscheduleSessionTurnsByTag: ( + params: PluginSessionTurnUnscheduleByTagParams, + ) => Promise; +}; + +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; @@ -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; + /** + * 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; + /** + * 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; /** Register the active detached task runtime for this plugin (exclusive slot). */ registerDetachedTaskRuntime: ( runtime: import("./runtime/runtime-tasks.types.js").DetachedTaskLifecycleRuntime,