mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 19:14:44 +00:00
feat(plugin-sdk): consolidate session workflow APIs
This commit is contained in:
@@ -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)
|
||||
|
||||
2
extensions/googlechat/config-api.ts
Normal file
2
extensions/googlechat/config-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { GoogleChatConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
@@ -1,3 +1,3 @@
|
||||
import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../runtime-api.js";
|
||||
import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../config-api.js";
|
||||
|
||||
export const GoogleChatChannelConfigSchema = buildChannelConfigSchema(GoogleChatConfigSchema);
|
||||
|
||||
@@ -27,6 +27,7 @@ type MatrixHandlerTestHarnessOptions = {
|
||||
client?: Partial<MatrixClient>;
|
||||
runtime?: RuntimeEnv;
|
||||
logger?: RuntimeLogger;
|
||||
currentConfig?: () => unknown;
|
||||
logVerboseMessage?: (message: string) => void;
|
||||
allowFrom?: string[];
|
||||
allowFromResolvedEntries?: MatrixMonitorHandlerParams["allowFromResolvedEntries"];
|
||||
@@ -193,7 +194,7 @@ export function createMatrixHandlerTestHarness(
|
||||
} as never,
|
||||
core: {
|
||||
config: {
|
||||
current: () => options.liveCfg ?? cfgForHandler,
|
||||
current: options.currentConfig ?? (() => options.liveCfg ?? cfgForHandler),
|
||||
},
|
||||
channel: {
|
||||
pairing: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
16
extensions/telegram/api.test.ts
Normal file
16
extensions/telegram/api.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { escapeTelegramHtml, markdownToTelegramHtml } from "./api.js";
|
||||
|
||||
describe("@openclaw/telegram api re-exports", () => {
|
||||
it("re-exports markdownToTelegramHtml as a working function", () => {
|
||||
expect(typeof markdownToTelegramHtml).toBe("function");
|
||||
const rendered = markdownToTelegramHtml("**bold** plain");
|
||||
expect(rendered).toContain("<b>");
|
||||
expect(rendered).toContain("plain");
|
||||
});
|
||||
|
||||
it("re-exports escapeTelegramHtml that escapes Telegram-reserved characters", () => {
|
||||
expect(typeof escapeTelegramHtml).toBe("function");
|
||||
expect(escapeTelegramHtml("<b>x & y</b>")).toBe("<b>x & y</b>");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { TelegramRuntime } from "./runtime.types.js";
|
||||
|
||||
const probeTelegram = vi.fn();
|
||||
const monitorTelegramProvider = vi.fn();
|
||||
const sendMessageTelegram = vi.fn();
|
||||
|
||||
function installTelegramRuntime() {
|
||||
const runtime = createPluginRuntimeMock();
|
||||
@@ -22,6 +23,7 @@ function installTelegramRuntime() {
|
||||
telegram: {
|
||||
probeTelegram: probeTelegram as TelegramProbeFn,
|
||||
monitorTelegramProvider: monitorTelegramProvider as TelegramMonitorFn,
|
||||
sendMessageTelegram,
|
||||
},
|
||||
},
|
||||
} as unknown as TelegramRuntime);
|
||||
@@ -80,6 +82,7 @@ afterEach(() => {
|
||||
clearTelegramRuntime();
|
||||
probeTelegram.mockReset();
|
||||
monitorTelegramProvider.mockReset();
|
||||
sendMessageTelegram.mockReset();
|
||||
});
|
||||
|
||||
describe("telegramPlugin gateway startup", () => {
|
||||
@@ -220,3 +223,47 @@ describe("telegramPlugin gateway startup", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegramPlugin outbound attachments", () => {
|
||||
it("preserves default markdown rendering unless a parse mode is explicit", async () => {
|
||||
installTelegramRuntime();
|
||||
sendMessageTelegram.mockResolvedValue({ messageId: "tg-1", chatId: "12345" });
|
||||
const sendText = telegramPlugin.outbound?.sendText;
|
||||
expect(sendText).toBeDefined();
|
||||
|
||||
await sendText!({
|
||||
cfg: createTelegramConfig(),
|
||||
to: "12345",
|
||||
text: "hi **boss**",
|
||||
});
|
||||
expect(sendMessageTelegram.mock.calls[0]?.[2]).not.toHaveProperty("textMode");
|
||||
|
||||
await sendText!({
|
||||
cfg: createTelegramConfig(),
|
||||
to: "12345",
|
||||
text: "<b>hi boss</b>",
|
||||
formatting: { parseMode: "HTML" },
|
||||
});
|
||||
expect(sendMessageTelegram.mock.calls[1]?.[2]).toMatchObject({ textMode: "html" });
|
||||
});
|
||||
|
||||
it("preserves explicit HTML parse mode for payload media captions", async () => {
|
||||
installTelegramRuntime();
|
||||
sendMessageTelegram.mockResolvedValue({ messageId: "tg-payload", chatId: "12345" });
|
||||
const sendPayload = telegramPlugin.outbound?.sendPayload;
|
||||
expect(sendPayload).toBeDefined();
|
||||
|
||||
await sendPayload!({
|
||||
cfg: createTelegramConfig(),
|
||||
to: "12345",
|
||||
text: "",
|
||||
payload: {
|
||||
text: "<b>report</b>",
|
||||
mediaUrl: "https://example.com/report.png",
|
||||
},
|
||||
formatting: { parseMode: "HTML" },
|
||||
});
|
||||
|
||||
expect(sendMessageTelegram.mock.calls[0]?.[2]).toMatchObject({ textMode: "html" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return escapeTelegramHtml(text);
|
||||
}
|
||||
|
||||
function escapeHtmlAttr(text: string): string {
|
||||
return escapeHtml(text).replace(/"/g, """);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
renderMessagePresentationFallbackText,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import type { OutboundDeliveryFormattingOptions } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { resolveTelegramInlineButtons } from "./button-types.js";
|
||||
import { markdownToTelegramHtmlChunks } from "./format.js";
|
||||
import { markdownToTelegramHtmlChunks, splitTelegramHtmlChunks } from "./format.js";
|
||||
import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js";
|
||||
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
|
||||
|
||||
@@ -46,12 +47,23 @@ async function resolveDefaultTelegramSend(deps?: OutboundSendDeps): Promise<Tele
|
||||
);
|
||||
}
|
||||
|
||||
function chunkTelegramOutboundText(
|
||||
text: string,
|
||||
limit: number,
|
||||
ctx?: { formatting?: OutboundDeliveryFormattingOptions },
|
||||
): string[] {
|
||||
return ctx?.formatting?.parseMode === "HTML"
|
||||
? splitTelegramHtmlChunks(text, limit)
|
||||
: markdownToTelegramHtmlChunks(text, limit);
|
||||
}
|
||||
|
||||
async function resolveTelegramSendContext(params: {
|
||||
cfg: NonNullable<TelegramSendOpts>["cfg"];
|
||||
deps?: OutboundSendDeps;
|
||||
accountId?: string | null;
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
formatting?: OutboundDeliveryFormattingOptions;
|
||||
silent?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
resolveSend: ResolveTelegramSendFn;
|
||||
@@ -60,7 +72,7 @@ async function resolveTelegramSendContext(params: {
|
||||
baseOpts: {
|
||||
cfg: NonNullable<TelegramSendOpts>["cfg"];
|
||||
verbose: false;
|
||||
textMode: "html";
|
||||
textMode?: "html";
|
||||
messageThreadId?: number;
|
||||
replyToMessageId?: number;
|
||||
accountId?: string;
|
||||
@@ -73,13 +85,13 @@ async function resolveTelegramSendContext(params: {
|
||||
send,
|
||||
baseOpts: {
|
||||
verbose: false,
|
||||
textMode: "html",
|
||||
cfg: params.cfg,
|
||||
messageThreadId: parseTelegramThreadId(params.threadId),
|
||||
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
|
||||
accountId: params.accountId ?? undefined,
|
||||
silent: params.silent,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
...(params.formatting?.parseMode === "HTML" ? { textMode: "html" as const } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -148,8 +160,9 @@ export function createTelegramOutboundAdapter(
|
||||
|
||||
return {
|
||||
deliveryMode: "direct",
|
||||
chunker: markdownToTelegramHtmlChunks,
|
||||
chunker: chunkTelegramOutboundText,
|
||||
chunkerMode: "markdown",
|
||||
chunkedTextFormatting: { parseMode: "HTML" },
|
||||
extractMarkdownImages: true,
|
||||
textChunkLimit: TELEGRAM_TEXT_CHUNK_LIMIT,
|
||||
sanitizeText: ({ text }) => sanitizeForPlainText(text),
|
||||
@@ -209,6 +222,7 @@ export function createTelegramOutboundAdapter(
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
formatting,
|
||||
silent,
|
||||
gatewayClientScopes,
|
||||
}) => {
|
||||
@@ -218,6 +232,7 @@ export function createTelegramOutboundAdapter(
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
formatting,
|
||||
silent,
|
||||
gatewayClientScopes,
|
||||
resolveSend,
|
||||
@@ -237,6 +252,7 @@ export function createTelegramOutboundAdapter(
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
formatting,
|
||||
forceDocument,
|
||||
silent,
|
||||
gatewayClientScopes,
|
||||
@@ -247,6 +263,7 @@ export function createTelegramOutboundAdapter(
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
formatting,
|
||||
silent,
|
||||
gatewayClientScopes,
|
||||
resolveSend,
|
||||
@@ -270,6 +287,7 @@ export function createTelegramOutboundAdapter(
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
formatting,
|
||||
forceDocument,
|
||||
silent,
|
||||
gatewayClientScopes,
|
||||
@@ -280,6 +298,7 @@ export function createTelegramOutboundAdapter(
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
formatting,
|
||||
silent,
|
||||
gatewayClientScopes,
|
||||
resolveSend,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToTelegramHtmlChunks } from "./format.js";
|
||||
import { markdownToTelegramHtmlChunks, splitTelegramHtmlChunks } from "./format.js";
|
||||
import { telegramOutbound } from "./outbound-adapter.js";
|
||||
import { clearTelegramRuntime } from "./runtime.js";
|
||||
|
||||
@@ -12,7 +12,20 @@ describe("telegramPlugin outbound", () => {
|
||||
expect(telegramOutbound.chunker?.(text, 4000)).toEqual(expected);
|
||||
expect(telegramOutbound.deliveryMode).toBe("direct");
|
||||
expect(telegramOutbound.chunkerMode).toBe("markdown");
|
||||
expect(telegramOutbound.chunkedTextFormatting).toEqual({ parseMode: "HTML" });
|
||||
expect(telegramOutbound.textChunkLimit).toBe(4000);
|
||||
expect(telegramOutbound.pollMaxOptions).toBe(10);
|
||||
});
|
||||
|
||||
it("preserves explicit HTML parse mode before chunking", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = "<b>hi</b>";
|
||||
|
||||
expect(telegramOutbound.chunker?.(text, 4000, { formatting: { parseMode: "HTML" } })).toEqual(
|
||||
splitTelegramHtmlChunks(text, 4000),
|
||||
);
|
||||
expect(telegramOutbound.chunker?.(text, 4000)).toEqual(
|
||||
markdownToTelegramHtmlChunks(text, 4000),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,11 +10,15 @@ import {
|
||||
|
||||
type JsonSchema = {
|
||||
type?: string | string[];
|
||||
const?: boolean | number | string | null;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
items?: JsonSchema;
|
||||
enum?: string[];
|
||||
patternProperties?: Record<string, JsonSchema>;
|
||||
anyOf?: JsonSchema[];
|
||||
oneOf?: JsonSchema[];
|
||||
additionalProperties?: boolean | JsonSchema;
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -31,7 +35,12 @@ const outPaths = [
|
||||
),
|
||||
];
|
||||
|
||||
const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\n// swiftlint:disable file_length\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\npublic let GATEWAY_MIN_PROTOCOL_VERSION = ${MIN_CLIENT_PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values(
|
||||
const STRICT_LITERAL_STRUCTS = new Set([
|
||||
"PluginsSessionActionSuccessResult",
|
||||
"PluginsSessionActionFailureResult",
|
||||
]);
|
||||
|
||||
const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\n// swiftlint:disable file_length\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\npublic let GATEWAY_MIN_PROTOCOL_VERSION = ${MIN_CLIENT_PROTOCOL_VERSION}\n\nprivate struct GatewayAnyCodingKey: CodingKey, Hashable {\n let stringValue: String\n let intValue: Int?\n\n init?(stringValue: String) {\n self.stringValue = stringValue\n self.intValue = nil\n }\n\n init?(intValue: Int) {\n self.stringValue = String(intValue)\n self.intValue = intValue\n }\n}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values(
|
||||
ErrorCodes,
|
||||
)
|
||||
.map((c) => ` case ${camelCase(c)} = "${c}"`)
|
||||
@@ -172,10 +181,111 @@ function emitEnum(name: string, schema: JsonSchema): string {
|
||||
function emitStruct(name: string, schema: JsonSchema): string {
|
||||
const props = schema.properties ?? {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
const literalProps = Object.entries(props)
|
||||
.map(([key, propSchema]) => ({
|
||||
key,
|
||||
propSchema,
|
||||
literal: literalSchemaValue(propSchema),
|
||||
}))
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is {
|
||||
key: string;
|
||||
propSchema: JsonSchema;
|
||||
literal: boolean | number | string | null;
|
||||
} => entry.literal !== undefined,
|
||||
);
|
||||
const lines: string[] = [];
|
||||
if (Object.keys(props).length === 0) {
|
||||
return `public struct ${name}: Codable, Sendable {}\n`;
|
||||
}
|
||||
if (STRICT_LITERAL_STRUCTS.has(name) && literalProps.length > 0) {
|
||||
const literalPropByKey = new Map(literalProps.map((entry) => [entry.key, entry.literal]));
|
||||
lines.push(`public struct ${name}: Codable, Sendable {`);
|
||||
const codingKeys: string[] = [];
|
||||
for (const [key, propSchema] of Object.entries(props)) {
|
||||
const propName = safeName(key);
|
||||
const propType = swiftType(propSchema, required.has(key), true);
|
||||
lines.push(` public let ${propName}: ${propType}`);
|
||||
if (propName !== key) {
|
||||
codingKeys.push(` case ${propName} = "${key}"`);
|
||||
} else {
|
||||
codingKeys.push(` case ${propName}`);
|
||||
}
|
||||
}
|
||||
const initializerParams = Object.entries(props)
|
||||
.filter(([key]) => !literalPropByKey.has(key))
|
||||
.map(([key, prop]) => {
|
||||
const propName = safeName(key);
|
||||
const req = required.has(key);
|
||||
return ` ${propName}: ${swiftType(prop, true, true)}${req ? "" : "?"}`;
|
||||
});
|
||||
lines.push(
|
||||
"\n public init(\n" +
|
||||
(initializerParams.length > 0 ? initializerParams.join(",\n") : " ") +
|
||||
"\n )\n" +
|
||||
" {\n" +
|
||||
Object.entries(props)
|
||||
.map(([key]) => {
|
||||
const propName = safeName(key);
|
||||
if (literalPropByKey.has(key)) {
|
||||
return ` self.${propName} = ${swiftLiteralSource(literalPropByKey.get(key)!)}`;
|
||||
}
|
||||
return ` self.${propName} = ${propName}`;
|
||||
})
|
||||
.join("\n") +
|
||||
"\n }\n\n" +
|
||||
" private enum CodingKeys: String, CodingKey {\n" +
|
||||
codingKeys.join("\n") +
|
||||
"\n }\n\n" +
|
||||
" public init(from decoder: Decoder) throws {\n" +
|
||||
(schema.additionalProperties === false
|
||||
? ` let rawContainer = try decoder.container(keyedBy: GatewayAnyCodingKey.self)\n let unexpectedKeys = rawContainer.allKeys\n .map(\\.stringValue)\n .filter { !Set([${Object.keys(
|
||||
props,
|
||||
)
|
||||
.map((key) => JSON.stringify(key))
|
||||
.join(
|
||||
", ",
|
||||
)}]).contains($0) }\n if !unexpectedKeys.isEmpty {\n throw DecodingError.dataCorrupted(\n .init(\n codingPath: rawContainer.codingPath,\n debugDescription: "Unexpected keys for ${name}: \\(unexpectedKeys.sorted().joined(separator: ", "))"\n )\n )\n }\n`
|
||||
: "") +
|
||||
" let container = try decoder.container(keyedBy: CodingKeys.self)\n" +
|
||||
Object.entries(props)
|
||||
.map(([key, propSchema]) => {
|
||||
const propName = safeName(key);
|
||||
const capitalizedPropName = propName.slice(0, 1).toUpperCase() + propName.slice(1);
|
||||
const literal = literalPropByKey.get(key);
|
||||
if (literal !== undefined) {
|
||||
const literalType = swiftType(propSchema, true, true);
|
||||
return ` let decoded${capitalizedPropName} = try container.decode(${literalType}.self, forKey: .${propName})\n guard decoded${capitalizedPropName} == ${swiftLiteralSource(literal)} else {\n throw DecodingError.dataCorruptedError(\n forKey: .${propName},\n in: container,\n debugDescription: "Expected ${key} to equal ${String(literal)}"\n )\n }\n self.${propName} = ${swiftLiteralSource(literal)}`;
|
||||
}
|
||||
if (required.has(key)) {
|
||||
return ` self.${propName} = try container.decode(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
|
||||
}
|
||||
return ` self.${propName} = try container.decodeIfPresent(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
|
||||
})
|
||||
.join("\n") +
|
||||
"\n }\n\n" +
|
||||
" public func encode(to encoder: Encoder) throws {\n" +
|
||||
" var container = encoder.container(keyedBy: CodingKeys.self)\n" +
|
||||
Object.entries(props)
|
||||
.map(([key]) => {
|
||||
const propName = safeName(key);
|
||||
const literal = literalPropByKey.get(key);
|
||||
if (literal !== undefined) {
|
||||
return ` try container.encode(${swiftLiteralSource(literal)}, forKey: .${propName})`;
|
||||
}
|
||||
if (required.has(key)) {
|
||||
return ` try container.encode(${propName}, forKey: .${propName})`;
|
||||
}
|
||||
return ` try container.encodeIfPresent(${propName}, forKey: .${propName})`;
|
||||
})
|
||||
.join("\n") +
|
||||
"\n }\n}",
|
||||
);
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
lines.push(`public struct ${name}: Codable, Sendable {`);
|
||||
const codingKeys: string[] = [];
|
||||
for (const [key, propSchema] of Object.entries(props)) {
|
||||
@@ -214,6 +324,131 @@ function emitStruct(name: string, schema: JsonSchema): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function literalSchemaValue(schema: JsonSchema): boolean | number | string | null | undefined {
|
||||
if ("const" in schema) {
|
||||
return schema.const;
|
||||
}
|
||||
if (schema.enum?.length === 1) {
|
||||
return schema.enum[0] ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function swiftLiteralTypeName(value: boolean | number | string | null): string {
|
||||
if (typeof value === "boolean") {
|
||||
return "Bool";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? "Int" : "Double";
|
||||
}
|
||||
if (value === null) {
|
||||
return "AnyCodable";
|
||||
}
|
||||
return "String";
|
||||
}
|
||||
|
||||
function swiftLiteralSource(value: boolean | number | string | null): string {
|
||||
if (typeof value === "string") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (value === null) {
|
||||
return "AnyCodable(nil)";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function swiftUnionCaseName(value: boolean | number | string | null, fallback: string): string {
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "success" : "failure";
|
||||
}
|
||||
if (value === null) {
|
||||
return fallback;
|
||||
}
|
||||
return safeName(String(value));
|
||||
}
|
||||
|
||||
function emitDiscriminatedUnion(name: string, schema: JsonSchema): string | undefined {
|
||||
const branches = schema.oneOf ?? schema.anyOf;
|
||||
if (!branches || branches.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
const objectBranches = branches.filter((branch) => branch.type === "object");
|
||||
if (objectBranches.length !== branches.length) {
|
||||
return undefined;
|
||||
}
|
||||
const discriminatorCandidates = Object.keys(objectBranches[0]?.properties ?? {});
|
||||
for (const discriminator of discriminatorCandidates) {
|
||||
const cases = objectBranches.map((branch, index) => {
|
||||
const discriminatorSchema = branch.properties?.[discriminator];
|
||||
const literal = discriminatorSchema ? literalSchemaValue(discriminatorSchema) : undefined;
|
||||
const branchName = namedSchema(branch, true);
|
||||
if (literal === undefined || !branchName) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
branchName,
|
||||
caseName: swiftUnionCaseName(literal, `case${index + 1}`),
|
||||
literal,
|
||||
};
|
||||
});
|
||||
if (cases.some((entry) => !entry)) {
|
||||
continue;
|
||||
}
|
||||
const resolvedCases: Array<{
|
||||
branchName: string;
|
||||
caseName: string;
|
||||
literal: boolean | number | string | null;
|
||||
}> = cases;
|
||||
const [firstCase] = resolvedCases;
|
||||
if (!firstCase) {
|
||||
continue;
|
||||
}
|
||||
const literalType = swiftLiteralTypeName(firstCase.literal);
|
||||
if (
|
||||
resolvedCases.some((entry) => swiftLiteralTypeName(entry.literal) !== literalType) ||
|
||||
new Set(resolvedCases.map((entry) => String(entry.literal))).size !== resolvedCases.length
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return [
|
||||
`public enum ${name}: Codable, Sendable {`,
|
||||
...resolvedCases.map((entry) => ` case ${entry.caseName}(${entry.branchName})`),
|
||||
"",
|
||||
" private enum CodingKeys: String, CodingKey {",
|
||||
` case discriminator = "${discriminator}"`,
|
||||
" }",
|
||||
"",
|
||||
" public init(from decoder: Decoder) throws {",
|
||||
" let container = try decoder.container(keyedBy: CodingKeys.self)",
|
||||
` let discriminator = try container.decode(${literalType}.self, forKey: .discriminator)`,
|
||||
" switch discriminator {",
|
||||
...resolvedCases.map(
|
||||
(entry) =>
|
||||
` case ${swiftLiteralSource(entry.literal)}: self = try .${entry.caseName}(${entry.branchName}(from: decoder))`,
|
||||
),
|
||||
" default:",
|
||||
" throw DecodingError.dataCorruptedError(",
|
||||
" forKey: .discriminator,",
|
||||
" in: container,",
|
||||
` debugDescription: "Unknown ${name} discriminator value"`,
|
||||
" )",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" public func encode(to encoder: Encoder) throws {",
|
||||
" switch self {",
|
||||
...resolvedCases.map(
|
||||
(entry) => ` case .${entry.caseName}(let value): try value.encode(to: encoder)`,
|
||||
),
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function emitGatewayFrame(): string {
|
||||
const cases = ["req", "res", "event"];
|
||||
const associated: Record<string, string> = {
|
||||
@@ -298,6 +533,16 @@ async function generate() {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, schema] of definitions) {
|
||||
if (name === "GatewayFrame") {
|
||||
continue;
|
||||
}
|
||||
const union = emitDiscriminatedUnion(name, schema);
|
||||
if (union) {
|
||||
parts.push(union);
|
||||
}
|
||||
}
|
||||
|
||||
// Frame enum must come after payload structs
|
||||
parts.push(emitGatewayFrame());
|
||||
|
||||
|
||||
@@ -2172,9 +2172,9 @@ describe("AcpSessionManager", () => {
|
||||
});
|
||||
|
||||
const internals = manager as unknown as {
|
||||
actorTailBySession: Map<string, Promise<void>>;
|
||||
actorQueue: { getTailMapForTesting(): Map<string, Promise<void>> };
|
||||
};
|
||||
expect(internals.actorTailBySession.size).toBe(0);
|
||||
expect(internals.actorQueue.getTailMapForTesting().size).toBe(0);
|
||||
});
|
||||
|
||||
it("surfaces backend failures raised after a done event", async () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -154,7 +154,7 @@ export async function callGatewayTool<T = Record<string, unknown>>(
|
||||
const gateway = resolveGatewayOptions(opts);
|
||||
const scopes = Array.isArray(extra?.scopes)
|
||||
? extra.scopes
|
||||
: resolveLeastPrivilegeOperatorScopesForMethod(method);
|
||||
: resolveLeastPrivilegeOperatorScopesForMethod(method, params);
|
||||
return await callGateway<T>({
|
||||
url: gateway.url,
|
||||
token: gateway.token,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SessionEntry } from "./types.js";
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
store: {} as Record<string, SessionEntry>,
|
||||
stores: {} as Record<string, Record<string, SessionEntry>>,
|
||||
}));
|
||||
|
||||
vi.mock("../io.js", () => ({
|
||||
@@ -12,11 +13,20 @@ vi.mock("../io.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./paths.js", () => ({
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
resolveStorePath: (_store?: string, opts?: { agentId?: string }) =>
|
||||
opts?.agentId === "worker" ? "/tmp/worker-sessions.json" : "/tmp/sessions.json",
|
||||
}));
|
||||
|
||||
vi.mock("./store.js", () => ({
|
||||
loadSessionStore: () => storeState.store,
|
||||
loadSessionStore: (storePath: string) => storeState.stores[storePath] ?? storeState.store,
|
||||
}));
|
||||
|
||||
vi.mock("./targets.js", () => ({
|
||||
resolveAllAgentSessionStoreTargetsSync: () => [
|
||||
{ agentId: "main", storePath: "/tmp/sessions.json" },
|
||||
{ agentId: "shadow", storePath: "/tmp/shadow-sessions.json" },
|
||||
{ agentId: "worker", storePath: "/tmp/worker-sessions.json" },
|
||||
],
|
||||
}));
|
||||
|
||||
let extractDeliveryInfo: typeof import("./delivery-info.js").extractDeliveryInfo;
|
||||
@@ -35,6 +45,7 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createSessionConversationTestRegistry());
|
||||
storeState.store = {};
|
||||
storeState.stores = {};
|
||||
});
|
||||
|
||||
describe("extractDeliveryInfo", () => {
|
||||
@@ -94,6 +105,35 @@ describe("extractDeliveryInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not build the normalized index when an exact routable key is present", () => {
|
||||
const sessionKey = "agent:main:webchat:dm:user-123";
|
||||
storeState.store = new Proxy(
|
||||
{
|
||||
[sessionKey]: buildEntry({
|
||||
channel: "webchat",
|
||||
to: "webchat:user-123",
|
||||
accountId: "default",
|
||||
}),
|
||||
},
|
||||
{
|
||||
ownKeys() {
|
||||
throw new Error("normalized index should not be built");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = extractDeliveryInfo(sessionKey);
|
||||
|
||||
expect(result).toEqual({
|
||||
deliveryContext: {
|
||||
channel: "webchat",
|
||||
to: "webchat:user-123",
|
||||
accountId: "default",
|
||||
},
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to base sessions for :thread: keys", () => {
|
||||
const baseKey = "agent:main:slack:channel:C0123ABC";
|
||||
const threadKey = `${baseKey}:thread:1234567890.123456`;
|
||||
@@ -115,6 +155,57 @@ describe("extractDeliveryInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("looks up deliveryContext in per-agent session stores", () => {
|
||||
const sessionKey = "agent:worker:webchat:dm:user-456";
|
||||
storeState.stores["/tmp/sessions.json"] = {};
|
||||
storeState.stores["/tmp/worker-sessions.json"] = {
|
||||
[sessionKey]: buildEntry({
|
||||
channel: "webchat",
|
||||
to: "webchat:user-456",
|
||||
accountId: "worker-account",
|
||||
}),
|
||||
};
|
||||
|
||||
const result = extractDeliveryInfo(sessionKey);
|
||||
|
||||
expect(result).toEqual({
|
||||
deliveryContext: {
|
||||
channel: "webchat",
|
||||
to: "webchat:user-456",
|
||||
accountId: "worker-account",
|
||||
},
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("continues across per-agent stores until it finds a routable deliveryContext", () => {
|
||||
const sessionKey = "agent:shadow:webchat:dm:user-789";
|
||||
storeState.stores["/tmp/sessions.json"] = {
|
||||
[sessionKey]: {
|
||||
sessionId: "stale-shadow",
|
||||
updatedAt: Date.now() - 1000,
|
||||
},
|
||||
};
|
||||
storeState.stores["/tmp/shadow-sessions.json"] = {
|
||||
[sessionKey]: buildEntry({
|
||||
channel: "webchat",
|
||||
to: "webchat:user-789",
|
||||
accountId: "shadow-account",
|
||||
}),
|
||||
};
|
||||
|
||||
const result = extractDeliveryInfo(sessionKey);
|
||||
|
||||
expect(result).toEqual({
|
||||
deliveryContext: {
|
||||
channel: "webchat",
|
||||
to: "webchat:user-789",
|
||||
accountId: "shadow-account",
|
||||
},
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to base sessions for :topic: keys", () => {
|
||||
const baseKey = "agent:main:telegram:group:98765";
|
||||
const topicKey = `${baseKey}:topic:55`;
|
||||
@@ -156,7 +247,7 @@ describe("extractDeliveryInfo", () => {
|
||||
channel: "telegram",
|
||||
to: "group:98765",
|
||||
accountId: "main",
|
||||
threadId: "77",
|
||||
threadId: 77,
|
||||
},
|
||||
threadId: undefined,
|
||||
});
|
||||
@@ -186,6 +277,135 @@ describe("extractDeliveryInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("continues candidate session keys until it finds the freshest routable entry", () => {
|
||||
const sessionKey = "agent:main:matrix:channel:!MixedCase:Example.Org";
|
||||
const canonicalKey = "agent:main:matrix:channel:!mixedcase:example.org";
|
||||
storeState.store[sessionKey] = {
|
||||
sessionId: "stale-session",
|
||||
updatedAt: Date.now() - 1000,
|
||||
origin: {
|
||||
provider: "matrix",
|
||||
},
|
||||
};
|
||||
storeState.store[canonicalKey] = {
|
||||
sessionId: "fresh-session",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "matrix",
|
||||
lastTo: "room:!MixedCase:Example.Org",
|
||||
};
|
||||
|
||||
const result = extractDeliveryInfo(sessionKey);
|
||||
|
||||
expect(result).toEqual({
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!MixedCase:Example.Org",
|
||||
accountId: undefined,
|
||||
},
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an older routable direct entry over a fresher normalized alias without a route", () => {
|
||||
const sessionKey = "agent:main:matrix:channel:!MixedCase:Example.Org";
|
||||
const canonicalKey = "agent:main:matrix:channel:!mixedcase:example.org";
|
||||
storeState.store[sessionKey] = {
|
||||
sessionId: "direct-routable-session",
|
||||
updatedAt: Date.now() - 1_000,
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!MixedCase:Example.Org",
|
||||
accountId: "matrix-account",
|
||||
},
|
||||
};
|
||||
storeState.store[canonicalKey] = {
|
||||
sessionId: "fresh-normalized-session",
|
||||
updatedAt: Date.now(),
|
||||
origin: {
|
||||
provider: "matrix",
|
||||
},
|
||||
};
|
||||
|
||||
const result = extractDeliveryInfo(sessionKey);
|
||||
|
||||
expect(result).toEqual({
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!MixedCase:Example.Org",
|
||||
accountId: "matrix-account",
|
||||
},
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an older routable normalized alias over a fresher non-routable alias", () => {
|
||||
const queriedKey = "agent:main:matrix:channel:!MiXeDCase:Example.Org";
|
||||
const routableAlias = "agent:main:matrix:channel:!MixedCase:Example.Org";
|
||||
const canonicalKey = "agent:main:matrix:channel:!mixedcase:example.org";
|
||||
storeState.store[canonicalKey] = {
|
||||
sessionId: "fresh-normalized-session",
|
||||
updatedAt: Date.now(),
|
||||
origin: {
|
||||
provider: "matrix",
|
||||
},
|
||||
};
|
||||
storeState.store[routableAlias] = {
|
||||
sessionId: "older-routable-session",
|
||||
updatedAt: Date.now() - 1_000,
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!MixedCase:Example.Org",
|
||||
accountId: "matrix-account",
|
||||
},
|
||||
};
|
||||
|
||||
const result = extractDeliveryInfo(queriedKey);
|
||||
|
||||
expect(result).toEqual({
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!MixedCase:Example.Org",
|
||||
accountId: "matrix-account",
|
||||
},
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the freshest routable alias even when the normalized key is already routable", () => {
|
||||
const queriedKey = "agent:main:matrix:channel:!MiXeDCase:Example.Org";
|
||||
const canonicalKey = "agent:main:matrix:channel:!mixedcase:example.org";
|
||||
const fresherAlias = "agent:main:matrix:channel:!MixedCase:Example.Org";
|
||||
storeState.store[canonicalKey] = {
|
||||
sessionId: "older-canonical-session",
|
||||
updatedAt: Date.now() - 1_000,
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!mixedcase:example.org",
|
||||
accountId: "matrix-account",
|
||||
},
|
||||
};
|
||||
storeState.store[fresherAlias] = {
|
||||
sessionId: "fresh-alias-session",
|
||||
updatedAt: Date.now(),
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!MixedCase:Example.Org",
|
||||
accountId: "matrix-account",
|
||||
},
|
||||
};
|
||||
|
||||
const result = extractDeliveryInfo(queriedKey);
|
||||
|
||||
expect(result).toEqual({
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!MixedCase:Example.Org",
|
||||
accountId: "matrix-account",
|
||||
},
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the base session when a thread entry only has partial route metadata", () => {
|
||||
const baseKey = "agent:main:matrix:channel:!MixedCase:example.org";
|
||||
const threadKey = `${baseKey}:thread:$thread-event`;
|
||||
|
||||
@@ -1,43 +1,56 @@
|
||||
import {
|
||||
resolveSessionStoreAgentId,
|
||||
resolveSessionStoreKey,
|
||||
} from "../../gateway/session-store-key.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
|
||||
import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js";
|
||||
import { getRuntimeConfig } from "../io.js";
|
||||
import type { OpenClawConfig } from "../types.openclaw.js";
|
||||
import { resolveStorePath } from "./paths.js";
|
||||
import { loadSessionStore } from "./store.js";
|
||||
export { parseSessionThreadInfo } from "./thread-info.js";
|
||||
import { resolveAllAgentSessionStoreTargetsSync } from "./targets.js";
|
||||
import { parseSessionThreadInfo } from "./thread-info.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
export { parseSessionThreadInfo };
|
||||
|
||||
export function extractDeliveryInfo(sessionKey: string | undefined): {
|
||||
function hasRoutableDeliveryContext(context?: {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
}): context is {
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
} {
|
||||
return Boolean(context?.channel && context?.to);
|
||||
}
|
||||
|
||||
export function extractDeliveryInfo(
|
||||
sessionKey: string | undefined,
|
||||
options?: { cfg?: OpenClawConfig },
|
||||
): {
|
||||
deliveryContext:
|
||||
| { channel?: string; to?: string; accountId?: string; threadId?: string }
|
||||
| { channel?: string; to?: string; accountId?: string; threadId?: string | number }
|
||||
| undefined;
|
||||
threadId: string | undefined;
|
||||
} {
|
||||
const hasRoutableDeliveryContext = (context?: {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
}): context is {
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
} => Boolean(context?.channel && context?.to);
|
||||
const { baseSessionKey, threadId } = parseSessionThreadInfo(sessionKey);
|
||||
if (!sessionKey || !baseSessionKey) {
|
||||
return { deliveryContext: undefined, threadId };
|
||||
}
|
||||
|
||||
let deliveryContext:
|
||||
| { channel?: string; to?: string; accountId?: string; threadId?: string }
|
||||
| { channel?: string; to?: string; accountId?: string; threadId?: string | number }
|
||||
| undefined;
|
||||
try {
|
||||
const cfg = getRuntimeConfig();
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
let entry = store[sessionKey];
|
||||
const cfg = options?.cfg ?? getRuntimeConfig();
|
||||
const lookup = loadDeliverySessionEntry({ cfg, sessionKey, baseSessionKey });
|
||||
let entry = lookup.entry;
|
||||
let storedDeliveryContext = deliveryContextFromSession(entry);
|
||||
if (!hasRoutableDeliveryContext(storedDeliveryContext) && baseSessionKey !== sessionKey) {
|
||||
entry = store[baseSessionKey];
|
||||
entry = lookup.baseEntry;
|
||||
storedDeliveryContext = deliveryContextFromSession(entry);
|
||||
}
|
||||
if (hasRoutableDeliveryContext(storedDeliveryContext)) {
|
||||
@@ -45,10 +58,7 @@ export function extractDeliveryInfo(sessionKey: string | undefined): {
|
||||
channel: storedDeliveryContext.channel,
|
||||
to: storedDeliveryContext.to,
|
||||
accountId: storedDeliveryContext.accountId,
|
||||
threadId:
|
||||
storedDeliveryContext.threadId != null
|
||||
? String(storedDeliveryContext.threadId)
|
||||
: undefined,
|
||||
threadId: storedDeliveryContext.threadId,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -56,3 +66,123 @@ export function extractDeliveryInfo(sessionKey: string | undefined): {
|
||||
}
|
||||
return { deliveryContext, threadId };
|
||||
}
|
||||
|
||||
function resolveDeliveryStorePaths(cfg: OpenClawConfig, agentId: string): string[] {
|
||||
const paths = new Set<string>();
|
||||
paths.add(resolveStorePath(cfg.session?.store, { agentId }));
|
||||
for (const target of resolveAllAgentSessionStoreTargetsSync(cfg)) {
|
||||
if (target.agentId === agentId) {
|
||||
paths.add(target.storePath);
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
function findSessionEntryInStore(
|
||||
store: ReturnType<typeof loadSessionStore>,
|
||||
keys: readonly string[],
|
||||
) {
|
||||
let normalizedIndex: Map<string, SessionEntry> | undefined;
|
||||
let bestEntry: SessionEntry | undefined;
|
||||
let bestUpdatedAt = 0;
|
||||
let bestRoutable = false;
|
||||
const acceptCandidate = (candidate: SessionEntry | undefined) => {
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
const candidateRoutable = hasRoutableDeliveryContext(deliveryContextFromSession(candidate));
|
||||
const candidateUpdatedAt = candidate.updatedAt ?? 0;
|
||||
if (
|
||||
!bestEntry ||
|
||||
(candidateRoutable && !bestRoutable) ||
|
||||
(candidateRoutable === bestRoutable && candidateUpdatedAt > bestUpdatedAt)
|
||||
) {
|
||||
bestEntry = candidate;
|
||||
bestUpdatedAt = candidateUpdatedAt;
|
||||
bestRoutable = candidateRoutable;
|
||||
}
|
||||
};
|
||||
for (const key of keys) {
|
||||
const trimmed = key.trim();
|
||||
const normalized = normalizeLowercaseStringOrEmpty(key);
|
||||
let foundRoutableCandidate = false;
|
||||
if (Object.prototype.hasOwnProperty.call(store, normalized)) {
|
||||
foundRoutableCandidate ||= hasRoutableDeliveryContext(
|
||||
deliveryContextFromSession(store[normalized]),
|
||||
);
|
||||
acceptCandidate(store[normalized]);
|
||||
}
|
||||
if (trimmed !== normalized && Object.prototype.hasOwnProperty.call(store, trimmed)) {
|
||||
foundRoutableCandidate ||= hasRoutableDeliveryContext(
|
||||
deliveryContextFromSession(store[trimmed]),
|
||||
);
|
||||
acceptCandidate(store[trimmed]);
|
||||
}
|
||||
if (trimmed !== normalized || !foundRoutableCandidate) {
|
||||
normalizedIndex ??= buildFreshestSessionEntryIndex(store);
|
||||
const freshest = normalizedIndex.get(normalized);
|
||||
acceptCandidate(freshest);
|
||||
}
|
||||
}
|
||||
return bestEntry;
|
||||
}
|
||||
|
||||
function buildFreshestSessionEntryIndex(
|
||||
store: Record<string, SessionEntry>,
|
||||
): Map<string, SessionEntry> {
|
||||
const index = new Map<string, SessionEntry>();
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(key);
|
||||
const existing = index.get(normalized);
|
||||
const entryRoutable = hasRoutableDeliveryContext(deliveryContextFromSession(entry));
|
||||
const existingRoutable = hasRoutableDeliveryContext(deliveryContextFromSession(existing));
|
||||
if (
|
||||
!existing ||
|
||||
(entryRoutable && !existingRoutable) ||
|
||||
(entryRoutable === existingRoutable && (entry.updatedAt ?? 0) > (existing.updatedAt ?? 0))
|
||||
) {
|
||||
index.set(normalized, entry);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function loadDeliverySessionEntry(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
baseSessionKey: string;
|
||||
}) {
|
||||
const canonicalKey = resolveSessionStoreKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
const canonicalBaseKey = resolveSessionStoreKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.baseSessionKey,
|
||||
});
|
||||
const agentId = resolveSessionStoreAgentId(params.cfg, canonicalKey);
|
||||
const sessionKeys = [params.sessionKey, canonicalKey];
|
||||
const baseKeys = [params.baseSessionKey, canonicalBaseKey];
|
||||
let fallback:
|
||||
| {
|
||||
entry: ReturnType<typeof findSessionEntryInStore>;
|
||||
baseEntry: ReturnType<typeof findSessionEntryInStore>;
|
||||
}
|
||||
| undefined;
|
||||
for (const storePath of resolveDeliveryStorePaths(params.cfg, agentId)) {
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = findSessionEntryInStore(store, sessionKeys);
|
||||
const baseEntry = findSessionEntryInStore(store, baseKeys);
|
||||
if (!entry && !baseEntry) {
|
||||
continue;
|
||||
}
|
||||
fallback ??= { entry, baseEntry };
|
||||
if (
|
||||
hasRoutableDeliveryContext(deliveryContextFromSession(entry)) ||
|
||||
hasRoutableDeliveryContext(deliveryContextFromSession(baseEntry))
|
||||
) {
|
||||
return { entry, baseEntry };
|
||||
}
|
||||
}
|
||||
return fallback ?? { entry: undefined, baseEntry: undefined };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -779,7 +779,7 @@ export async function callGatewayCli<T = Record<string, unknown>>(
|
||||
const scopes = Array.isArray(opts.scopes)
|
||||
? opts.scopes
|
||||
: isGatewayMethodClassified(opts.method)
|
||||
? resolveLeastPrivilegeOperatorScopesForMethod(opts.method)
|
||||
? resolveLeastPrivilegeOperatorScopesForMethod(opts.method, opts.params)
|
||||
: CLI_DEFAULT_OPERATOR_SCOPES;
|
||||
return await callGatewayWithScopes(opts, scopes);
|
||||
}
|
||||
@@ -787,7 +787,7 @@ export async function callGatewayCli<T = Record<string, unknown>>(
|
||||
export async function callGatewayLeastPrivilege<T = Record<string, unknown>>(
|
||||
opts: CallGatewayBaseOptions,
|
||||
): Promise<T> {
|
||||
const scopes = resolveLeastPrivilegeOperatorScopesForMethod(opts.method);
|
||||
const scopes = resolveLeastPrivilegeOperatorScopesForMethod(opts.method, opts.params);
|
||||
return await callGatewayWithScopes(opts, scopes);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
[],
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
READ_SCOPE,
|
||||
TALK_SECRETS_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
isOperatorScope,
|
||||
type OperatorScope,
|
||||
} from "./operator-scopes.js";
|
||||
|
||||
@@ -39,6 +40,8 @@ const NODE_ROLE_METHODS = new Set([
|
||||
"skills.bins",
|
||||
]);
|
||||
|
||||
const DYNAMIC_OPERATOR_SCOPE_METHODS = new Set(["plugins.sessionAction"]);
|
||||
|
||||
const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
[APPROVALS_SCOPE]: [
|
||||
"exec.approval.get",
|
||||
@@ -270,7 +273,65 @@ export function resolveRequiredOperatorScopeForMethod(method: string): OperatorS
|
||||
return resolveScopedMethod(method);
|
||||
}
|
||||
|
||||
export function resolveLeastPrivilegeOperatorScopesForMethod(method: string): OperatorScope[] {
|
||||
function normalizeSessionActionParam(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function resolveSessionActionRegisteredScopes(params: unknown): OperatorScope[] | undefined {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
return undefined;
|
||||
}
|
||||
const pluginId = normalizeSessionActionParam((params as { pluginId?: unknown }).pluginId);
|
||||
const actionId = normalizeSessionActionParam((params as { actionId?: unknown }).actionId);
|
||||
if (!pluginId || !actionId) {
|
||||
return undefined;
|
||||
}
|
||||
const registration = getPluginRegistryState()?.activeRegistry?.sessionActions?.find(
|
||||
(entry) => entry.pluginId === pluginId && entry.action.id === actionId,
|
||||
);
|
||||
if (!registration) {
|
||||
return undefined;
|
||||
}
|
||||
const requiredScopes = registration.action.requiredScopes;
|
||||
return requiredScopes && requiredScopes.length > 0 ? [...requiredScopes] : [WRITE_SCOPE];
|
||||
}
|
||||
|
||||
function resolveSessionActionLeastPrivilegeScopes(params: unknown): OperatorScope[] {
|
||||
const registeredScopes = resolveSessionActionRegisteredScopes(params);
|
||||
if (registeredScopes) {
|
||||
return registeredScopes;
|
||||
}
|
||||
if (params && typeof params === "object" && !Array.isArray(params)) {
|
||||
const pluginId = normalizeSessionActionParam((params as { pluginId?: unknown }).pluginId);
|
||||
const actionId = normalizeSessionActionParam((params as { actionId?: unknown }).actionId);
|
||||
if (pluginId && actionId) {
|
||||
// A standalone CLI/tool caller may be talking to a gateway whose live
|
||||
// plugin registry is not present in this local process. Avoid under-scoping
|
||||
// valid dynamic actions when we cannot determine the exact requirement
|
||||
// locally.
|
||||
return [...CLI_DEFAULT_OPERATOR_SCOPES];
|
||||
}
|
||||
}
|
||||
return [WRITE_SCOPE];
|
||||
}
|
||||
|
||||
function resolveDynamicLeastPrivilegeOperatorScopesForMethod(
|
||||
method: string,
|
||||
params: unknown,
|
||||
): OperatorScope[] {
|
||||
if (method === "plugins.sessionAction") {
|
||||
return resolveSessionActionLeastPrivilegeScopes(params);
|
||||
}
|
||||
return [WRITE_SCOPE];
|
||||
}
|
||||
|
||||
export function resolveLeastPrivilegeOperatorScopesForMethod(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
): OperatorScope[] {
|
||||
if (DYNAMIC_OPERATOR_SCOPE_METHODS.has(method)) {
|
||||
return resolveDynamicLeastPrivilegeOperatorScopesForMethod(method, params);
|
||||
}
|
||||
const requiredScope = resolveRequiredOperatorScopeForMethod(method);
|
||||
if (requiredScope) {
|
||||
return [requiredScope];
|
||||
@@ -282,10 +343,28 @@ export function resolveLeastPrivilegeOperatorScopesForMethod(method: string): Op
|
||||
export function authorizeOperatorScopesForMethod(
|
||||
method: string,
|
||||
scopes: readonly string[],
|
||||
params?: unknown,
|
||||
): { allowed: true } | { allowed: false; missingScope: OperatorScope } {
|
||||
if (scopes.includes(ADMIN_SCOPE)) {
|
||||
return { allowed: true };
|
||||
}
|
||||
if (DYNAMIC_OPERATOR_SCOPE_METHODS.has(method)) {
|
||||
const registeredScopes = resolveSessionActionRegisteredScopes(params);
|
||||
if (!registeredScopes && params && typeof params === "object" && !Array.isArray(params)) {
|
||||
const pluginId = normalizeSessionActionParam((params as { pluginId?: unknown }).pluginId);
|
||||
const actionId = normalizeSessionActionParam((params as { actionId?: unknown }).actionId);
|
||||
if (!pluginId || !actionId) {
|
||||
return scopes.some((scope) => isOperatorScope(scope))
|
||||
? { allowed: true }
|
||||
: { allowed: false, missingScope: WRITE_SCOPE };
|
||||
}
|
||||
}
|
||||
const requiredScopes = registeredScopes ?? [WRITE_SCOPE];
|
||||
const missingScope = requiredScopes.find((scope) => {
|
||||
return !scopes.includes(scope) && !(scope === READ_SCOPE && scopes.includes(WRITE_SCOPE));
|
||||
});
|
||||
return missingScope ? { allowed: false, missingScope } : { allowed: true };
|
||||
}
|
||||
const requiredScope = resolveRequiredOperatorScopeForMethod(method) ?? ADMIN_SCOPE;
|
||||
if (requiredScope === READ_SCOPE) {
|
||||
if (scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE)) {
|
||||
@@ -303,5 +382,8 @@ export function isGatewayMethodClassified(method: string): boolean {
|
||||
if (isNodeRoleMethod(method)) {
|
||||
return true;
|
||||
}
|
||||
if (DYNAMIC_OPERATOR_SCOPE_METHODS.has(method)) {
|
||||
return true;
|
||||
}
|
||||
return resolveRequiredOperatorScopeForMethod(method) !== undefined;
|
||||
}
|
||||
|
||||
@@ -192,6 +192,10 @@ import {
|
||||
PluginApprovalRequestParamsSchema,
|
||||
type PluginApprovalResolveParams,
|
||||
PluginApprovalResolveParamsSchema,
|
||||
type PluginsSessionActionParams,
|
||||
type PluginsSessionActionResult,
|
||||
PluginsSessionActionParamsSchema,
|
||||
PluginsSessionActionResultSchema,
|
||||
type PluginsUiDescriptorsParams,
|
||||
PluginsUiDescriptorsParamsSchema,
|
||||
ErrorCodes,
|
||||
@@ -714,6 +718,12 @@ export const validatePluginApprovalResolveParams = ajv.compile<PluginApprovalRes
|
||||
export const validatePluginsUiDescriptorsParams = ajv.compile<PluginsUiDescriptorsParams>(
|
||||
PluginsUiDescriptorsParamsSchema,
|
||||
);
|
||||
export const validatePluginsSessionActionParams = ajv.compile<PluginsSessionActionParams>(
|
||||
PluginsSessionActionParamsSchema,
|
||||
);
|
||||
export const validatePluginsSessionActionResult = ajv.compile<PluginsSessionActionResult>(
|
||||
PluginsSessionActionResultSchema,
|
||||
);
|
||||
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
);
|
||||
@@ -910,6 +920,8 @@ export {
|
||||
AgentsListResultSchema,
|
||||
CommandsListParamsSchema,
|
||||
CommandsListResultSchema,
|
||||
PluginsSessionActionParamsSchema,
|
||||
PluginsSessionActionResultSchema,
|
||||
PluginsUiDescriptorsParamsSchema,
|
||||
ModelsListParamsSchema,
|
||||
SkillsStatusParamsSchema,
|
||||
@@ -1049,6 +1061,8 @@ export type {
|
||||
CommandsListParams,
|
||||
CommandsListResult,
|
||||
CommandEntry,
|
||||
PluginsSessionActionParams,
|
||||
PluginsSessionActionResult,
|
||||
SkillsStatusParams,
|
||||
ToolsCatalogParams,
|
||||
ToolsCatalogResult,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -52,6 +52,7 @@ const BASE_METHODS = [
|
||||
"plugin.approval.waitDecision",
|
||||
"plugin.approval.resolve",
|
||||
"plugins.uiDescriptors",
|
||||
"plugins.sessionAction",
|
||||
"wizard.start",
|
||||
"wizard.next",
|
||||
"wizard.cancel",
|
||||
|
||||
@@ -56,7 +56,11 @@ const CONTROL_PLANE_WRITE_METHODS = new Set([
|
||||
"gateway.restart.request",
|
||||
"update.run",
|
||||
]);
|
||||
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
|
||||
function authorizeGatewayMethod(
|
||||
method: string,
|
||||
client: GatewayRequestOptions["client"],
|
||||
params: unknown,
|
||||
) {
|
||||
if (!client?.connect) {
|
||||
return null;
|
||||
}
|
||||
@@ -78,7 +82,7 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
|
||||
if (scopes.includes(ADMIN_SCOPE)) {
|
||||
return null;
|
||||
}
|
||||
const scopeAuth = authorizeOperatorScopesForMethod(method, scopes);
|
||||
const scopeAuth = authorizeOperatorScopesForMethod(method, scopes, params);
|
||||
if (!scopeAuth.allowed) {
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${scopeAuth.missingScope}`);
|
||||
}
|
||||
@@ -132,7 +136,7 @@ export async function handleGatewayRequest(
|
||||
opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers },
|
||||
): Promise<void> {
|
||||
const { req, respond, client, isWebchatConnect, context } = opts;
|
||||
const authError = authorizeGatewayMethod(req.method, client);
|
||||
const authError = authorizeGatewayMethod(req.method, client, req.params);
|
||||
if (authError) {
|
||||
respond(false, undefined, authError);
|
||||
return;
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { isPluginJsonValue } from "../../plugins/host-hooks.js";
|
||||
import { getActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import {
|
||||
validateJsonSchemaValue,
|
||||
type JsonSchemaValidationError,
|
||||
type JsonSchemaValue,
|
||||
} from "../../plugins/schema-validator.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import { ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../operator-scopes.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validatePluginsSessionActionParams,
|
||||
validatePluginsSessionActionResult,
|
||||
validatePluginsUiDescriptorsParams,
|
||||
} from "../protocol/index.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
const log = createSubsystemLogger("gateway/plugin-host-hooks");
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function formatSessionActionPayloadSchemaErrors(errors: JsonSchemaValidationError[]): string {
|
||||
return errors.map((error) => error.text).join("; ");
|
||||
}
|
||||
|
||||
function validatePluginSessionActionJsonFields(
|
||||
result: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
for (const field of ["result", "reply", "details"] as const) {
|
||||
if (result[field] !== undefined && !isPluginJsonValue(result[field])) {
|
||||
return `plugin session action ${field} must be JSON-compatible`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const pluginHostHookHandlers: GatewayRequestHandlers = {
|
||||
"plugins.uiDescriptors": ({ params, respond }) => {
|
||||
if (!validatePluginsUiDescriptorsParams(params)) {
|
||||
@@ -28,4 +61,182 @@ export const pluginHostHookHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
respond(true, { ok: true, descriptors }, undefined);
|
||||
},
|
||||
"plugins.sessionAction": async ({ params, client, respond }) => {
|
||||
if (!validatePluginsSessionActionParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid plugins.sessionAction params: ${formatValidationErrors(validatePluginsSessionActionParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pluginId = normalizeOptionalString(params.pluginId);
|
||||
const actionId = normalizeOptionalString(params.actionId);
|
||||
const sessionKey = normalizeOptionalString(params.sessionKey);
|
||||
if (!pluginId || !actionId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"plugins.sessionAction pluginId and actionId must be non-empty",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const registry = getActivePluginRegistry();
|
||||
const pluginLoaded = Boolean(
|
||||
registry?.plugins.some((plugin) => plugin.id === pluginId && plugin.status === "loaded"),
|
||||
);
|
||||
const registration = (registry?.sessionActions ?? []).find(
|
||||
(entry) => entry.pluginId === pluginId && entry.action.id === actionId,
|
||||
);
|
||||
if (!registration || !pluginLoaded) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
`unknown plugin session action: ${pluginId}/${actionId}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : [];
|
||||
const hasAdmin = scopes.includes(ADMIN_SCOPE);
|
||||
const requiredScopes =
|
||||
registration.action.requiredScopes && registration.action.requiredScopes.length > 0
|
||||
? registration.action.requiredScopes
|
||||
: [WRITE_SCOPE];
|
||||
const missingScope = requiredScopes.find(
|
||||
(scope) =>
|
||||
!hasAdmin &&
|
||||
!scopes.includes(scope) &&
|
||||
!(scope === READ_SCOPE && scopes.includes(WRITE_SCOPE)),
|
||||
);
|
||||
if (missingScope) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${missingScope}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (params.payload !== undefined && !isPluginJsonValue(params.payload)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"plugin session action payload must be JSON-compatible",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (registration.action.schema !== undefined) {
|
||||
if (
|
||||
typeof registration.action.schema !== "boolean" &&
|
||||
!isRecord(registration.action.schema)
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"plugin session action schema must be an object or boolean",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const validation = validateJsonSchemaValue({
|
||||
schema: registration.action.schema as JsonSchemaValue,
|
||||
cacheKey: `plugin-session-action:${pluginId}:${actionId}`,
|
||||
value: params.payload,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`plugin session action payload does not match schema: ${formatSessionActionPayloadSchemaErrors(validation.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = await registration.action.handler({
|
||||
pluginId,
|
||||
actionId,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(params.payload !== undefined ? { payload: params.payload } : {}),
|
||||
client: {
|
||||
...(client?.connId ? { connId: client.connId } : {}),
|
||||
scopes: [...scopes],
|
||||
},
|
||||
});
|
||||
if (result !== undefined && !isRecord(result)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "plugin session action result must be an object"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const wireResult = result?.ok === false ? result : { ok: true as const, ...result };
|
||||
if (!validatePluginsSessionActionResult(wireResult)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid plugin session action result: ${formatValidationErrors(validatePluginsSessionActionResult.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const jsonFieldError = result ? validatePluginSessionActionJsonFields(result) : undefined;
|
||||
if (jsonFieldError) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, jsonFieldError));
|
||||
return;
|
||||
}
|
||||
if (!wireResult.ok) {
|
||||
// Plugin-declared action failures are returned as a successful RPC
|
||||
// with `ok: false` per PluginsSessionActionResultSchema. Reserve
|
||||
// transport errorShape for protocol-level failures (validation,
|
||||
// schema mismatch, dispatch error). Distinguishing these in the
|
||||
// wire shape lets callers handle plugin failures (often retryable
|
||||
// or user-facing) differently from transport errors (operator
|
||||
// diagnostics).
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: false,
|
||||
error: wireResult.error,
|
||||
...(wireResult.code !== undefined ? { code: wireResult.code } : {}),
|
||||
...(wireResult.details !== undefined ? { details: wireResult.details } : {}),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, {
|
||||
ok: true,
|
||||
...(wireResult.result !== undefined ? { result: wireResult.result } : {}),
|
||||
...(wireResult.continueAgent !== undefined
|
||||
? { continueAgent: wireResult.continueAgent }
|
||||
: {}),
|
||||
...(wireResult.reply !== undefined ? { reply: wireResult.reply } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`plugin session action failed plugin=${pluginId} action=${actionId}: ${formatErrorMessage(error)}`,
|
||||
);
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "plugin session action failed"));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -774,6 +774,28 @@ describe("gateway send mirroring", () => {
|
||||
expect(deliveryCall()?.threadId).toBe("1710000000.9999");
|
||||
});
|
||||
|
||||
it("forwards gateway send delivery options to outbound delivery", async () => {
|
||||
mockDeliverySuccess("m-options");
|
||||
|
||||
await runSend({
|
||||
to: "channel:C1",
|
||||
message: "<b>report</b>",
|
||||
channel: "slack",
|
||||
forceDocument: true,
|
||||
silent: true,
|
||||
parseMode: "HTML",
|
||||
idempotencyKey: "idem-send-options",
|
||||
});
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
forceDocument: true,
|
||||
silent: true,
|
||||
formatting: { parseMode: "HTML" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates mirror session keys and delivery thread ids when Slack routing derives a thread", async () => {
|
||||
mockDeliverySuccess("m-thread-derived");
|
||||
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2143,6 +2143,49 @@ describe("deliverOutboundPayloads", () => {
|
||||
expect(chunker).toHaveBeenNthCalledWith(1, text, 4000);
|
||||
});
|
||||
|
||||
it("passes formatting overrides for pre-rendered chunker output", async () => {
|
||||
const chunker = vi.fn(() => ["<b>bold</b>"]);
|
||||
const sendText = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
|
||||
channel: "matrix" as const,
|
||||
messageId: text,
|
||||
roomId: "r1",
|
||||
}));
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "matrix",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker,
|
||||
chunkerMode: "markdown",
|
||||
chunkedTextFormatting: { parseMode: "HTML" },
|
||||
textChunkLimit: 4000,
|
||||
sendText,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg: matrixChunkConfig,
|
||||
channel: "matrix",
|
||||
to: "!room",
|
||||
payloads: [{ text: "**bold**" }],
|
||||
});
|
||||
|
||||
expect(chunker).toHaveBeenCalledWith("**bold**", 4000);
|
||||
expect(sendText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "<b>bold</b>",
|
||||
formatting: { parseMode: "HTML" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes config through for plugin media sends", async () => {
|
||||
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-media", roomId: "!room" });
|
||||
setActivePluginRegistry(
|
||||
|
||||
@@ -136,6 +136,7 @@ async function loadChannelBootstrapRuntime() {
|
||||
type ChannelHandler = {
|
||||
chunker: ChannelOutboundAdapter["chunker"] | null;
|
||||
chunkerMode?: "text" | "markdown";
|
||||
chunkedTextFormatting?: OutboundDeliveryFormattingOptions;
|
||||
textChunkLimit?: number;
|
||||
supportsMedia: boolean;
|
||||
sanitizeText?: (payload: ReplyPayload) => string;
|
||||
@@ -345,6 +346,7 @@ function createPluginHandler(
|
||||
replyToIdSource?: "explicit" | "implicit";
|
||||
threadId?: string | number | null;
|
||||
audioAsVoice?: boolean;
|
||||
formatting?: OutboundDeliveryFormattingOptions;
|
||||
}): Omit<ChannelOutboundContext, "text" | "mediaUrl"> => ({
|
||||
...baseCtx,
|
||||
replyToId: overrides && "replyToId" in overrides ? overrides.replyToId : baseCtx.replyToId,
|
||||
@@ -354,6 +356,10 @@ function createPluginHandler(
|
||||
: baseCtx.replyToIdSource,
|
||||
threadId: overrides && "threadId" in overrides ? overrides.threadId : baseCtx.threadId,
|
||||
audioAsVoice: overrides?.audioAsVoice,
|
||||
formatting:
|
||||
overrides && "formatting" in overrides
|
||||
? { ...baseCtx.formatting, ...overrides.formatting }
|
||||
: baseCtx.formatting,
|
||||
});
|
||||
const buildTargetRef = (overrides?: {
|
||||
threadId?: string | number | null;
|
||||
@@ -366,6 +372,7 @@ function createPluginHandler(
|
||||
return {
|
||||
chunker,
|
||||
chunkerMode,
|
||||
chunkedTextFormatting: outbound?.chunkedTextFormatting,
|
||||
textChunkLimit: outbound?.textChunkLimit,
|
||||
supportsMedia: Boolean(messageMedia ?? sendMedia),
|
||||
sanitizeText: outbound?.sanitizeText
|
||||
@@ -1391,6 +1398,7 @@ async function deliverOutboundPayloadsCore(
|
||||
overrides,
|
||||
chunker: handler.chunker,
|
||||
chunkerMode: handler.chunkerMode,
|
||||
chunkedTextFormatting: handler.chunkedTextFormatting,
|
||||
textLimit,
|
||||
chunkMode,
|
||||
formatting: params.formatting,
|
||||
|
||||
@@ -6,4 +6,5 @@ export type OutboundDeliveryFormattingOptions = {
|
||||
maxLinesPerMessage?: number;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
parseMode?: "HTML";
|
||||
};
|
||||
|
||||
@@ -85,4 +85,22 @@ describe("outbound message planning", () => {
|
||||
["media", undefined, "https://example.com/2.png", undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds formatting overrides only to chunked text units", () => {
|
||||
const units = planOutboundTextMessageUnits({
|
||||
text: "**bold**",
|
||||
textLimit: 4000,
|
||||
chunker: () => ["<b>bold</b>"],
|
||||
chunkedTextFormatting: { parseMode: "HTML" },
|
||||
overrides: {},
|
||||
});
|
||||
|
||||
expect(units).toEqual([
|
||||
{
|
||||
kind: "text",
|
||||
text: "<b>bold</b>",
|
||||
overrides: { formatting: { parseMode: "HTML" } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -91,6 +91,7 @@ type MessageSendParams = {
|
||||
mirror?: OutboundMirror;
|
||||
abortSignal?: AbortSignal;
|
||||
silent?: boolean;
|
||||
parseMode?: "HTML";
|
||||
};
|
||||
|
||||
export type MessageSendResult = {
|
||||
@@ -397,6 +398,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
||||
signal: params.abortSignal,
|
||||
silent: params.silent,
|
||||
mediaAccess: params.mediaAccess,
|
||||
formatting: params.parseMode ? { parseMode: params.parseMode } : undefined,
|
||||
mirror: params.mirror
|
||||
? {
|
||||
...params.mirror,
|
||||
@@ -435,6 +437,10 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
||||
agentId: params.agentId,
|
||||
channel,
|
||||
replyToId: params.replyToId,
|
||||
threadId: params.threadId != null ? String(params.threadId) : undefined,
|
||||
forceDocument: params.forceDocument,
|
||||
silent: params.silent,
|
||||
parseMode: params.parseMode,
|
||||
sessionKey: params.mirror?.sessionKey,
|
||||
idempotencyKey: await resolveGatewayIdempotencyKey(params.idempotencyKey),
|
||||
},
|
||||
|
||||
@@ -48,6 +48,8 @@ export type {
|
||||
OpenClawPluginServiceContext,
|
||||
PluginCommandContext,
|
||||
PluginCommandResult,
|
||||
PluginAgentEventEmitParams,
|
||||
PluginAgentEventEmitResult,
|
||||
PluginAgentEventSubscriptionRegistration,
|
||||
PluginAgentTurnPrepareEvent,
|
||||
PluginAgentTurnPrepareResult,
|
||||
@@ -61,8 +63,16 @@ export type {
|
||||
PluginRunContextGetParams,
|
||||
PluginRunContextPatch,
|
||||
PluginRuntimeLifecycleRegistration,
|
||||
PluginSessionActionContext,
|
||||
PluginSessionActionRegistration,
|
||||
PluginSessionActionResult,
|
||||
PluginSessionAttachmentParams,
|
||||
PluginSessionAttachmentResult,
|
||||
PluginSessionSchedulerJobHandle,
|
||||
PluginSessionSchedulerJobRegistration,
|
||||
PluginSessionTurnScheduleParams,
|
||||
PluginSessionTurnUnscheduleByTagParams,
|
||||
PluginSessionTurnUnscheduleByTagResult,
|
||||
PluginSessionExtensionRegistration,
|
||||
PluginSessionExtensionProjection,
|
||||
PluginToolMetadataRegistration,
|
||||
|
||||
@@ -85,6 +85,8 @@ import type {
|
||||
SpeechProviderPlugin,
|
||||
PluginCommandContext,
|
||||
PluginCommandResult,
|
||||
PluginAgentEventEmitParams,
|
||||
PluginAgentEventEmitResult,
|
||||
PluginAgentEventSubscriptionRegistration,
|
||||
PluginAgentTurnPrepareEvent,
|
||||
PluginAgentTurnPrepareResult,
|
||||
@@ -98,8 +100,16 @@ import type {
|
||||
PluginRunContextGetParams,
|
||||
PluginRunContextPatch,
|
||||
PluginRuntimeLifecycleRegistration,
|
||||
PluginSessionActionContext,
|
||||
PluginSessionActionRegistration,
|
||||
PluginSessionActionResult,
|
||||
PluginSessionAttachmentParams,
|
||||
PluginSessionAttachmentResult,
|
||||
PluginSessionSchedulerJobHandle,
|
||||
PluginSessionSchedulerJobRegistration,
|
||||
PluginSessionTurnScheduleParams,
|
||||
PluginSessionTurnUnscheduleByTagParams,
|
||||
PluginSessionTurnUnscheduleByTagResult,
|
||||
PluginSessionExtensionRegistration,
|
||||
PluginSessionExtensionProjection,
|
||||
PluginToolMetadataRegistration,
|
||||
@@ -130,6 +140,8 @@ export type {
|
||||
OpenClawPluginToolFactory,
|
||||
PluginCommandContext,
|
||||
PluginCommandResult,
|
||||
PluginAgentEventEmitParams,
|
||||
PluginAgentEventEmitResult,
|
||||
PluginAgentEventSubscriptionRegistration,
|
||||
PluginAgentTurnPrepareEvent,
|
||||
PluginAgentTurnPrepareResult,
|
||||
@@ -143,8 +155,16 @@ export type {
|
||||
PluginRunContextGetParams,
|
||||
PluginRunContextPatch,
|
||||
PluginRuntimeLifecycleRegistration,
|
||||
PluginSessionActionContext,
|
||||
PluginSessionActionRegistration,
|
||||
PluginSessionActionResult,
|
||||
PluginSessionSchedulerJobHandle,
|
||||
PluginSessionSchedulerJobRegistration,
|
||||
PluginSessionAttachmentParams,
|
||||
PluginSessionAttachmentResult,
|
||||
PluginSessionTurnScheduleParams,
|
||||
PluginSessionTurnUnscheduleByTagParams,
|
||||
PluginSessionTurnUnscheduleByTagResult,
|
||||
PluginSessionExtensionRegistration,
|
||||
PluginSessionExtensionProjection,
|
||||
PluginToolMetadataRegistration,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import {
|
||||
attachPluginApiFacades,
|
||||
type OpenClawPluginApiWithoutFacades,
|
||||
} from "../plugins/api-facades.js";
|
||||
import type { OpenClawPluginApi } from "./plugin-runtime.js";
|
||||
|
||||
export type TestPluginApiInput = Partial<OpenClawPluginApi>;
|
||||
|
||||
export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPluginApi {
|
||||
return {
|
||||
const { agent, lifecycle, runContext, session, ...flatApi } = api;
|
||||
const mergedApi = {
|
||||
id: "test-plugin",
|
||||
name: "test-plugin",
|
||||
source: "test",
|
||||
@@ -61,10 +66,15 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
|
||||
registerControlUiDescriptor() {},
|
||||
registerRuntimeLifecycle() {},
|
||||
registerAgentEventSubscription() {},
|
||||
emitAgentEvent: () => ({ emitted: false as const, reason: "test api" }),
|
||||
setRunContext: () => false,
|
||||
getRunContext: () => undefined,
|
||||
clearRunContext() {},
|
||||
registerSessionSchedulerJob: () => undefined,
|
||||
registerSessionAction() {},
|
||||
sendSessionAttachment: async () => ({ ok: false, error: "test plugin api" }),
|
||||
scheduleSessionTurn: async () => undefined,
|
||||
unscheduleSessionTurnsByTag: async () => ({ removed: 0, failed: 0 }),
|
||||
registerMemoryCapability() {},
|
||||
registerMemoryPromptSection() {},
|
||||
registerMemoryPromptSupplement() {},
|
||||
@@ -76,6 +86,14 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
|
||||
return input;
|
||||
},
|
||||
on() {},
|
||||
...api,
|
||||
...flatApi,
|
||||
} as OpenClawPluginApiWithoutFacades;
|
||||
const withFacades = attachPluginApiFacades(mergedApi);
|
||||
return {
|
||||
...withFacades,
|
||||
...(agent ? { agent } : {}),
|
||||
...(lifecycle ? { lifecycle } : {}),
|
||||
...(runContext ? { runContext } : {}),
|
||||
...(session ? { session } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
84
src/plugins/agent-event-emission.ts
Normal file
84
src/plugins/agent-event-emission.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import {
|
||||
isPluginJsonValue,
|
||||
type PluginAgentEventEmitParams,
|
||||
type PluginAgentEventEmitResult,
|
||||
type PluginJsonValue,
|
||||
} from "./host-hooks.js";
|
||||
import type { PluginOrigin } from "./plugin-origin.types.js";
|
||||
|
||||
const HOST_OWNED_AGENT_EVENT_STREAMS = new Set<string>([
|
||||
"lifecycle",
|
||||
"tool",
|
||||
"assistant",
|
||||
"error",
|
||||
"item",
|
||||
"plan",
|
||||
"approval",
|
||||
"command_output",
|
||||
"patch",
|
||||
"compaction",
|
||||
"thinking",
|
||||
"model",
|
||||
]);
|
||||
|
||||
function isPluginOwnedAgentEventStream(pluginId: string, stream: string): boolean {
|
||||
return stream === pluginId || stream.startsWith(`${pluginId}.`);
|
||||
}
|
||||
|
||||
function normalizePluginEventData(params: {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
data: PluginJsonValue;
|
||||
}): Record<string, unknown> {
|
||||
if (params.data && typeof params.data === "object" && !Array.isArray(params.data)) {
|
||||
return {
|
||||
...params.data,
|
||||
pluginId: params.pluginId,
|
||||
...(params.pluginName ? { pluginName: params.pluginName } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: params.data,
|
||||
pluginId: params.pluginId,
|
||||
...(params.pluginName ? { pluginName: params.pluginName } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function emitPluginAgentEvent(params: {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
origin: PluginOrigin;
|
||||
event: PluginAgentEventEmitParams;
|
||||
}): PluginAgentEventEmitResult {
|
||||
const runId = normalizeOptionalString(params.event.runId);
|
||||
const sessionKey = normalizeOptionalString(params.event.sessionKey);
|
||||
const stream = normalizeOptionalString(params.event.stream);
|
||||
if (!runId || !stream) {
|
||||
return { emitted: false, reason: "runId and stream are required" };
|
||||
}
|
||||
if (!isPluginJsonValue(params.event.data)) {
|
||||
return { emitted: false, reason: "event data must be JSON-compatible" };
|
||||
}
|
||||
if (params.origin !== "bundled" && HOST_OWNED_AGENT_EVENT_STREAMS.has(stream)) {
|
||||
return { emitted: false, reason: `stream ${stream} is reserved for bundled plugins` };
|
||||
}
|
||||
if (params.origin !== "bundled" && !isPluginOwnedAgentEventStream(params.pluginId, stream)) {
|
||||
return {
|
||||
emitted: false,
|
||||
reason: `stream ${stream} must be scoped to plugin ${params.pluginId}`,
|
||||
};
|
||||
}
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
data: normalizePluginEventData({
|
||||
pluginId: params.pluginId,
|
||||
pluginName: params.pluginName,
|
||||
data: params.event.data,
|
||||
}),
|
||||
});
|
||||
return { emitted: true, stream };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
60
src/plugins/api-facades.ts
Normal file
60
src/plugins/api-facades.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { OpenClawPluginApi } from "./types.js";
|
||||
|
||||
type PluginApiFacadeFields = Pick<
|
||||
OpenClawPluginApi,
|
||||
"agent" | "lifecycle" | "runContext" | "session"
|
||||
>;
|
||||
export type OpenClawPluginApiWithoutFacades = Omit<OpenClawPluginApi, keyof PluginApiFacadeFields>;
|
||||
type PluginApiFacadeSource = Pick<
|
||||
OpenClawPluginApi,
|
||||
| "clearRunContext"
|
||||
| "emitAgentEvent"
|
||||
| "enqueueNextTurnInjection"
|
||||
| "getRunContext"
|
||||
| "registerAgentEventSubscription"
|
||||
| "registerControlUiDescriptor"
|
||||
| "registerRuntimeLifecycle"
|
||||
| "registerSessionAction"
|
||||
| "registerSessionExtension"
|
||||
| "registerSessionSchedulerJob"
|
||||
| "scheduleSessionTurn"
|
||||
| "sendSessionAttachment"
|
||||
| "setRunContext"
|
||||
| "unscheduleSessionTurnsByTag"
|
||||
>;
|
||||
|
||||
export function attachPluginApiFacades<T extends object>(
|
||||
api: T & PluginApiFacadeSource & Partial<PluginApiFacadeFields>,
|
||||
): T & PluginApiFacadeFields {
|
||||
api.session = {
|
||||
state: {
|
||||
registerSessionExtension: (...args) => api.registerSessionExtension(...args),
|
||||
},
|
||||
workflow: {
|
||||
enqueueNextTurnInjection: (...args) => api.enqueueNextTurnInjection(...args),
|
||||
registerSessionSchedulerJob: (...args) => api.registerSessionSchedulerJob(...args),
|
||||
sendSessionAttachment: (...args) => api.sendSessionAttachment(...args),
|
||||
scheduleSessionTurn: (...args) => api.scheduleSessionTurn(...args),
|
||||
unscheduleSessionTurnsByTag: (...args) => api.unscheduleSessionTurnsByTag(...args),
|
||||
},
|
||||
controls: {
|
||||
registerSessionAction: (...args) => api.registerSessionAction(...args),
|
||||
registerControlUiDescriptor: (...args) => api.registerControlUiDescriptor(...args),
|
||||
},
|
||||
};
|
||||
api.agent = {
|
||||
events: {
|
||||
registerAgentEventSubscription: (...args) => api.registerAgentEventSubscription(...args),
|
||||
emitAgentEvent: (...args) => api.emitAgentEvent(...args),
|
||||
},
|
||||
};
|
||||
api.runContext = {
|
||||
setRunContext: (...args) => api.setRunContext(...args),
|
||||
getRunContext: (...args) => api.getRunContext(...args),
|
||||
clearRunContext: (...args) => api.clearRunContext(...args),
|
||||
};
|
||||
api.lifecycle = {
|
||||
registerRuntimeLifecycle: (...args) => api.registerRuntimeLifecycle(...args),
|
||||
};
|
||||
return api as T & PluginApiFacadeFields;
|
||||
}
|
||||
34
src/plugins/api-lifecycle.ts
Normal file
34
src/plugins/api-lifecycle.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { OpenClawPluginApi } from "./types.js";
|
||||
|
||||
type FunctionPropertyNames<T> = Extract<
|
||||
{
|
||||
[K in keyof T]-?: Exclude<T[K], undefined> extends (...args: unknown[]) => unknown ? K : never;
|
||||
}[keyof T],
|
||||
string
|
||||
>;
|
||||
|
||||
export type PluginApiMethodName = FunctionPropertyNames<OpenClawPluginApi>;
|
||||
|
||||
export type PluginApiLifecyclePolicy = {
|
||||
phase: "registration" | "runtime";
|
||||
lateCallable: boolean;
|
||||
};
|
||||
|
||||
const PLUGIN_API_METHOD_POLICIES: Partial<Record<PluginApiMethodName, PluginApiLifecyclePolicy>> = {
|
||||
emitAgentEvent: { phase: "runtime", lateCallable: true },
|
||||
sendSessionAttachment: { phase: "runtime", lateCallable: true },
|
||||
scheduleSessionTurn: { phase: "runtime", lateCallable: true },
|
||||
unscheduleSessionTurnsByTag: { phase: "runtime", lateCallable: true },
|
||||
};
|
||||
|
||||
export function getPluginApiMethodLifecyclePolicy(
|
||||
methodName: string,
|
||||
): PluginApiLifecyclePolicy | undefined {
|
||||
return PLUGIN_API_METHOD_POLICIES[methodName as PluginApiMethodName];
|
||||
}
|
||||
|
||||
export function isLateCallablePluginApiMethod(
|
||||
methodName: string,
|
||||
): methodName is PluginApiMethodName {
|
||||
return getPluginApiMethodLifecyclePolicy(methodName)?.lateCallable === true;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
PluginAgentEventSubscriptionRegistration,
|
||||
PluginControlUiDescriptor,
|
||||
PluginRuntimeLifecycleRegistration,
|
||||
PluginSessionActionRegistration,
|
||||
PluginSessionSchedulerJobRegistration,
|
||||
PluginSessionExtensionRegistration,
|
||||
PluginToolMetadataRegistration,
|
||||
@@ -74,6 +75,7 @@ export type CapturedPluginRegistration = {
|
||||
runtimeLifecycles: PluginRuntimeLifecycleRegistration[];
|
||||
agentEventSubscriptions: PluginAgentEventSubscriptionRegistration[];
|
||||
sessionSchedulerJobs: PluginSessionSchedulerJobRegistration[];
|
||||
sessionActions: PluginSessionActionRegistration[];
|
||||
tools: AnyAgentTool[];
|
||||
modelCatalogProviders: UnifiedModelCatalogProviderPlugin[];
|
||||
};
|
||||
@@ -110,6 +112,8 @@ export function createCapturedPluginRegistration(params?: {
|
||||
const runtimeLifecycles: PluginRuntimeLifecycleRegistration[] = [];
|
||||
const agentEventSubscriptions: PluginAgentEventSubscriptionRegistration[] = [];
|
||||
const sessionSchedulerJobs: PluginSessionSchedulerJobRegistration[] = [];
|
||||
const sessionActions: PluginSessionActionRegistration[] = [];
|
||||
let capturedSessionTurnCount = 0;
|
||||
const tools: AnyAgentTool[] = [];
|
||||
const modelCatalogProviders: UnifiedModelCatalogProviderPlugin[] = [];
|
||||
const pluginId = params?.id ?? "captured-plugin-registration";
|
||||
@@ -148,6 +152,7 @@ export function createCapturedPluginRegistration(params?: {
|
||||
runtimeLifecycles,
|
||||
agentEventSubscriptions,
|
||||
sessionSchedulerJobs,
|
||||
sessionActions,
|
||||
tools,
|
||||
modelCatalogProviders,
|
||||
api: buildPluginApi({
|
||||
@@ -270,15 +275,30 @@ export function createCapturedPluginRegistration(params?: {
|
||||
registerAgentEventSubscription(subscription: PluginAgentEventSubscriptionRegistration) {
|
||||
agentEventSubscriptions.push(subscription);
|
||||
},
|
||||
emitAgentEvent: () => ({ emitted: false, reason: "captured registration" }),
|
||||
registerSessionSchedulerJob(job: PluginSessionSchedulerJobRegistration) {
|
||||
sessionSchedulerJobs.push(job);
|
||||
return {
|
||||
id: job.id,
|
||||
pluginId: "captured-plugin-registration",
|
||||
pluginId,
|
||||
sessionKey: job.sessionKey,
|
||||
kind: job.kind,
|
||||
};
|
||||
},
|
||||
registerSessionAction(action: PluginSessionActionRegistration) {
|
||||
sessionActions.push(action);
|
||||
},
|
||||
sendSessionAttachment: async () => ({ ok: false, error: "captured registration" }),
|
||||
scheduleSessionTurn: async (schedule) => {
|
||||
capturedSessionTurnCount += 1;
|
||||
return {
|
||||
id: `captured-session-turn-${capturedSessionTurnCount}`,
|
||||
pluginId,
|
||||
sessionKey: schedule.sessionKey,
|
||||
kind: "session-turn",
|
||||
};
|
||||
},
|
||||
unscheduleSessionTurnsByTag: async () => ({ removed: 0, failed: 0 }),
|
||||
registerTool(tool) {
|
||||
if (typeof tool !== "function") {
|
||||
tools.push(tool);
|
||||
@@ -289,10 +309,12 @@ export function createCapturedPluginRegistration(params?: {
|
||||
};
|
||||
}
|
||||
|
||||
export function capturePluginRegistration(params: {
|
||||
register(api: OpenClawPluginApi): void;
|
||||
}): CapturedPluginRegistration {
|
||||
const captured = createCapturedPluginRegistration();
|
||||
export function capturePluginRegistration(
|
||||
params: NonNullable<Parameters<typeof createCapturedPluginRegistration>[0]> & {
|
||||
register(api: OpenClawPluginApi): void;
|
||||
},
|
||||
): CapturedPluginRegistration {
|
||||
const captured = createCapturedPluginRegistration(params);
|
||||
params.register(captured.api);
|
||||
return captured;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
1122
src/plugins/contracts/scheduled-turns.contract.test.ts
Normal file
1122
src/plugins/contracts/scheduled-turns.contract.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
926
src/plugins/contracts/session-actions.contract.test.ts
Normal file
926
src/plugins/contracts/session-actions.contract.test.ts
Normal file
@@ -0,0 +1,926 @@
|
||||
import {
|
||||
createPluginRegistryFixture,
|
||||
registerTestPlugin,
|
||||
} from "openclaw/plugin-sdk/plugin-test-contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { APPROVALS_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../../gateway/operator-scopes.js";
|
||||
import { handleGatewayRequest } from "../../gateway/server-methods.js";
|
||||
import { pluginHostHookHandlers } from "../../gateway/server-methods/plugin-host-hooks.js";
|
||||
import type { GatewayClient, RespondFn } from "../../gateway/server-methods/types.js";
|
||||
import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js";
|
||||
import { createEmptyPluginRegistry } from "../registry-empty.js";
|
||||
import { createPluginRegistry } from "../registry.js";
|
||||
import { setActivePluginRegistry } from "../runtime.js";
|
||||
import { createPluginRecord } from "../status.test-helpers.js";
|
||||
import type { OpenClawPluginApi } from "../types.js";
|
||||
|
||||
const MAIN_SESSION_KEY = "agent:main:main";
|
||||
|
||||
function sessionActionBody(
|
||||
pluginId: string,
|
||||
actionId: string,
|
||||
extra?: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
pluginId,
|
||||
actionId,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function callPluginSessionActionForTest(params: {
|
||||
body: Record<string, unknown>;
|
||||
scopes?: string[];
|
||||
}): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> {
|
||||
let response: { ok: boolean; payload?: unknown; error?: unknown } | undefined;
|
||||
const respond: RespondFn = (ok, payload, error) => {
|
||||
response = { ok, payload, error };
|
||||
};
|
||||
await pluginHostHookHandlers["plugins.sessionAction"]({
|
||||
req: { id: "test", type: "req", method: "plugins.sessionAction", params: params.body },
|
||||
params: params.body,
|
||||
client: {
|
||||
connId: "test-client",
|
||||
connect: { scopes: params.scopes ?? [WRITE_SCOPE] },
|
||||
} as GatewayClient,
|
||||
isWebchatConnect: () => false,
|
||||
respond,
|
||||
context: {} as never,
|
||||
});
|
||||
return response ?? { ok: false, error: new Error("handler did not respond") };
|
||||
}
|
||||
|
||||
async function callRegisteredSessionActionForTest(params: {
|
||||
pluginId: string;
|
||||
actionId: string;
|
||||
extra?: Record<string, unknown>;
|
||||
scopes?: string[];
|
||||
}): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> {
|
||||
return callPluginSessionActionForTest({
|
||||
body: sessionActionBody(params.pluginId, params.actionId, params.extra),
|
||||
...(params.scopes ? { scopes: params.scopes } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function callPluginSessionActionThroughGatewayForTest(params: {
|
||||
body: Record<string, unknown>;
|
||||
scopes?: string[];
|
||||
}): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> {
|
||||
let response: { ok: boolean; payload?: unknown; error?: unknown } | undefined;
|
||||
const respond: RespondFn = (ok, payload, error) => {
|
||||
response = { ok, payload, error };
|
||||
};
|
||||
await handleGatewayRequest({
|
||||
req: { id: "test", type: "req", method: "plugins.sessionAction", params: params.body },
|
||||
respond,
|
||||
client: {
|
||||
connId: "test-client",
|
||||
connect: {
|
||||
role: "operator",
|
||||
scopes: params.scopes ?? [],
|
||||
},
|
||||
} as GatewayClient,
|
||||
isWebchatConnect: () => false,
|
||||
context: {
|
||||
logGateway: {
|
||||
warn() {},
|
||||
},
|
||||
} as unknown as Parameters<typeof handleGatewayRequest>[0]["context"],
|
||||
});
|
||||
return response ?? { ok: false, error: new Error("handler did not respond") };
|
||||
}
|
||||
|
||||
async function callRegisteredSessionActionThroughGatewayForTest(params: {
|
||||
pluginId: string;
|
||||
actionId: string;
|
||||
extra?: Record<string, unknown>;
|
||||
scopes?: string[];
|
||||
}): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> {
|
||||
return callPluginSessionActionThroughGatewayForTest({
|
||||
body: sessionActionBody(params.pluginId, params.actionId, params.extra),
|
||||
...(params.scopes ? { scopes: params.scopes } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function registerActionFixture(params: {
|
||||
id: string;
|
||||
name?: string;
|
||||
register: (api: OpenClawPluginApi) => void;
|
||||
}) {
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
registerTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: params.id,
|
||||
name: params.name ?? params.id,
|
||||
}),
|
||||
register: params.register,
|
||||
});
|
||||
return { config, registry };
|
||||
}
|
||||
|
||||
describe("plugin session actions", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
resetAgentEventsForTest();
|
||||
});
|
||||
|
||||
it("initializes and registers typed session actions", () => {
|
||||
expect(createEmptyPluginRegistry().sessionActions).toEqual([]);
|
||||
|
||||
const { registry } = registerActionFixture({
|
||||
id: "session-action-fixture",
|
||||
name: "Session Action Fixture",
|
||||
register(api) {
|
||||
api.registerSessionAction({
|
||||
id: "approve",
|
||||
description: "Approve the current workflow",
|
||||
requiredScopes: [APPROVALS_SCOPE],
|
||||
handler: () => ({ ok: true, result: { accepted: true } }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.registry.sessionActions).toHaveLength(1);
|
||||
expect(registry.registry.sessionActions?.[0]).toMatchObject({
|
||||
pluginId: "session-action-fixture",
|
||||
pluginName: "Session Action Fixture",
|
||||
action: {
|
||||
id: "approve",
|
||||
description: "Approve the current workflow",
|
||||
requiredScopes: [APPROVALS_SCOPE],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid or duplicate session action registrations", () => {
|
||||
const { registry } = registerActionFixture({
|
||||
id: "invalid-session-actions",
|
||||
name: "Invalid Session Actions",
|
||||
register(api) {
|
||||
for (const action of [
|
||||
{ id: "dup" },
|
||||
{ id: "dup" },
|
||||
{ id: "bad-scope", requiredScopes: ["not-a-scope"] as never },
|
||||
{ id: "bad-schema-shape", schema: "not-an-object" as never },
|
||||
{ id: "bad-schema-compile", schema: { type: "not-a-json-schema-type" } as never },
|
||||
{ id: "" },
|
||||
]) {
|
||||
api.registerSessionAction({
|
||||
...action,
|
||||
handler: () => ({ ok: true }),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.registry.sessionActions?.map((entry) => entry.action.id)).toEqual(["dup"]);
|
||||
expect(registry.registry.diagnostics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: "invalid-session-actions",
|
||||
message: "session action already registered: dup",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pluginId: "invalid-session-actions",
|
||||
message: "session action requiredScopes contains unknown operator scope: not-a-scope",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pluginId: "invalid-session-actions",
|
||||
message:
|
||||
"session action schema must be a JSON schema object or boolean: bad-schema-shape",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pluginId: "invalid-session-actions",
|
||||
message: expect.stringContaining(
|
||||
"session action schema is not valid JSON Schema: bad-schema-compile",
|
||||
),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pluginId: "invalid-session-actions",
|
||||
message: "session action registration requires id, handler, and valid optional fields",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("validates payload schemas and typed action results", async () => {
|
||||
const callSchemaAction = (
|
||||
actionId: string,
|
||||
extra?: Record<string, unknown>,
|
||||
): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> =>
|
||||
callRegisteredSessionActionForTest({
|
||||
pluginId: "schema-action-fixture",
|
||||
actionId,
|
||||
...(extra ? { extra } : {}),
|
||||
});
|
||||
const handlerCalls: unknown[] = [];
|
||||
const { registry } = registerActionFixture({
|
||||
id: "schema-action-fixture",
|
||||
name: "Schema Action Fixture",
|
||||
register(api) {
|
||||
api.registerSessionAction({
|
||||
id: "approve",
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["version"],
|
||||
properties: {
|
||||
version: { type: "string" },
|
||||
},
|
||||
},
|
||||
handler: ({ payload, sessionKey, client }) => {
|
||||
handlerCalls.push({ payload, sessionKey, scopes: client?.scopes ?? [] });
|
||||
return {
|
||||
result: { accepted: true, ...(sessionKey ? { sessionKey } : {}) },
|
||||
continueAgent: true,
|
||||
reply: { text: "approved" },
|
||||
};
|
||||
},
|
||||
});
|
||||
api.registerSessionAction({
|
||||
id: "typed-error",
|
||||
handler: () => ({
|
||||
ok: false,
|
||||
error: "needs operator input",
|
||||
code: "needs_input",
|
||||
details: { field: "version" },
|
||||
}),
|
||||
});
|
||||
api.registerSessionAction({
|
||||
id: "allow-any",
|
||||
schema: true,
|
||||
handler: ({ payload }) => ({ result: { payload: payload ?? null } }),
|
||||
});
|
||||
api.registerSessionAction({
|
||||
id: "deny-all",
|
||||
schema: false,
|
||||
handler: () => ({ result: { unreachable: true } }),
|
||||
});
|
||||
},
|
||||
});
|
||||
setActivePluginRegistry(registry.registry);
|
||||
|
||||
const rejected = await callPluginSessionActionForTest({
|
||||
body: sessionActionBody("schema-action-fixture", "approve", { payload: { version: 1 } }),
|
||||
});
|
||||
expect(rejected.ok).toBe(false);
|
||||
expect(rejected.error).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
});
|
||||
expect(String((rejected.error as { message?: unknown } | undefined)?.message)).toContain(
|
||||
"plugin session action payload does not match schema",
|
||||
);
|
||||
expect(handlerCalls).toEqual([]);
|
||||
|
||||
await expect(
|
||||
callSchemaAction("approve", {
|
||||
sessionKey: MAIN_SESSION_KEY,
|
||||
payload: { version: "2026.05.01" },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
result: { accepted: true, sessionKey: MAIN_SESSION_KEY },
|
||||
continueAgent: true,
|
||||
reply: { text: "approved" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
expect(handlerCalls).toEqual([
|
||||
{
|
||||
payload: { version: "2026.05.01" },
|
||||
sessionKey: MAIN_SESSION_KEY,
|
||||
scopes: [WRITE_SCOPE],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(callSchemaAction("typed-error")).resolves.toMatchObject({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: false,
|
||||
error: "needs operator input",
|
||||
code: "needs_input",
|
||||
details: {
|
||||
field: "version",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
callSchemaAction("allow-any", { payload: { any: ["json", true] } }),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
result: { payload: { any: ["json", true] } },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
callSchemaAction("deny-all", { payload: { rejected: true } }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("validates plugin session action results before returning gateway payloads", async () => {
|
||||
const callValidationAction = (
|
||||
actionId: string,
|
||||
): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> =>
|
||||
callRegisteredSessionActionForTest({
|
||||
pluginId: "session-action-validation-fixture",
|
||||
actionId,
|
||||
});
|
||||
const { registry } = registerActionFixture({
|
||||
id: "session-action-validation-fixture",
|
||||
name: "Session Action Validation Fixture",
|
||||
register(api) {
|
||||
const handlers = {
|
||||
"bad-result": () => ({ result: 1n as never }),
|
||||
"bad-reply": () => ({ reply: { text: "ok", extra: () => undefined } as never }),
|
||||
"primitive-result": () => "not-an-object" as never,
|
||||
"typed-error": () => ({
|
||||
ok: false,
|
||||
error: "needs operator input",
|
||||
code: "needs_input",
|
||||
details: { field: "version" },
|
||||
}),
|
||||
"bad-ok": () =>
|
||||
({
|
||||
ok: "false",
|
||||
error: "must not masquerade as success",
|
||||
}) as never,
|
||||
"error-shaped-success": () =>
|
||||
({
|
||||
error: "must declare ok false",
|
||||
}) as never,
|
||||
"bad-error-details": () => ({
|
||||
ok: false,
|
||||
error: "bad details",
|
||||
details: { value: 1n } as never,
|
||||
}),
|
||||
"bad-continue-agent": () => ({ continueAgent: "yes" as never }),
|
||||
"mixed-branch-fields": () =>
|
||||
({
|
||||
ok: false,
|
||||
error: "stop",
|
||||
continueAgent: true,
|
||||
result: { leaked: true },
|
||||
}) as never,
|
||||
"unknown-success-field": () =>
|
||||
({
|
||||
result: { accepted: true },
|
||||
extra: "unexpected",
|
||||
}) as never,
|
||||
"throws-secret": () => {
|
||||
throw new Error("fixture action failed");
|
||||
},
|
||||
};
|
||||
for (const [id, handler] of Object.entries(handlers)) {
|
||||
api.registerSessionAction({ id, handler: handler as never });
|
||||
}
|
||||
},
|
||||
});
|
||||
setActivePluginRegistry(registry.registry);
|
||||
|
||||
const expectValidationError = async (actionId: string, message: unknown) => {
|
||||
await expect(callValidationAction(actionId)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await expectValidationError(
|
||||
"bad-result",
|
||||
"plugin session action result must be JSON-compatible",
|
||||
);
|
||||
await expectValidationError("bad-reply", "plugin session action reply must be JSON-compatible");
|
||||
await expect(callValidationAction("primitive-result")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "plugin session action result must be an object",
|
||||
},
|
||||
});
|
||||
await expect(callValidationAction("typed-error")).resolves.toMatchObject({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: false,
|
||||
error: "needs operator input",
|
||||
details: {
|
||||
field: "version",
|
||||
},
|
||||
},
|
||||
});
|
||||
await expectValidationError("bad-ok", expect.stringContaining("/ok: must be boolean"));
|
||||
await expectValidationError(
|
||||
"error-shaped-success",
|
||||
expect.stringContaining("unexpected property 'error'"),
|
||||
);
|
||||
await expectValidationError(
|
||||
"bad-error-details",
|
||||
"plugin session action details must be JSON-compatible",
|
||||
);
|
||||
await expectValidationError(
|
||||
"bad-continue-agent",
|
||||
expect.stringContaining("/continueAgent: must be boolean"),
|
||||
);
|
||||
await expectValidationError(
|
||||
"mixed-branch-fields",
|
||||
expect.stringContaining("unexpected property 'continueAgent'"),
|
||||
);
|
||||
await expectValidationError(
|
||||
"unknown-success-field",
|
||||
expect.stringContaining("unexpected property 'extra'"),
|
||||
);
|
||||
await expect(callValidationAction("throws-secret")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
message: "plugin session action failed",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes session actions through the gateway by action-declared scopes", async () => {
|
||||
const callApprovalAction = (params: {
|
||||
actionId: string;
|
||||
extra?: Record<string, unknown>;
|
||||
scopes?: string[];
|
||||
}): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> =>
|
||||
callRegisteredSessionActionThroughGatewayForTest({
|
||||
pluginId: "approval-action-fixture",
|
||||
actionId: params.actionId,
|
||||
...(params.extra ? { extra: params.extra } : {}),
|
||||
...(params.scopes ? { scopes: params.scopes } : {}),
|
||||
});
|
||||
const handlerCalls: unknown[] = [];
|
||||
const { registry } = registerActionFixture({
|
||||
id: "approval-action-fixture",
|
||||
name: "Approval Action Fixture",
|
||||
register(api) {
|
||||
api.registerSessionAction({
|
||||
id: "approve",
|
||||
requiredScopes: [APPROVALS_SCOPE],
|
||||
handler: ({ client, sessionKey }) => {
|
||||
handlerCalls.push({ scopes: client?.scopes ?? [], sessionKey });
|
||||
return {
|
||||
result: { approved: true, ...(sessionKey ? { sessionKey } : {}) },
|
||||
continueAgent: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
api.registerSessionAction({
|
||||
id: "view",
|
||||
requiredScopes: [READ_SCOPE],
|
||||
handler: ({ client }) => {
|
||||
handlerCalls.push({ scopes: client?.scopes ?? [], action: "view" });
|
||||
return { result: { visible: true }, continueAgent: false };
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
setActivePluginRegistry(registry.registry);
|
||||
|
||||
await expect(
|
||||
callApprovalAction({
|
||||
actionId: "approve",
|
||||
scopes: [APPROVALS_SCOPE],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
payload: { ok: true, result: { approved: true }, continueAgent: true },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
await expect(
|
||||
callApprovalAction({
|
||||
actionId: "view",
|
||||
scopes: [WRITE_SCOPE],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
payload: { ok: true, result: { visible: true }, continueAgent: false },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
await expect(
|
||||
callApprovalAction({
|
||||
actionId: "approve",
|
||||
scopes: [READ_SCOPE],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: `missing scope: ${APPROVALS_SCOPE}`,
|
||||
},
|
||||
});
|
||||
expect(handlerCalls).toEqual([
|
||||
{ scopes: [APPROVALS_SCOPE], sessionKey: undefined },
|
||||
{ scopes: [WRITE_SCOPE], action: "view" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
callApprovalAction({
|
||||
actionId: "approve",
|
||||
extra: { sessionKey: ` ${MAIN_SESSION_KEY} ` },
|
||||
scopes: [APPROVALS_SCOPE],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
result: { approved: true, sessionKey: MAIN_SESSION_KEY },
|
||||
continueAgent: true,
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
await expect(
|
||||
callApprovalAction({
|
||||
actionId: "view",
|
||||
scopes: [READ_SCOPE],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
payload: { ok: true, result: { visible: true }, continueAgent: false },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
await expect(
|
||||
callPluginSessionActionThroughGatewayForTest({
|
||||
body: {
|
||||
pluginId: " ",
|
||||
actionId: "approve",
|
||||
},
|
||||
scopes: [APPROVALS_SCOPE],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "plugins.sessionAction pluginId and actionId must be non-empty",
|
||||
},
|
||||
});
|
||||
expect(handlerCalls).toEqual([
|
||||
{ scopes: [APPROVALS_SCOPE], sessionKey: undefined },
|
||||
{ scopes: [WRITE_SCOPE], action: "view" },
|
||||
{ scopes: [APPROVALS_SCOPE], sessionKey: MAIN_SESSION_KEY },
|
||||
{ scopes: [READ_SCOPE], action: "view" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes a defensive copy of client scopes to session action handlers", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
let response: { ok: boolean; payload?: unknown; error?: unknown } | undefined;
|
||||
let handlerScopes: string[] | undefined;
|
||||
const originalScopes = [READ_SCOPE];
|
||||
registry.sessionActions = [
|
||||
{
|
||||
pluginId: "scope-copy-fixture",
|
||||
pluginName: "Scope Copy Fixture",
|
||||
source: "test",
|
||||
action: {
|
||||
id: "mutate",
|
||||
requiredScopes: [READ_SCOPE],
|
||||
handler: ({ client }) => {
|
||||
handlerScopes = client?.scopes;
|
||||
client?.scopes.push(APPROVALS_SCOPE);
|
||||
return { result: { ok: true } };
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
registry.plugins = [createPluginRecord({ id: "scope-copy-fixture" })];
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
await pluginHostHookHandlers["plugins.sessionAction"]({
|
||||
req: {
|
||||
id: "scope-copy",
|
||||
type: "req",
|
||||
method: "plugins.sessionAction",
|
||||
params: { pluginId: "scope-copy-fixture", actionId: "mutate" },
|
||||
},
|
||||
params: { pluginId: "scope-copy-fixture", actionId: "mutate" },
|
||||
client: {
|
||||
connId: "scope-copy-client",
|
||||
connect: { scopes: originalScopes },
|
||||
} as GatewayClient,
|
||||
isWebchatConnect: () => false,
|
||||
respond: (ok, payload, error) => {
|
||||
response = { ok, payload, error };
|
||||
},
|
||||
context: {} as never,
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
ok: true,
|
||||
payload: { ok: true, result: { ok: true } },
|
||||
error: undefined,
|
||||
});
|
||||
expect(handlerScopes).toEqual([READ_SCOPE, APPROVALS_SCOPE]);
|
||||
expect(handlerScopes).not.toBe(originalScopes);
|
||||
expect(originalScopes).toEqual([READ_SCOPE]);
|
||||
});
|
||||
|
||||
it("does not dispatch session actions for plugins that are not loaded", async () => {
|
||||
const handler = vi.fn(() => ({ result: { stale: true } }));
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.sessionActions = [
|
||||
{
|
||||
pluginId: "failed-action-plugin",
|
||||
pluginName: "Failed Action Plugin",
|
||||
source: "test",
|
||||
action: {
|
||||
id: "stale",
|
||||
requiredScopes: [READ_SCOPE],
|
||||
handler,
|
||||
},
|
||||
},
|
||||
];
|
||||
registry.plugins = [
|
||||
createPluginRecord({
|
||||
id: "failed-action-plugin",
|
||||
name: "Failed Action Plugin",
|
||||
status: "error",
|
||||
}),
|
||||
];
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
await expect(
|
||||
callPluginSessionActionThroughGatewayForTest({
|
||||
body: {
|
||||
pluginId: "failed-action-plugin",
|
||||
actionId: "stale",
|
||||
},
|
||||
scopes: [READ_SCOPE],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
message: "unknown plugin session action: failed-action-plugin/stale",
|
||||
},
|
||||
});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits plugin-attributed agent events through the plugin API", () => {
|
||||
const observed: unknown[] = [];
|
||||
const unsubscribe = onAgentEvent((event) => observed.push(event));
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
let bundledApi: OpenClawPluginApi | undefined;
|
||||
let workspaceApi: OpenClawPluginApi | undefined;
|
||||
registerTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "event-plugin",
|
||||
name: "Event Plugin",
|
||||
origin: "bundled",
|
||||
}),
|
||||
register(api) {
|
||||
bundledApi = api;
|
||||
},
|
||||
});
|
||||
registerTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "workspace-event-plugin",
|
||||
name: "Workspace Event Plugin",
|
||||
origin: "workspace",
|
||||
}),
|
||||
register(api) {
|
||||
workspaceApi = api;
|
||||
},
|
||||
});
|
||||
setActivePluginRegistry(registry.registry);
|
||||
|
||||
try {
|
||||
expect(
|
||||
bundledApi?.agent?.events.emitAgentEvent({
|
||||
runId: "run-emit",
|
||||
sessionKey: " agent:main:main ",
|
||||
stream: "approval",
|
||||
data: { state: "queued" },
|
||||
}),
|
||||
).toEqual({ emitted: true, stream: "approval" });
|
||||
expect(
|
||||
workspaceApi?.emitAgentEvent({
|
||||
runId: "run-emit",
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
}),
|
||||
).toEqual({ emitted: false, reason: "stream lifecycle is reserved for bundled plugins" });
|
||||
expect(
|
||||
workspaceApi?.emitAgentEvent({
|
||||
runId: "run-emit",
|
||||
stream: "assistant",
|
||||
data: { text: "spoofed assistant output" },
|
||||
}),
|
||||
).toEqual({ emitted: false, reason: "stream assistant is reserved for bundled plugins" });
|
||||
expect(
|
||||
workspaceApi?.emitAgentEvent({
|
||||
runId: "run-emit",
|
||||
stream: "other-plugin.workflow",
|
||||
data: { state: "queued" },
|
||||
}),
|
||||
).toEqual({
|
||||
emitted: false,
|
||||
reason: "stream other-plugin.workflow must be scoped to plugin workspace-event-plugin",
|
||||
});
|
||||
expect(
|
||||
workspaceApi?.emitAgentEvent({
|
||||
runId: "run-emit",
|
||||
stream: "workspace-event-plugin.workflow",
|
||||
data: { state: "queued" },
|
||||
}),
|
||||
).toEqual({ emitted: true, stream: "workspace-event-plugin.workflow" });
|
||||
expect(
|
||||
bundledApi?.emitAgentEvent({
|
||||
runId: "run-emit",
|
||||
stream: "approval",
|
||||
data: 1n as never,
|
||||
}),
|
||||
).toEqual({ emitted: false, reason: "event data must be JSON-compatible" });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(observed).toEqual([
|
||||
expect.objectContaining({
|
||||
runId: "run-emit",
|
||||
sessionKey: "agent:main:main",
|
||||
stream: "approval",
|
||||
data: {
|
||||
state: "queued",
|
||||
pluginId: "event-plugin",
|
||||
pluginName: "Event Plugin",
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
runId: "run-emit",
|
||||
stream: "workspace-event-plugin.workflow",
|
||||
data: {
|
||||
state: "queued",
|
||||
pluginId: "workspace-event-plugin",
|
||||
pluginName: "Workspace Event Plugin",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks agent events from stale and non-activating plugin API closures", () => {
|
||||
const observed: unknown[] = [];
|
||||
const unsubscribe = onAgentEvent((event) => observed.push(event));
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
let capturedApi: OpenClawPluginApi | undefined;
|
||||
registerTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "stale-event-plugin",
|
||||
name: "Stale Event Plugin",
|
||||
origin: "bundled",
|
||||
}),
|
||||
register(api) {
|
||||
capturedApi = api;
|
||||
},
|
||||
});
|
||||
setActivePluginRegistry(registry.registry);
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
|
||||
try {
|
||||
expect(
|
||||
capturedApi?.emitAgentEvent({
|
||||
runId: "stale-run",
|
||||
stream: "approval",
|
||||
data: { stale: true },
|
||||
}),
|
||||
).toEqual({ emitted: false, reason: "plugin is not loaded" });
|
||||
|
||||
const neverActiveRegistry = createPluginRegistry({
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
},
|
||||
runtime: {} as never,
|
||||
});
|
||||
let neverActiveApi: OpenClawPluginApi | undefined;
|
||||
registerTestPlugin({
|
||||
registry: neverActiveRegistry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "never-active-event-plugin",
|
||||
name: "Never Active Event Plugin",
|
||||
origin: "bundled",
|
||||
}),
|
||||
register(api) {
|
||||
neverActiveApi = api;
|
||||
},
|
||||
});
|
||||
expect(
|
||||
neverActiveApi?.emitAgentEvent({
|
||||
runId: "never-active-run",
|
||||
stream: "approval",
|
||||
data: { inactive: true },
|
||||
}),
|
||||
).toEqual({ emitted: false, reason: "plugin is not loaded" });
|
||||
|
||||
const inactiveRegistry = createPluginRegistry({
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
},
|
||||
runtime: {} as never,
|
||||
activateGlobalSideEffects: false,
|
||||
});
|
||||
let inactiveApi: OpenClawPluginApi | undefined;
|
||||
registerTestPlugin({
|
||||
registry: inactiveRegistry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "inactive-event-plugin",
|
||||
name: "Inactive Event Plugin",
|
||||
origin: "bundled",
|
||||
}),
|
||||
register(api) {
|
||||
inactiveApi = api;
|
||||
},
|
||||
});
|
||||
expect(
|
||||
inactiveApi?.emitAgentEvent({
|
||||
runId: "inactive-run",
|
||||
stream: "approval",
|
||||
data: { inactive: true },
|
||||
}),
|
||||
).toEqual({ emitted: false, reason: "global side effects disabled" });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(observed).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows reactivated cached registries to emit agent events again", () => {
|
||||
const observed: unknown[] = [];
|
||||
const unsubscribe = onAgentEvent((event) => observed.push(event));
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
let capturedApi: OpenClawPluginApi | undefined;
|
||||
registerTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "reactivated-event-plugin",
|
||||
name: "Reactivated Event Plugin",
|
||||
origin: "bundled",
|
||||
}),
|
||||
register(api) {
|
||||
capturedApi = api;
|
||||
},
|
||||
});
|
||||
|
||||
setActivePluginRegistry(registry.registry);
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
setActivePluginRegistry(registry.registry);
|
||||
|
||||
try {
|
||||
expect(
|
||||
capturedApi?.emitAgentEvent({
|
||||
runId: "reactivated-run",
|
||||
stream: "approval",
|
||||
data: { active: true },
|
||||
}),
|
||||
).toEqual({ emitted: true, stream: "approval" });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(observed).toEqual([
|
||||
expect.objectContaining({
|
||||
runId: "reactivated-run",
|
||||
stream: "approval",
|
||||
data: {
|
||||
active: true,
|
||||
pluginId: "reactivated-event-plugin",
|
||||
pluginName: "Reactivated Event Plugin",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
708
src/plugins/contracts/session-attachments.contract.test.ts
Normal file
708
src/plugins/contracts/session-attachments.contract.test.ts
Normal file
@@ -0,0 +1,708 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createPluginRegistryFixture,
|
||||
registerTestPlugin,
|
||||
} from "openclaw/plugin-sdk/plugin-test-contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
|
||||
import { withTempConfig } from "../../gateway/test-temp-config.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
|
||||
import { FILE_TYPE_SNIFF_MAX_BYTES } from "../../media/mime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { resolveAttachmentDelivery, sendPluginSessionAttachment } from "../host-hook-attachments.js";
|
||||
import { clearPluginLoaderCache } from "../loader.js";
|
||||
import { createEmptyPluginRegistry } from "../registry-empty.js";
|
||||
import { createPluginRegistry } from "../registry.js";
|
||||
import { setActivePluginRegistry } from "../runtime.js";
|
||||
import type { PluginRuntime } from "../runtime/types.js";
|
||||
import { createPluginRecord } from "../status.test-helpers.js";
|
||||
import type { OpenClawPluginApi } from "../types.js";
|
||||
|
||||
const workflowMocks = vi.hoisted(() => ({
|
||||
getChannelPlugin: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
const MAIN_SESSION_KEY = "agent:main:main";
|
||||
const DEFAULT_TELEGRAM_ROUTE = {
|
||||
channel: "telegram",
|
||||
to: "12345",
|
||||
} as const;
|
||||
|
||||
type SessionAttachmentRequest = Parameters<typeof sendPluginSessionAttachment>[0];
|
||||
type TestSessionEntry = {
|
||||
sessionId?: string;
|
||||
updatedAt?: number;
|
||||
deliveryContext?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: workflowMocks.getChannelPlugin,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/message.js", () => ({
|
||||
sendMessage: workflowMocks.sendMessage,
|
||||
}));
|
||||
|
||||
function createSilentPluginLogger() {
|
||||
return {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
};
|
||||
}
|
||||
|
||||
async function withSessionStore(
|
||||
run: (params: { stateDir: string; storePath: string; filePath: string }) => Promise<void>,
|
||||
) {
|
||||
const stateDir = await fs.mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-session-attachments-"),
|
||||
);
|
||||
const storePath = path.join(stateDir, "sessions.json");
|
||||
const filePath = path.join(stateDir, "x.txt");
|
||||
await fs.writeFile(filePath, "x", "utf8");
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
try {
|
||||
await withTempConfig({
|
||||
cfg: { session: { store: storePath } },
|
||||
run: async () => await run({ stateDir, storePath, filePath }),
|
||||
});
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSessionEntry(
|
||||
storePath: string,
|
||||
entry: TestSessionEntry = { deliveryContext: DEFAULT_TELEGRAM_ROUTE },
|
||||
key = MAIN_SESSION_KEY,
|
||||
) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[key] = {
|
||||
sessionId: "session-id",
|
||||
updatedAt: Date.now(),
|
||||
...entry,
|
||||
} as unknown as SessionEntry;
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulAttachmentDelivery(messageId = "attachment-1") {
|
||||
workflowMocks.sendMessage.mockImplementation(async (params: Record<string, unknown>) => ({
|
||||
channel: params.channel,
|
||||
to: params.to,
|
||||
via: "direct" as const,
|
||||
mediaUrl: null,
|
||||
result: { channel: params.channel, messageId },
|
||||
}));
|
||||
}
|
||||
|
||||
async function sendBundledSessionAttachment(
|
||||
params: Omit<SessionAttachmentRequest, "origin" | "sessionKey"> &
|
||||
Partial<Pick<SessionAttachmentRequest, "sessionKey">>,
|
||||
) {
|
||||
return await sendPluginSessionAttachment({
|
||||
origin: "bundled",
|
||||
sessionKey: MAIN_SESSION_KEY,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
describe("plugin session attachments", () => {
|
||||
afterEach(() => {
|
||||
workflowMocks.getChannelPlugin.mockReset();
|
||||
workflowMocks.sendMessage.mockReset();
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
clearPluginLoaderCache();
|
||||
delete (globalThis as { __proofAttachmentApi?: OpenClawPluginApi }).__proofAttachmentApi;
|
||||
delete (globalThis as { __proofAttachmentLog?: unknown[] }).__proofAttachmentLog;
|
||||
});
|
||||
|
||||
it("resolves channel hint precedence for attachment delivery", () => {
|
||||
expect(
|
||||
resolveAttachmentDelivery({
|
||||
channel: "telegram",
|
||||
captionFormat: "html",
|
||||
channelHints: { telegram: { parseMode: "HTML" } },
|
||||
}),
|
||||
).toEqual({ parseMode: "HTML" });
|
||||
expect(resolveAttachmentDelivery({ channel: "telegram", captionFormat: "html" })).toEqual({
|
||||
parseMode: "HTML",
|
||||
});
|
||||
expect(resolveAttachmentDelivery({ channel: "telegram", captionFormat: "plain" })).toEqual({
|
||||
parseMode: "HTML",
|
||||
escapePlainHtmlCaption: true,
|
||||
});
|
||||
expect(
|
||||
resolveAttachmentDelivery({
|
||||
channel: "telegram",
|
||||
captionFormat: "plain",
|
||||
channelHints: { telegram: { parseMode: "HTML" } },
|
||||
}),
|
||||
).toEqual({
|
||||
parseMode: "HTML",
|
||||
escapePlainHtmlCaption: true,
|
||||
});
|
||||
expect(
|
||||
resolveAttachmentDelivery({
|
||||
channel: "telegram",
|
||||
channelHints: {
|
||||
telegram: { disableNotification: true, forceDocumentMime: "application/pdf" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
disableNotification: true,
|
||||
forceDocumentMime: "application/pdf",
|
||||
});
|
||||
expect(
|
||||
resolveAttachmentDelivery({
|
||||
channel: "slack",
|
||||
channelHints: { slack: { threadTs: "1700000000.000100" } },
|
||||
}),
|
||||
).toEqual({ threadTs: "1700000000.000100" });
|
||||
expect(
|
||||
resolveAttachmentDelivery({
|
||||
channel: "slack",
|
||||
channelHints: { slack: { threadTs: " 1700000000.000100 " } },
|
||||
}),
|
||||
).toEqual({ threadTs: "1700000000.000100" });
|
||||
expect(
|
||||
resolveAttachmentDelivery({
|
||||
channel: "slack",
|
||||
channelHints: { slack: { threadTs: " " } },
|
||||
}),
|
||||
).toEqual({});
|
||||
expect(resolveAttachmentDelivery({ channel: "discord", captionFormat: "markdown" })).toEqual(
|
||||
{},
|
||||
);
|
||||
expect(
|
||||
resolveAttachmentDelivery({
|
||||
channel: "unknown",
|
||||
channelHints: { telegram: { parseMode: "HTML" } },
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("sends validated files through the session delivery route with channel hints", async () => {
|
||||
await withSessionStore(async ({ storePath, filePath }) => {
|
||||
await writeSessionEntry(storePath, {
|
||||
deliveryContext: {
|
||||
...DEFAULT_TELEGRAM_ROUTE,
|
||||
accountId: "default",
|
||||
threadId: 42,
|
||||
},
|
||||
});
|
||||
mockSuccessfulAttachmentDelivery();
|
||||
|
||||
const result = await sendBundledSessionAttachment({
|
||||
files: [{ path: filePath }],
|
||||
channelHints: { telegram: { disableNotification: true, parseMode: "HTML" } },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
channel: "telegram",
|
||||
deliveredTo: "12345",
|
||||
count: 1,
|
||||
});
|
||||
expect(workflowMocks.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(workflowMocks.sendMessage.mock.calls[0]?.[0]).toMatchObject({
|
||||
to: "12345",
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
threadId: 42,
|
||||
mediaUrls: [filePath],
|
||||
bestEffort: false,
|
||||
silent: true,
|
||||
parseMode: "HTML",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not use best-effort mode for attachment batches", async () => {
|
||||
await withSessionStore(async ({ storePath, stateDir }) => {
|
||||
const first = path.join(stateDir, "first.txt");
|
||||
const second = path.join(stateDir, "second.txt");
|
||||
await fs.writeFile(first, "1", "utf8");
|
||||
await fs.writeFile(second, "2", "utf8");
|
||||
await writeSessionEntry(storePath);
|
||||
mockSuccessfulAttachmentDelivery();
|
||||
|
||||
await expect(
|
||||
sendBundledSessionAttachment({
|
||||
files: [{ path: first }, { path: second }],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, channel: "telegram", count: 2 });
|
||||
expect(workflowMocks.sendMessage.mock.calls[0]?.[0]).toMatchObject({
|
||||
mediaUrls: [first, second],
|
||||
bestEffort: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes plain Telegram attachment captions before HTML delivery", async () => {
|
||||
await withSessionStore(async ({ storePath, filePath }) => {
|
||||
await writeSessionEntry(storePath);
|
||||
mockSuccessfulAttachmentDelivery();
|
||||
|
||||
await expect(
|
||||
sendBundledSessionAttachment({
|
||||
files: [{ path: filePath }],
|
||||
text: "1 < 2 & 3 > 2",
|
||||
captionFormat: "plain",
|
||||
channelHints: { telegram: { parseMode: "HTML" } },
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
channel: "telegram",
|
||||
deliveredTo: "12345",
|
||||
count: 1,
|
||||
});
|
||||
expect(workflowMocks.sendMessage.mock.calls[0]?.[0]).toMatchObject({
|
||||
content: "1 < 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
306
src/plugins/host-hook-attachments.ts
Normal file
306
src/plugins/host-hook-attachments.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { lstat, open } from "node:fs/promises";
|
||||
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { resolvePathFromInput } from "../agents/path-policy.js";
|
||||
import { resolveWorkspaceRoot } from "../agents/workspace-dir.js";
|
||||
import { extractDeliveryInfo } from "../config/sessions/delivery-info.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { detectMime, FILE_TYPE_SNIFF_MAX_BYTES, normalizeMimeType } from "../media/mime.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import type {
|
||||
PluginAttachmentChannelHints,
|
||||
PluginSessionAttachmentCaptionFormat,
|
||||
PluginSessionAttachmentParams,
|
||||
PluginSessionAttachmentResult,
|
||||
} from "./host-hooks.js";
|
||||
import type { PluginOrigin } from "./plugin-origin.types.js";
|
||||
|
||||
const DEFAULT_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
|
||||
const MAX_ATTACHMENT_FILES = 10;
|
||||
|
||||
type SendMessage = typeof import("../infra/outbound/message.js").sendMessage;
|
||||
let sendMessagePromise: Promise<SendMessage> | undefined;
|
||||
|
||||
async function loadSendMessage(): Promise<SendMessage> {
|
||||
sendMessagePromise ??= import("../infra/outbound/message.js").then(
|
||||
(module) => module.sendMessage,
|
||||
);
|
||||
return sendMessagePromise;
|
||||
}
|
||||
|
||||
type GetChannelPlugin = typeof import("../channels/plugins/index.js").getChannelPlugin;
|
||||
let getChannelPluginPromise: Promise<GetChannelPlugin> | undefined;
|
||||
|
||||
type AttachmentDeliveryChannelPlugin = {
|
||||
outbound?: {
|
||||
deliveryMode?: string;
|
||||
};
|
||||
};
|
||||
|
||||
async function loadGetChannelPlugin(): Promise<GetChannelPlugin> {
|
||||
getChannelPluginPromise ??= import("../channels/plugins/index.js").then(
|
||||
(module) => module.getChannelPlugin,
|
||||
);
|
||||
return getChannelPluginPromise;
|
||||
}
|
||||
|
||||
type ResolvedAttachmentDelivery = {
|
||||
parseMode?: "HTML";
|
||||
escapePlainHtmlCaption?: boolean;
|
||||
disableNotification?: boolean;
|
||||
forceDocumentMime?: string;
|
||||
threadTs?: string;
|
||||
};
|
||||
|
||||
function captionFormatToParseMode(
|
||||
captionFormat: PluginSessionAttachmentCaptionFormat | undefined,
|
||||
): "HTML" | undefined {
|
||||
if (captionFormat === "html") {
|
||||
return "HTML";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function escapeHtmlText(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
async function readMimeSniffBuffer(
|
||||
filePath: string,
|
||||
size: number,
|
||||
): Promise<Buffer | { error: string }> {
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(filePath, "r");
|
||||
const length = Math.min(Math.max(0, size), FILE_TYPE_SNIFF_MAX_BYTES);
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, 0);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `attachment file MIME read failed for ${filePath}: ${formatErrorMessage(error)}`,
|
||||
};
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAttachmentDelivery(params: {
|
||||
channel: string;
|
||||
captionFormat?: PluginSessionAttachmentCaptionFormat;
|
||||
channelHints?: PluginAttachmentChannelHints;
|
||||
}): ResolvedAttachmentDelivery {
|
||||
const fallbackParseMode = captionFormatToParseMode(params.captionFormat);
|
||||
const channel = params.channel.trim().toLowerCase();
|
||||
if (channel === "telegram") {
|
||||
const hint = params.channelHints?.telegram;
|
||||
const parseMode =
|
||||
hint?.parseMode ?? (params.captionFormat === "plain" ? "HTML" : fallbackParseMode);
|
||||
const escapePlainHtmlCaption = params.captionFormat === "plain" && parseMode === "HTML";
|
||||
const forceDocumentMime = normalizeMimeType(hint?.forceDocumentMime);
|
||||
return {
|
||||
...(parseMode ? { parseMode } : {}),
|
||||
...(escapePlainHtmlCaption ? { escapePlainHtmlCaption: true } : {}),
|
||||
...(hint?.disableNotification !== undefined
|
||||
? { disableNotification: hint.disableNotification }
|
||||
: {}),
|
||||
...(forceDocumentMime ? { forceDocumentMime } : {}),
|
||||
};
|
||||
}
|
||||
if (channel === "discord") {
|
||||
return fallbackParseMode ? { parseMode: fallbackParseMode } : {};
|
||||
}
|
||||
if (channel === "slack") {
|
||||
const hint = params.channelHints?.slack;
|
||||
const threadTs = normalizeOptionalString(hint?.threadTs);
|
||||
return {
|
||||
...(fallbackParseMode ? { parseMode: fallbackParseMode } : {}),
|
||||
...(threadTs ? { threadTs } : {}),
|
||||
};
|
||||
}
|
||||
return fallbackParseMode ? { parseMode: fallbackParseMode } : {};
|
||||
}
|
||||
|
||||
async function validateAttachmentFiles(
|
||||
files: PluginSessionAttachmentParams["files"],
|
||||
maxBytes: number,
|
||||
options?: {
|
||||
forceDocumentMime?: string;
|
||||
config?: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
},
|
||||
): Promise<string[] | { error: string }> {
|
||||
if (files.length > MAX_ATTACHMENT_FILES) {
|
||||
return { error: `at most ${MAX_ATTACHMENT_FILES} attachment files are allowed` };
|
||||
}
|
||||
const paths: string[] = [];
|
||||
let totalBytes = 0;
|
||||
for (const file of files) {
|
||||
if (!file || typeof file !== "object" || Array.isArray(file)) {
|
||||
return { error: "attachment file entry must be an object" };
|
||||
}
|
||||
const filePath = normalizeOptionalString((file as { path?: unknown }).path);
|
||||
if (!filePath) {
|
||||
return { error: "attachment file path is required" };
|
||||
}
|
||||
const resolvedPath = resolveAttachmentFilePath({
|
||||
filePath,
|
||||
config: options?.config,
|
||||
sessionKey: options?.sessionKey,
|
||||
});
|
||||
const info = await lstat(resolvedPath).catch(() => undefined);
|
||||
if (info?.isSymbolicLink()) {
|
||||
return { error: `attachment file symlinks are not allowed: ${resolvedPath}` };
|
||||
}
|
||||
if (!info?.isFile()) {
|
||||
return { error: `attachment file not found: ${resolvedPath}` };
|
||||
}
|
||||
if (info.size > maxBytes) {
|
||||
return { error: `attachment file exceeds ${maxBytes} bytes: ${resolvedPath}` };
|
||||
}
|
||||
if (options?.forceDocumentMime) {
|
||||
const fileBuffer = await readMimeSniffBuffer(resolvedPath, info.size);
|
||||
if (!Buffer.isBuffer(fileBuffer)) {
|
||||
return fileBuffer;
|
||||
}
|
||||
let detectedMime: string | undefined;
|
||||
try {
|
||||
detectedMime = normalizeMimeType(await detectMime({ buffer: fileBuffer }));
|
||||
} catch (error) {
|
||||
return {
|
||||
error:
|
||||
`attachment file MIME detection failed for ${filePath}: ` + formatErrorMessage(error),
|
||||
};
|
||||
}
|
||||
if (detectedMime !== options.forceDocumentMime) {
|
||||
return {
|
||||
error:
|
||||
`attachment file MIME mismatch for ${resolvedPath}: ` +
|
||||
`expected ${options.forceDocumentMime}, got ${detectedMime ?? "unknown"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
totalBytes += info.size;
|
||||
if (totalBytes > maxBytes) {
|
||||
return { error: `attachment files exceed ${maxBytes} bytes total` };
|
||||
}
|
||||
paths.push(resolvedPath);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function resolveAttachmentFilePath(params: {
|
||||
filePath: string;
|
||||
config?: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
}): string {
|
||||
const workspaceDir =
|
||||
params.sessionKey && params.config
|
||||
? resolveAgentWorkspaceDir(params.config, resolveAgentIdFromSessionKey(params.sessionKey))
|
||||
: undefined;
|
||||
return resolvePathFromInput(params.filePath, resolveWorkspaceRoot(workspaceDir));
|
||||
}
|
||||
|
||||
function normalizeOptionalThreadId(value: unknown): string | number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
|
||||
export async function sendPluginSessionAttachment(
|
||||
params: PluginSessionAttachmentParams & { config?: OpenClawConfig; origin?: PluginOrigin },
|
||||
): Promise<PluginSessionAttachmentResult> {
|
||||
if (params.origin !== "bundled") {
|
||||
return { ok: false, error: "session attachments are restricted to bundled plugins" };
|
||||
}
|
||||
const sessionKey = normalizeOptionalString(params.sessionKey);
|
||||
if (!sessionKey) {
|
||||
return { ok: false, error: "sessionKey is required" };
|
||||
}
|
||||
if (!Array.isArray(params.files) || params.files.length === 0) {
|
||||
return { ok: false, error: "at least one attachment file is required" };
|
||||
}
|
||||
const maxBytes =
|
||||
typeof params.maxBytes === "number" && Number.isFinite(params.maxBytes)
|
||||
? Math.min(DEFAULT_ATTACHMENT_MAX_BYTES, Math.max(1, Math.floor(params.maxBytes)))
|
||||
: DEFAULT_ATTACHMENT_MAX_BYTES;
|
||||
const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey, { cfg: params.config });
|
||||
if (!deliveryContext?.channel || !deliveryContext.to) {
|
||||
return { ok: false, error: `session has no active delivery route: ${sessionKey}` };
|
||||
}
|
||||
const normalizedChannel = normalizeMessageChannel(deliveryContext.channel);
|
||||
try {
|
||||
const deliveryPlugin =
|
||||
normalizedChannel && isDeliverableMessageChannel(normalizedChannel)
|
||||
? ((await loadGetChannelPlugin())(normalizedChannel) as
|
||||
| AttachmentDeliveryChannelPlugin
|
||||
| undefined)
|
||||
: undefined;
|
||||
if (deliveryPlugin?.outbound?.deliveryMode === "gateway") {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`session attachments require direct outbound delivery for channel ` +
|
||||
`${deliveryContext.channel}; channel uses gateway delivery`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `attachment delivery setup failed: ${formatErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
const rawText = normalizeOptionalString(params.text) ?? "";
|
||||
const explicitThreadId = normalizeOptionalThreadId(params.threadId);
|
||||
const deliveryThreadId = normalizeOptionalThreadId(deliveryContext.threadId);
|
||||
const fallbackThreadId = normalizeOptionalThreadId(threadId);
|
||||
const resolvedDelivery = resolveAttachmentDelivery({
|
||||
channel: deliveryContext.channel,
|
||||
captionFormat: params.captionFormat,
|
||||
channelHints: params.channelHints,
|
||||
});
|
||||
const validated = await validateAttachmentFiles(params.files, maxBytes, {
|
||||
forceDocumentMime: resolvedDelivery.forceDocumentMime,
|
||||
config: params.config,
|
||||
sessionKey,
|
||||
});
|
||||
if (!Array.isArray(validated)) {
|
||||
return { ok: false, error: validated.error };
|
||||
}
|
||||
const resolvedThreadId =
|
||||
resolvedDelivery.threadTs ?? explicitThreadId ?? fallbackThreadId ?? deliveryThreadId;
|
||||
let result: Awaited<ReturnType<SendMessage>>;
|
||||
try {
|
||||
const sendMessage = await loadSendMessage();
|
||||
result = await sendMessage({
|
||||
to: deliveryContext.to,
|
||||
content: resolvedDelivery.escapePlainHtmlCaption ? escapeHtmlText(rawText) : rawText,
|
||||
channel: deliveryContext.channel,
|
||||
accountId: deliveryContext.accountId,
|
||||
threadId: resolvedThreadId,
|
||||
requesterSessionKey: sessionKey,
|
||||
mediaUrls: validated,
|
||||
forceDocument: resolvedDelivery.forceDocumentMime ? true : params.forceDocument,
|
||||
bestEffort: false,
|
||||
cfg: params.config,
|
||||
...(resolvedDelivery.parseMode ? { parseMode: resolvedDelivery.parseMode } : {}),
|
||||
...(resolvedDelivery.disableNotification !== undefined
|
||||
? { silent: resolvedDelivery.disableNotification }
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, error: `attachment delivery failed: ${formatErrorMessage(error)}` };
|
||||
}
|
||||
if (!result.result) {
|
||||
return { ok: false, error: "attachment delivery failed: no delivery result returned" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
channel: result.channel,
|
||||
deliveredTo: deliveryContext.to,
|
||||
count: validated.length,
|
||||
};
|
||||
}
|
||||
@@ -305,6 +305,7 @@ export async function runPluginHostCleanup(params: {
|
||||
preserveSchedulerJobIds?: ReadonlySet<string>;
|
||||
shouldCleanup?: () => boolean;
|
||||
restartPromotedSessionEntrySlotKeys?: ReadonlySet<string>;
|
||||
preserveSchedulerOwnerRegistry?: PluginRegistry | null;
|
||||
}): Promise<PluginHostCleanupResult> {
|
||||
const failures: PluginHostCleanupFailure[] = [];
|
||||
const shouldCleanup = params.shouldCleanup ?? (() => true);
|
||||
@@ -408,6 +409,7 @@ export async function runPluginHostCleanup(params: {
|
||||
sessionKey: params.sessionKey,
|
||||
records: registry.sessionSchedulerJobs,
|
||||
preserveJobIds: params.preserveSchedulerJobIds,
|
||||
preserveOwnerRegistry: params.preserveSchedulerOwnerRegistry,
|
||||
shouldCleanup,
|
||||
});
|
||||
for (const failure of schedulerFailures) {
|
||||
@@ -538,6 +540,7 @@ export async function cleanupReplacedPluginHostRegistry(params: {
|
||||
pluginId,
|
||||
)
|
||||
: undefined,
|
||||
preserveSchedulerOwnerRegistry: restarted ? params.nextRegistry : undefined,
|
||||
});
|
||||
cleanupCount += result.cleanupCount;
|
||||
failures.push(...result.failures);
|
||||
|
||||
@@ -22,6 +22,7 @@ type SchedulerJobRecord = {
|
||||
pluginName?: string;
|
||||
job: PluginSessionSchedulerJobRegistration;
|
||||
generation: number;
|
||||
ownerRegistry?: PluginRegistry;
|
||||
};
|
||||
|
||||
type PluginHostRuntimeState = {
|
||||
@@ -356,6 +357,7 @@ export function registerPluginSessionSchedulerJob(params: {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
job: PluginSessionSchedulerJobRegistration;
|
||||
ownerRegistry?: PluginRegistry;
|
||||
}): PluginSessionSchedulerJobHandle | undefined {
|
||||
const id = normalizeOptionalString(params.job.id);
|
||||
const sessionKey = normalizeOptionalString(params.job.sessionKey);
|
||||
@@ -371,12 +373,13 @@ export function registerPluginSessionSchedulerJob(params: {
|
||||
pluginName: params.pluginName,
|
||||
job: { ...params.job, id, sessionKey, kind },
|
||||
generation,
|
||||
...(params.ownerRegistry ? { ownerRegistry: params.ownerRegistry } : {}),
|
||||
});
|
||||
state.schedulerJobsByPlugin.set(params.pluginId, jobs);
|
||||
return { id, pluginId: params.pluginId, sessionKey, kind };
|
||||
}
|
||||
|
||||
function deletePluginSessionSchedulerJob(params: {
|
||||
export function deletePluginSessionSchedulerJob(params: {
|
||||
pluginId: string;
|
||||
jobId: string;
|
||||
sessionKey?: string;
|
||||
@@ -450,6 +453,7 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
|
||||
preserveJobIds?: ReadonlySet<string>;
|
||||
excludeJobKeys?: ReadonlySet<string>;
|
||||
shouldCleanup?: () => boolean;
|
||||
preserveOwnerRegistry?: PluginRegistry | null;
|
||||
}): Promise<Array<{ pluginId: string; hookId: string; error: unknown }>> {
|
||||
const state = getPluginHostRuntimeState();
|
||||
const failures: Array<{ pluginId: string; hookId: string; error: unknown }> = [];
|
||||
@@ -457,6 +461,9 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
|
||||
if (!shouldCleanup()) {
|
||||
return failures;
|
||||
}
|
||||
const registryRecordKeys = new Set<string>();
|
||||
const schedulerJobKey = (pluginId: string, jobId: string, sessionKey: string) =>
|
||||
`${pluginId}\0${jobId}\0${sessionKey}`;
|
||||
if (params.records) {
|
||||
for (const record of params.records) {
|
||||
if (!shouldCleanup()) {
|
||||
@@ -473,6 +480,7 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
|
||||
if (params.sessionKey && sessionKey !== params.sessionKey) {
|
||||
continue;
|
||||
}
|
||||
registryRecordKeys.add(schedulerJobKey(record.pluginId, jobId, sessionKey));
|
||||
const liveGeneration = getPluginSessionSchedulerJobGeneration({
|
||||
pluginId: record.pluginId,
|
||||
jobId,
|
||||
@@ -530,7 +538,6 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
|
||||
expectedGeneration: record.generation,
|
||||
});
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
const pluginIds = params.pluginId ? [params.pluginId] : [...state.schedulerJobsByPlugin.keys()];
|
||||
for (const pluginId of pluginIds) {
|
||||
@@ -548,6 +555,15 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
|
||||
if (params.sessionKey && record.job.sessionKey !== params.sessionKey) {
|
||||
continue;
|
||||
}
|
||||
if (registryRecordKeys.has(schedulerJobKey(pluginId, jobId, record.job.sessionKey))) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
params.preserveOwnerRegistry !== undefined &&
|
||||
record.ownerRegistry === params.preserveOwnerRegistry
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (params.excludeJobKeys?.has(makePluginSessionSchedulerJobKey(pluginId, jobId))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
482
src/plugins/host-hook-scheduled-turns.ts
Normal file
482
src/plugins/host-hook-scheduled-turns.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ADMIN_SCOPE } from "../gateway/operator-scopes.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import {
|
||||
deletePluginSessionSchedulerJob,
|
||||
registerPluginSessionSchedulerJob,
|
||||
} from "./host-hook-runtime.js";
|
||||
import type {
|
||||
PluginSessionSchedulerJobHandle,
|
||||
PluginSessionTurnScheduleParams,
|
||||
PluginSessionTurnUnscheduleByTagParams,
|
||||
PluginSessionTurnUnscheduleByTagResult,
|
||||
} from "./host-hooks.js";
|
||||
import type { PluginOrigin } from "./plugin-origin.types.js";
|
||||
import type { PluginRegistry } from "./registry-types.js";
|
||||
|
||||
const log = createSubsystemLogger("plugins/host-scheduled-turns");
|
||||
const PLUGIN_CRON_NAME_PREFIX = "plugin:";
|
||||
const PLUGIN_CRON_TAG_MARKER = ":tag:";
|
||||
|
||||
type CallGatewayTool = typeof import("../agents/tools/gateway.js").callGatewayTool;
|
||||
let callGatewayToolPromise: Promise<CallGatewayTool> | undefined;
|
||||
|
||||
type ResolvedSessionTurnSchedule =
|
||||
| {
|
||||
kind: "cron";
|
||||
expr: string;
|
||||
tz?: string;
|
||||
}
|
||||
| {
|
||||
kind: "at";
|
||||
at: string;
|
||||
};
|
||||
|
||||
async function callGatewayToolLazy(
|
||||
...args: Parameters<CallGatewayTool>
|
||||
): Promise<Awaited<ReturnType<CallGatewayTool>>> {
|
||||
callGatewayToolPromise ??= import("../agents/tools/gateway.js").then(
|
||||
(module) => module.callGatewayTool,
|
||||
);
|
||||
const callGatewayTool = await callGatewayToolPromise;
|
||||
return callGatewayTool(...args);
|
||||
}
|
||||
|
||||
function resolveSchedule(
|
||||
params: PluginSessionTurnScheduleParams,
|
||||
): ResolvedSessionTurnSchedule | undefined {
|
||||
const cron = normalizeOptionalString((params as { cron?: unknown }).cron);
|
||||
if (cron) {
|
||||
const tz = normalizeOptionalString((params as { tz?: unknown }).tz);
|
||||
return {
|
||||
kind: "cron",
|
||||
expr: cron,
|
||||
...(tz ? { tz } : {}),
|
||||
};
|
||||
}
|
||||
if ("delayMs" in params) {
|
||||
if (!Number.isFinite(params.delayMs) || params.delayMs < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = Date.now() + Math.max(1, Math.floor(params.delayMs));
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return undefined;
|
||||
}
|
||||
const at = new Date(timestamp);
|
||||
if (!Number.isFinite(at.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind: "at", at: at.toISOString() };
|
||||
}
|
||||
const rawAt = (params as { at?: unknown }).at;
|
||||
const at = rawAt instanceof Date ? rawAt : new Date(rawAt as string | number | Date);
|
||||
if (!Number.isFinite(at.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind: "at", at: at.toISOString() };
|
||||
}
|
||||
|
||||
function resolveSessionTurnDeliveryMode(deliveryMode: unknown): "none" | "announce" | undefined {
|
||||
if (deliveryMode === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (deliveryMode === "none" || deliveryMode === "announce") {
|
||||
return deliveryMode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatScheduleLogContext(params: {
|
||||
pluginId: string;
|
||||
sessionKey?: string;
|
||||
name?: string;
|
||||
jobId?: string;
|
||||
}): string {
|
||||
const parts = [`pluginId=${params.pluginId}`];
|
||||
if (params.sessionKey) {
|
||||
parts.push(`sessionKey=${params.sessionKey}`);
|
||||
}
|
||||
if (params.name) {
|
||||
parts.push(`name=${params.name}`);
|
||||
}
|
||||
if (params.jobId) {
|
||||
parts.push(`jobId=${params.jobId}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
async function removeScheduledSessionTurn(params: {
|
||||
jobId: string;
|
||||
pluginId: string;
|
||||
sessionKey?: string;
|
||||
name?: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const result = await callGatewayToolLazy(
|
||||
"cron.remove",
|
||||
{},
|
||||
{ id: params.jobId },
|
||||
{ scopes: [ADMIN_SCOPE] },
|
||||
);
|
||||
return didCronCleanupJob(result);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`plugin session turn cleanup failed (${formatScheduleLogContext(params)}): ${formatErrorMessage(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapGatewayPayload(value: unknown): unknown {
|
||||
if (!isCronJobRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
const payload = value.payload;
|
||||
return isCronJobRecord(payload) ? payload : value;
|
||||
}
|
||||
|
||||
function didCronRemoveJob(value: unknown): boolean {
|
||||
const result = unwrapGatewayPayload(value);
|
||||
if (!isCronJobRecord(result)) {
|
||||
return false;
|
||||
}
|
||||
return result.ok !== false && result.removed === true;
|
||||
}
|
||||
|
||||
function didCronCleanupJob(value: unknown): boolean {
|
||||
const result = unwrapGatewayPayload(value);
|
||||
if (!isCronJobRecord(result) || result.ok === false) {
|
||||
return false;
|
||||
}
|
||||
return result.removed === true || result.removed === false;
|
||||
}
|
||||
|
||||
function normalizeCronJobId(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function extractCronJobId(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const topLevelId = normalizeCronJobId(record.jobId ?? record.id);
|
||||
if (topLevelId) {
|
||||
return topLevelId;
|
||||
}
|
||||
const payload =
|
||||
record.payload && typeof record.payload === "object"
|
||||
? (record.payload as Record<string, unknown>)
|
||||
: record;
|
||||
return normalizeCronJobId(payload.jobId ?? payload.id);
|
||||
}
|
||||
|
||||
const PLUGIN_CRON_RESERVED_DELIMITER = ":";
|
||||
|
||||
function resolvePluginSessionTurnTag(value: unknown): {
|
||||
tag?: string;
|
||||
invalid: boolean;
|
||||
} {
|
||||
const tag = normalizeOptionalString(value);
|
||||
if (!tag) {
|
||||
return { invalid: false };
|
||||
}
|
||||
if (tag.includes(PLUGIN_CRON_RESERVED_DELIMITER)) {
|
||||
return { invalid: true };
|
||||
}
|
||||
return { tag, invalid: false };
|
||||
}
|
||||
|
||||
export function buildPluginSchedulerCronName(params: {
|
||||
pluginId: string;
|
||||
sessionKey: string;
|
||||
tag?: string;
|
||||
uniqueId?: string;
|
||||
}): string {
|
||||
const uniqueId = params.uniqueId ?? randomUUID();
|
||||
if (!params.tag) {
|
||||
return `${PLUGIN_CRON_NAME_PREFIX}${params.pluginId}:${params.sessionKey}:${uniqueId}`;
|
||||
}
|
||||
return `${PLUGIN_CRON_NAME_PREFIX}${params.pluginId}${PLUGIN_CRON_TAG_MARKER}${params.tag}:${params.sessionKey}:${uniqueId}`;
|
||||
}
|
||||
|
||||
function buildPluginSchedulerTagPrefix(params: {
|
||||
pluginId: string;
|
||||
tag: string;
|
||||
sessionKey: string;
|
||||
}): string {
|
||||
return `${PLUGIN_CRON_NAME_PREFIX}${params.pluginId}${PLUGIN_CRON_TAG_MARKER}${params.tag}:${params.sessionKey}:`;
|
||||
}
|
||||
|
||||
function isCronJobRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readCronListJobs(value: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(isCronJobRecord);
|
||||
}
|
||||
if (isCronJobRecord(value)) {
|
||||
const jobs = (value as { jobs?: unknown }).jobs;
|
||||
if (Array.isArray(jobs)) {
|
||||
return jobs.filter(isCronJobRecord);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function readCronListNextOffset(value: unknown): number | undefined {
|
||||
if (!isCronJobRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const nextOffset = value.nextOffset;
|
||||
return typeof nextOffset === "number" && Number.isInteger(nextOffset) && nextOffset >= 0
|
||||
? nextOffset
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readCronListHasMore(value: unknown): boolean {
|
||||
return isCronJobRecord(value) && value.hasMore === true;
|
||||
}
|
||||
|
||||
async function listAllCronJobsForPluginTagCleanup(
|
||||
query: string,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const jobs: Record<string, unknown>[] = [];
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const listResult = await callGatewayToolLazy(
|
||||
"cron.list",
|
||||
{},
|
||||
{
|
||||
includeDisabled: true,
|
||||
limit: 200,
|
||||
query,
|
||||
sortBy: "name",
|
||||
sortDir: "asc",
|
||||
...(offset > 0 ? { offset } : {}),
|
||||
},
|
||||
{ scopes: [ADMIN_SCOPE] },
|
||||
);
|
||||
jobs.push(...readCronListJobs(listResult));
|
||||
if (!readCronListHasMore(listResult)) {
|
||||
return jobs;
|
||||
}
|
||||
const nextOffset = readCronListNextOffset(listResult);
|
||||
if (nextOffset === undefined || nextOffset <= offset) {
|
||||
return jobs;
|
||||
}
|
||||
offset = nextOffset;
|
||||
}
|
||||
}
|
||||
|
||||
export async function schedulePluginSessionTurn(params: {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
origin?: PluginOrigin;
|
||||
schedule: PluginSessionTurnScheduleParams;
|
||||
shouldCommit?: () => boolean;
|
||||
ownerRegistry?: PluginRegistry;
|
||||
}): Promise<PluginSessionSchedulerJobHandle | undefined> {
|
||||
if (params.origin !== "bundled") {
|
||||
return undefined;
|
||||
}
|
||||
const sessionKey = normalizeOptionalString(params.schedule.sessionKey);
|
||||
const message = normalizeOptionalString(params.schedule.message);
|
||||
if (!sessionKey || !message) {
|
||||
return undefined;
|
||||
}
|
||||
const cronSchedule = resolveSchedule(params.schedule);
|
||||
if (!cronSchedule) {
|
||||
return undefined;
|
||||
}
|
||||
const rawDeliveryMode = (params.schedule as { deliveryMode?: unknown }).deliveryMode;
|
||||
const deliveryMode = resolveSessionTurnDeliveryMode(rawDeliveryMode);
|
||||
const scheduleName = normalizeOptionalString(params.schedule.name);
|
||||
if (rawDeliveryMode !== undefined && !deliveryMode) {
|
||||
log.warn(
|
||||
`plugin session turn scheduling failed (${formatScheduleLogContext({
|
||||
pluginId: params.pluginId,
|
||||
sessionKey,
|
||||
...(scheduleName ? { name: scheduleName } : {}),
|
||||
})}): unsupported deliveryMode`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (cronSchedule.kind === "cron" && params.schedule.deleteAfterRun === true) {
|
||||
log.warn(
|
||||
`plugin session turn scheduling failed (${formatScheduleLogContext({
|
||||
pluginId: params.pluginId,
|
||||
sessionKey,
|
||||
...(scheduleName ? { name: scheduleName } : {}),
|
||||
})}): deleteAfterRun requires a one-shot schedule`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const { tag, invalid: invalidTag } = resolvePluginSessionTurnTag(params.schedule.tag);
|
||||
if (invalidTag) {
|
||||
log.warn(
|
||||
`plugin session turn scheduling failed (${formatScheduleLogContext({
|
||||
pluginId: params.pluginId,
|
||||
sessionKey,
|
||||
...(scheduleName ? { name: scheduleName } : {}),
|
||||
})}): tag contains reserved delimiter ":"`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const cronDeliveryMode = deliveryMode ?? "announce";
|
||||
if (params.shouldCommit && !params.shouldCommit()) {
|
||||
return undefined;
|
||||
}
|
||||
const cronJobName = buildPluginSchedulerCronName({
|
||||
pluginId: params.pluginId,
|
||||
sessionKey,
|
||||
...(tag !== undefined ? { tag } : {}),
|
||||
...(scheduleName ? { uniqueId: scheduleName } : {}),
|
||||
});
|
||||
const cronPayload: Record<string, unknown> = {
|
||||
kind: "agentTurn",
|
||||
message,
|
||||
};
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await callGatewayToolLazy(
|
||||
"cron.add",
|
||||
{},
|
||||
{
|
||||
name: cronJobName,
|
||||
schedule: cronSchedule,
|
||||
sessionTarget: `session:${sessionKey}`,
|
||||
payload: cronPayload,
|
||||
...(params.schedule.agentId ? { agentId: params.schedule.agentId } : {}),
|
||||
deleteAfterRun: params.schedule.deleteAfterRun ?? cronSchedule.kind === "at",
|
||||
wakeMode: "now",
|
||||
delivery: {
|
||||
mode: cronDeliveryMode,
|
||||
...(cronDeliveryMode === "announce" ? { channel: "last" } : {}),
|
||||
},
|
||||
},
|
||||
{ scopes: [ADMIN_SCOPE] },
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`plugin session turn scheduling failed (${formatScheduleLogContext({
|
||||
pluginId: params.pluginId,
|
||||
sessionKey,
|
||||
name: cronJobName,
|
||||
})}): ${formatErrorMessage(error)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const jobId = extractCronJobId(result);
|
||||
if (!jobId) {
|
||||
return undefined;
|
||||
}
|
||||
if (params.shouldCommit && !params.shouldCommit()) {
|
||||
const removed = await removeScheduledSessionTurn({
|
||||
jobId,
|
||||
pluginId: params.pluginId,
|
||||
sessionKey,
|
||||
name: cronJobName,
|
||||
});
|
||||
if (!removed) {
|
||||
log.warn(
|
||||
`plugin session turn scheduling rollback failed (${formatScheduleLogContext({
|
||||
pluginId: params.pluginId,
|
||||
sessionKey,
|
||||
name: cronJobName,
|
||||
jobId,
|
||||
})}): failed to remove stale scheduled session turn`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const handle = registerPluginSessionSchedulerJob({
|
||||
pluginId: params.pluginId,
|
||||
pluginName: params.pluginName,
|
||||
ownerRegistry: params.ownerRegistry,
|
||||
job: {
|
||||
id: jobId,
|
||||
sessionKey,
|
||||
kind: "session-turn",
|
||||
cleanup: async () => {
|
||||
const removed = await removeScheduledSessionTurn({
|
||||
jobId,
|
||||
pluginId: params.pluginId,
|
||||
sessionKey,
|
||||
name: cronJobName,
|
||||
});
|
||||
if (!removed) {
|
||||
throw new Error(`failed to remove scheduled session turn: ${jobId}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
}
|
||||
|
||||
export async function unschedulePluginSessionTurnsByTag(params: {
|
||||
pluginId: string;
|
||||
origin?: PluginOrigin;
|
||||
request: PluginSessionTurnUnscheduleByTagParams;
|
||||
}): Promise<PluginSessionTurnUnscheduleByTagResult> {
|
||||
if (params.origin !== "bundled") {
|
||||
return { removed: 0, failed: 0 };
|
||||
}
|
||||
const sessionKey = normalizeOptionalString(params.request.sessionKey);
|
||||
const { tag, invalid: invalidTag } = resolvePluginSessionTurnTag(params.request.tag);
|
||||
if (!sessionKey || !tag || invalidTag) {
|
||||
return { removed: 0, failed: 0 };
|
||||
}
|
||||
const namePrefix = buildPluginSchedulerTagPrefix({
|
||||
pluginId: params.pluginId,
|
||||
tag,
|
||||
sessionKey,
|
||||
});
|
||||
let jobs: Record<string, unknown>[];
|
||||
try {
|
||||
jobs = await listAllCronJobsForPluginTagCleanup(namePrefix);
|
||||
} catch (error) {
|
||||
log.warn(`plugin session turn untag-list failed: ${formatErrorMessage(error)}`);
|
||||
return { removed: 0, failed: 1 };
|
||||
}
|
||||
const candidates = jobs.filter((job) => {
|
||||
const name = typeof job.name === "string" ? job.name : "";
|
||||
const target = typeof job.sessionTarget === "string" ? job.sessionTarget : "";
|
||||
return name.startsWith(namePrefix) && target === `session:${sessionKey}`;
|
||||
});
|
||||
let removed = 0;
|
||||
let failed = 0;
|
||||
for (const job of candidates) {
|
||||
const id = typeof job.id === "string" ? job.id.trim() : "";
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await callGatewayToolLazy(
|
||||
"cron.remove",
|
||||
{},
|
||||
{ id },
|
||||
{ scopes: [ADMIN_SCOPE] },
|
||||
);
|
||||
if (didCronRemoveJob(result)) {
|
||||
removed += 1;
|
||||
deletePluginSessionSchedulerJob({
|
||||
pluginId: params.pluginId,
|
||||
jobId: id,
|
||||
sessionKey,
|
||||
});
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`plugin session turn untag-remove failed: id=${id} error=${formatErrorMessage(error)}`,
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return { removed, failed };
|
||||
}
|
||||
@@ -110,6 +110,41 @@ export type PluginControlUiDescriptor = {
|
||||
requiredScopes?: OperatorScope[];
|
||||
};
|
||||
|
||||
export type PluginSessionActionContext = {
|
||||
pluginId: string;
|
||||
actionId: string;
|
||||
sessionKey?: string;
|
||||
payload?: PluginJsonValue;
|
||||
client?: {
|
||||
connId?: string;
|
||||
scopes: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginSessionActionResult =
|
||||
| {
|
||||
ok?: true;
|
||||
result?: PluginJsonValue;
|
||||
reply?: PluginJsonValue;
|
||||
continueAgent?: boolean;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
code?: string;
|
||||
details?: PluginJsonValue;
|
||||
};
|
||||
|
||||
export type PluginSessionActionRegistration = {
|
||||
id: string;
|
||||
description?: string;
|
||||
schema?: PluginJsonValue;
|
||||
requiredScopes?: OperatorScope[];
|
||||
handler: (
|
||||
ctx: PluginSessionActionContext,
|
||||
) => PluginSessionActionResult | void | Promise<PluginSessionActionResult | void>;
|
||||
};
|
||||
|
||||
export type PluginRuntimeLifecycleRegistration = {
|
||||
id: string;
|
||||
description?: string;
|
||||
@@ -137,6 +172,17 @@ export type PluginAgentEventSubscriptionRegistration = {
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type PluginAgentEventEmitParams = {
|
||||
runId: string;
|
||||
stream: AgentEventStream;
|
||||
data: PluginJsonValue;
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
export type PluginAgentEventEmitResult =
|
||||
| { emitted: true; stream: AgentEventStream }
|
||||
| { emitted: false; reason: string };
|
||||
|
||||
export type PluginRunContextPatch = {
|
||||
runId: string;
|
||||
namespace: string;
|
||||
@@ -168,6 +214,87 @@ export type PluginSessionSchedulerJobHandle = {
|
||||
kind: string;
|
||||
};
|
||||
|
||||
export type PluginSessionAttachmentFile = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type PluginAttachmentChannelHints = {
|
||||
telegram?: {
|
||||
parseMode?: "HTML";
|
||||
disableNotification?: boolean;
|
||||
/**
|
||||
* Require host-side detection to match this MIME before forcing document delivery.
|
||||
* Mismatched files are rejected before the outbound adapter is called.
|
||||
*/
|
||||
forceDocumentMime?: string;
|
||||
};
|
||||
slack?: {
|
||||
threadTs?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginSessionAttachmentCaptionFormat = "plain" | "html" | "markdown";
|
||||
|
||||
export type PluginSessionAttachmentParams = {
|
||||
sessionKey: string;
|
||||
files: PluginSessionAttachmentFile[];
|
||||
text?: string;
|
||||
threadId?: string | number;
|
||||
forceDocument?: boolean;
|
||||
maxBytes?: number;
|
||||
captionFormat?: PluginSessionAttachmentCaptionFormat;
|
||||
channelHints?: PluginAttachmentChannelHints;
|
||||
};
|
||||
|
||||
export type PluginSessionAttachmentResult =
|
||||
| {
|
||||
ok: true;
|
||||
channel: string;
|
||||
deliveredTo: string;
|
||||
count: number;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type PluginSessionTurnSchedule =
|
||||
| { at: string | number | Date }
|
||||
| { delayMs: number }
|
||||
| { cron: string; tz?: string };
|
||||
|
||||
type PluginSessionTurnScheduleCommonParams = {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
agentId?: string;
|
||||
deliveryMode?: "none" | "announce";
|
||||
name?: string;
|
||||
/** Optional cleanup tag. Reserved cron-name delimiters like `:` are rejected. */
|
||||
tag?: string;
|
||||
};
|
||||
|
||||
export type PluginSessionTurnScheduleParams =
|
||||
| ({
|
||||
at: string | number | Date;
|
||||
deleteAfterRun?: boolean;
|
||||
} & PluginSessionTurnScheduleCommonParams)
|
||||
| ({
|
||||
delayMs: number;
|
||||
deleteAfterRun?: boolean;
|
||||
} & PluginSessionTurnScheduleCommonParams)
|
||||
| ({
|
||||
cron: string;
|
||||
tz?: string;
|
||||
deleteAfterRun?: false;
|
||||
} & PluginSessionTurnScheduleCommonParams);
|
||||
|
||||
export type PluginSessionTurnUnscheduleByTagParams = {
|
||||
sessionKey: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
export type PluginSessionTurnUnscheduleByTagResult = {
|
||||
removed: number;
|
||||
failed: number;
|
||||
};
|
||||
|
||||
export function normalizePluginHostHookId(value: string | undefined): string {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolvePluginActivationSourceConfig } from "./activation-source-config.js";
|
||||
import { buildPluginApi } from "./api-builder.js";
|
||||
import { attachPluginApiFacades } from "./api-facades.js";
|
||||
import { isLateCallablePluginApiMethod } from "./api-lifecycle.js";
|
||||
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
|
||||
import {
|
||||
clearPluginCommands,
|
||||
@@ -349,6 +351,7 @@ type PluginRegistrySnapshot = {
|
||||
securityAuditCollectors: NonNullable<PluginRegistry["securityAuditCollectors"]>;
|
||||
services: PluginRegistry["services"];
|
||||
commands: PluginRegistry["commands"];
|
||||
sessionActions: NonNullable<PluginRegistry["sessionActions"]>;
|
||||
conversationBindingResolvedHandlers: PluginRegistry["conversationBindingResolvedHandlers"];
|
||||
diagnostics: PluginRegistry["diagnostics"];
|
||||
};
|
||||
@@ -391,6 +394,7 @@ function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapsho
|
||||
securityAuditCollectors: [...(registry.securityAuditCollectors ?? [])],
|
||||
services: [...registry.services],
|
||||
commands: [...registry.commands],
|
||||
sessionActions: [...(registry.sessionActions ?? [])],
|
||||
conversationBindingResolvedHandlers: [...registry.conversationBindingResolvedHandlers],
|
||||
diagnostics: [...registry.diagnostics],
|
||||
},
|
||||
@@ -432,6 +436,7 @@ function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistr
|
||||
registry.securityAuditCollectors = snapshot.arrays.securityAuditCollectors;
|
||||
registry.services = snapshot.arrays.services;
|
||||
registry.commands = snapshot.arrays.commands;
|
||||
registry.sessionActions = snapshot.arrays.sessionActions;
|
||||
registry.conversationBindingResolvedHandlers =
|
||||
snapshot.arrays.conversationBindingResolvedHandlers;
|
||||
registry.diagnostics = snapshot.arrays.diagnostics;
|
||||
@@ -445,21 +450,29 @@ function createGuardedPluginRegistrationApi(api: OpenClawPluginApi): {
|
||||
close: () => void;
|
||||
} {
|
||||
let closed = false;
|
||||
return {
|
||||
api: new Proxy(api, {
|
||||
const guardedApi = attachPluginApiFacades(
|
||||
new Proxy(api, {
|
||||
get(target, prop, receiver) {
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
if (typeof value !== "function") {
|
||||
return value;
|
||||
}
|
||||
if (typeof prop === "string" && isLateCallablePluginApiMethod(prop)) {
|
||||
return (...args: unknown[]) => Reflect.apply(value, target, args);
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (closed) {
|
||||
const isLateCallableMethod =
|
||||
typeof prop === "string" && isLateCallablePluginApiMethod(prop);
|
||||
if (closed && !isLateCallableMethod) {
|
||||
return undefined;
|
||||
}
|
||||
return Reflect.apply(value, target, args);
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
return {
|
||||
api: guardedApi,
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
@@ -573,6 +586,8 @@ export const __testing = {
|
||||
shouldLoadChannelPluginInSetupRuntime,
|
||||
shouldPreferNativeModuleLoad,
|
||||
toSafeImportPath,
|
||||
createGuardedPluginRegistrationApi,
|
||||
runPluginRegisterSync,
|
||||
getCompatibleActivePluginRegistry,
|
||||
resolvePluginLoadCacheContext,
|
||||
get maxPluginRegistryCacheEntries() {
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
runtimeLifecycles: [],
|
||||
agentEventSubscriptions: [],
|
||||
sessionSchedulerJobs: [],
|
||||
sessionActions: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginRegistry } from "./registry-types.js";
|
||||
|
||||
const retiredRegistries = new WeakSet<PluginRegistry>();
|
||||
const activatedRegistries = new WeakSet<PluginRegistry>();
|
||||
|
||||
export function markPluginRegistryRetired(registry: PluginRegistry | null | undefined): void {
|
||||
if (registry) {
|
||||
@@ -10,10 +11,15 @@ export function markPluginRegistryRetired(registry: PluginRegistry | null | unde
|
||||
|
||||
export function markPluginRegistryActive(registry: PluginRegistry | null | undefined): void {
|
||||
if (registry) {
|
||||
activatedRegistries.add(registry);
|
||||
retiredRegistries.delete(registry);
|
||||
}
|
||||
}
|
||||
|
||||
export function isPluginRegistryActivated(registry: PluginRegistry): boolean {
|
||||
return activatedRegistries.has(registry);
|
||||
}
|
||||
|
||||
export function isPluginRegistryRetired(registry: PluginRegistry): boolean {
|
||||
return retiredRegistries.has(registry);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import { isOperatorScope, type OperatorScope } from "../gateway/operator-scopes.
|
||||
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
||||
import { registerInternalHook, unregisterInternalHook } from "../hooks/internal-hooks.js";
|
||||
import type { HookEntry } from "../hooks/types.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import {
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_SYSTEM_NOTIFY_COMMAND,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
registerDetachedTaskLifecycleRuntime,
|
||||
} from "../tasks/detached-task-runtime-state.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { emitPluginAgentEvent } from "./agent-event-emission.js";
|
||||
import type { AgentToolResultMiddleware } from "./agent-tool-result-middleware-types.js";
|
||||
import {
|
||||
normalizeAgentToolResultMiddlewareRuntimeIds,
|
||||
@@ -59,6 +61,7 @@ import {
|
||||
getRegisteredCompactionProvider,
|
||||
registerCompactionProvider,
|
||||
} from "./compaction-provider.js";
|
||||
import { sendPluginSessionAttachment } from "./host-hook-attachments.js";
|
||||
import {
|
||||
clearPluginRunContext,
|
||||
getPluginRunContext,
|
||||
@@ -66,6 +69,10 @@ import {
|
||||
registerPluginSessionSchedulerJob,
|
||||
setPluginRunContext,
|
||||
} from "./host-hook-runtime.js";
|
||||
import {
|
||||
schedulePluginSessionTurn,
|
||||
unschedulePluginSessionTurnsByTag,
|
||||
} from "./host-hook-scheduled-turns.js";
|
||||
import { enqueuePluginNextTurnInjection } from "./host-hook-state.js";
|
||||
import {
|
||||
isPluginJsonValue,
|
||||
@@ -73,6 +80,7 @@ import {
|
||||
type PluginAgentEventSubscriptionRegistration,
|
||||
type PluginControlUiDescriptor,
|
||||
type PluginRuntimeLifecycleRegistration,
|
||||
type PluginSessionActionRegistration,
|
||||
type PluginSessionSchedulerJobRegistration,
|
||||
type PluginSessionExtensionRegistration,
|
||||
type PluginToolMetadataRegistration,
|
||||
@@ -100,15 +108,25 @@ import {
|
||||
import { createModelCatalogRegistrationHandlers } from "./model-catalog-registration.js";
|
||||
import { normalizeRegisteredProvider } from "./provider-validation.js";
|
||||
import { createEmptyPluginRegistry } from "./registry-empty.js";
|
||||
import { isPluginRegistryRetired } from "./registry-lifecycle.js";
|
||||
import { isPluginRegistryActivated, isPluginRegistryRetired } from "./registry-lifecycle.js";
|
||||
import type {
|
||||
PluginHttpRouteRegistration as RegistryTypesPluginHttpRouteRegistration,
|
||||
PluginRecord,
|
||||
PluginRegistryParams,
|
||||
PluginSessionActionRegistryRegistration,
|
||||
PluginTextTransformsRegistration,
|
||||
} from "./registry-types.js";
|
||||
export type {
|
||||
PluginReloadRegistration,
|
||||
PluginRuntimeLifecycleRegistryRegistration,
|
||||
PluginSecurityAuditCollectorRegistration,
|
||||
PluginServiceRegistration,
|
||||
PluginSessionExtensionRegistryRegistration,
|
||||
} from "./registry-types.js";
|
||||
import { getActivePluginRegistry } from "./runtime.js";
|
||||
import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import { validateJsonSchemaValue, type JsonSchemaValue } from "./schema-validator.js";
|
||||
import { normalizeSessionEntrySlotKey } from "./session-entry-slot-keys.js";
|
||||
import { defaultSlotIdForKey, hasKind } from "./slots.js";
|
||||
import {
|
||||
@@ -185,14 +203,9 @@ export type {
|
||||
PluginProviderRegistration,
|
||||
PluginControlUiDescriptorRegistryRegistration,
|
||||
PluginHostedMediaResolverRegistration,
|
||||
PluginRuntimeLifecycleRegistryRegistration,
|
||||
PluginRecord,
|
||||
PluginRegistry,
|
||||
PluginRegistryParams,
|
||||
PluginReloadRegistration,
|
||||
PluginSecurityAuditCollectorRegistration,
|
||||
PluginServiceRegistration,
|
||||
PluginSessionExtensionRegistryRegistration,
|
||||
PluginTextTransformsRegistration,
|
||||
PluginToolMetadataRegistryRegistration,
|
||||
PluginTrustedToolPolicyRegistryRegistration,
|
||||
@@ -1678,6 +1691,54 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
return normalized as string[];
|
||||
};
|
||||
|
||||
const validateSessionActionSchema = (
|
||||
record: PluginRecord,
|
||||
id: string,
|
||||
schema: unknown,
|
||||
): schema is JsonSchemaValue => {
|
||||
if (schema === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (!isPluginJsonValue(schema)) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `session action schema must be JSON-compatible: ${id}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof schema !== "boolean" &&
|
||||
(!schema || typeof schema !== "object" || Array.isArray(schema))
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `session action schema must be a JSON schema object or boolean: ${id}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
validateJsonSchemaValue({
|
||||
schema,
|
||||
cacheKey: `plugin-session-action-registration:${record.id}:${id}`,
|
||||
value: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `session action schema is not valid JSON Schema: ${id}: ${message}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const controlUiSurfaces = new Set<PluginControlUiDescriptor["surface"]>([
|
||||
"session",
|
||||
"tool",
|
||||
@@ -2114,6 +2175,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const handle = registerPluginSessionSchedulerJob({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
ownerRegistry: registry,
|
||||
job: { ...job, id: jobId, sessionKey, kind },
|
||||
});
|
||||
if (!handle) {
|
||||
@@ -2140,6 +2202,67 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
return handle;
|
||||
};
|
||||
|
||||
const registerSessionAction = (record: PluginRecord, action: PluginSessionActionRegistration) => {
|
||||
const id = normalizeHostHookString(action.id);
|
||||
const description = normalizeOptionalHostHookString(action.description);
|
||||
const requiredScopes = normalizeHostHookStringList(action.requiredScopes);
|
||||
if (
|
||||
!id ||
|
||||
description === "" ||
|
||||
requiredScopes === null ||
|
||||
typeof action.handler !== "function"
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "session action registration requires id, handler, and valid optional fields",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (requiredScopes !== undefined) {
|
||||
const unknownScope = requiredScopes.find((scope) => !isOperatorScope(scope));
|
||||
if (unknownScope !== undefined) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `session action requiredScopes contains unknown operator scope: ${unknownScope}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!validateSessionActionSchema(record, id, action.schema)) {
|
||||
return;
|
||||
}
|
||||
const existing = (registry.sessionActions ?? []).find(
|
||||
(entry) => entry.pluginId === record.id && entry.action.id === id,
|
||||
);
|
||||
if (existing) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `session action already registered: ${id}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
(registry.sessionActions ??= []).push({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
action: {
|
||||
...action,
|
||||
id,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
...(requiredScopes !== undefined
|
||||
? { requiredScopes: requiredScopes as OperatorScope[] }
|
||||
: {}),
|
||||
},
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
} satisfies PluginSessionActionRegistryRegistration);
|
||||
};
|
||||
|
||||
const registerTypedHook = <K extends PluginHookName>(
|
||||
record: PluginRecord,
|
||||
hookName: K,
|
||||
@@ -2303,6 +2426,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const sideEffectGuard = createPluginSideEffectGuard(record.id);
|
||||
const isLoadedRecordInRegistry = () =>
|
||||
registry.plugins.some((plugin) => plugin.id === record.id && plugin.status === "loaded");
|
||||
const isLoadedRecordInActiveRegistry = () =>
|
||||
getActivePluginRegistry() === registry && isLoadedRecordInRegistry();
|
||||
const isActivatingLoadedRecord = () =>
|
||||
registryParams.activateGlobalSideEffects !== false &&
|
||||
record.enabled &&
|
||||
@@ -2311,7 +2436,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const shouldCommitWorkflowSideEffect = () =>
|
||||
sideEffectGuard.active &&
|
||||
!isPluginRegistryRetired(registry) &&
|
||||
(isLoadedRecordInRegistry() || isActivatingLoadedRecord());
|
||||
(isActivatingLoadedRecord() ||
|
||||
(isPluginRegistryActivated(registry) && isLoadedRecordInRegistry()));
|
||||
return buildPluginApi({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
@@ -2525,6 +2651,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerRuntimeLifecycle: (lifecycle) => registerRuntimeLifecycle(record, lifecycle),
|
||||
registerAgentEventSubscription: (subscription) =>
|
||||
registerAgentEventSubscription(record, subscription),
|
||||
emitAgentEvent: (event) => {
|
||||
if (registryParams.activateGlobalSideEffects === false) {
|
||||
return { emitted: false, reason: "global side effects disabled" };
|
||||
}
|
||||
if (!shouldCommitWorkflowSideEffect()) {
|
||||
return { emitted: false, reason: "plugin is not loaded" };
|
||||
}
|
||||
return emitPluginAgentEvent({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
origin: record.origin,
|
||||
event,
|
||||
});
|
||||
},
|
||||
setRunContext: (patch) =>
|
||||
registryParams.activateGlobalSideEffects !== false &&
|
||||
shouldCommitWorkflowSideEffect()
|
||||
@@ -2545,6 +2685,58 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
});
|
||||
},
|
||||
registerSessionSchedulerJob: (job) => registerSessionSchedulerJob(record, job),
|
||||
registerSessionAction: (action) => registerSessionAction(record, action),
|
||||
sendSessionAttachment: async (attachment) => {
|
||||
if (registryParams.activateGlobalSideEffects === false) {
|
||||
return { ok: false, error: "global side effects disabled" };
|
||||
}
|
||||
try {
|
||||
if (!isLoadedRecordInActiveRegistry()) {
|
||||
return { ok: false, error: "plugin is not loaded" };
|
||||
}
|
||||
const runtimeConfig =
|
||||
(registryParams.runtime.config?.current?.() as OpenClawConfig | undefined) ??
|
||||
params.config;
|
||||
return await sendPluginSessionAttachment({
|
||||
...attachment,
|
||||
config: runtimeConfig,
|
||||
origin: record.origin,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `attachment delivery setup failed: ${formatErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
scheduleSessionTurn: async (schedule) => {
|
||||
if (registryParams.activateGlobalSideEffects === false) {
|
||||
return undefined;
|
||||
}
|
||||
await Promise.resolve();
|
||||
return schedulePluginSessionTurn({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
origin: record.origin,
|
||||
schedule,
|
||||
shouldCommit: isLoadedRecordInActiveRegistry,
|
||||
ownerRegistry: registry,
|
||||
});
|
||||
},
|
||||
unscheduleSessionTurnsByTag: async (request) => {
|
||||
if (registryParams.activateGlobalSideEffects === false) {
|
||||
return { removed: 0, failed: 0 };
|
||||
}
|
||||
await Promise.resolve();
|
||||
if (!isLoadedRecordInActiveRegistry()) {
|
||||
return { removed: 0, failed: 0 };
|
||||
}
|
||||
return unschedulePluginSessionTurnsByTag({
|
||||
pluginId: record.id,
|
||||
origin: record.origin,
|
||||
request,
|
||||
});
|
||||
},
|
||||
registerMemoryCapability: (capability) => {
|
||||
if (!hasKind(record.kind, "memory")) {
|
||||
throwRegistrationError("only memory plugins can register a memory capability");
|
||||
@@ -2771,6 +2963,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerRuntimeLifecycle,
|
||||
registerAgentEventSubscription,
|
||||
registerSessionSchedulerJob,
|
||||
registerSessionAction,
|
||||
registerHook,
|
||||
registerTypedHook,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -38,6 +38,7 @@ const state: RegistryState = (() => {
|
||||
pinned: false,
|
||||
version: 0,
|
||||
},
|
||||
agentEventBridgeUnsubscribe: undefined,
|
||||
key: null,
|
||||
workspaceDir: null,
|
||||
runtimeSubagentMode: "default",
|
||||
@@ -48,8 +49,6 @@ const state: RegistryState = (() => {
|
||||
return registryState;
|
||||
})();
|
||||
|
||||
let pluginAgentEventUnsubscribe: (() => void) | undefined;
|
||||
|
||||
function registryHasPluginHostCleanupWork(registry: PluginRegistry | null): boolean {
|
||||
if (!registry) {
|
||||
return false;
|
||||
@@ -63,6 +62,17 @@ function registryHasPluginHostCleanupWork(registry: PluginRegistry | null): bool
|
||||
);
|
||||
}
|
||||
|
||||
function isRegistryPinned(registry: PluginRegistry): boolean {
|
||||
return (
|
||||
(state.httpRoute.pinned && state.httpRoute.registry === registry) ||
|
||||
(state.channel.pinned && state.channel.registry === registry)
|
||||
);
|
||||
}
|
||||
|
||||
function isRegistryLive(registry: PluginRegistry): boolean {
|
||||
return state.activeRegistry === registry || isRegistryPinned(registry);
|
||||
}
|
||||
|
||||
async function cleanupPreviousPluginHostRegistry(params: {
|
||||
previousRegistry: PluginRegistry;
|
||||
}): Promise<void> {
|
||||
@@ -85,14 +95,51 @@ async function cleanupPreviousPluginHostRegistry(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function syncPluginAgentEventBridge(registry: PluginRegistry | null): void {
|
||||
pluginAgentEventUnsubscribe?.();
|
||||
pluginAgentEventUnsubscribe = undefined;
|
||||
if (!registry) {
|
||||
function cleanupRetiredPluginHostRegistry(previousRegistry: PluginRegistry): void {
|
||||
if (!registryHasPluginHostCleanupWork(previousRegistry)) {
|
||||
return;
|
||||
}
|
||||
pluginAgentEventUnsubscribe = onAgentEvent((event) => {
|
||||
dispatchPluginAgentEventSubscriptions({ registry: state.activeRegistry, event });
|
||||
void cleanupPreviousPluginHostRegistry({
|
||||
previousRegistry,
|
||||
}).catch((error) => {
|
||||
log.warn(`plugin host registry cleanup failed: ${String(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function retirePluginRegistryIfUnused(registry: PluginRegistry | null): boolean {
|
||||
if (!registry || isRegistryLive(registry)) {
|
||||
return false;
|
||||
}
|
||||
markPluginRegistryRetired(registry);
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectLivePluginAgentEventRegistries(): PluginRegistry[] {
|
||||
const registries: PluginRegistry[] = [];
|
||||
const seen = new Set<PluginRegistry>();
|
||||
const addRegistry = (registry: PluginRegistry | null) => {
|
||||
if (!registry || seen.has(registry)) {
|
||||
return;
|
||||
}
|
||||
seen.add(registry);
|
||||
registries.push(registry);
|
||||
};
|
||||
addRegistry(asPluginRegistry(state.activeRegistry));
|
||||
addRegistry(asPluginRegistry(state.httpRoute.registry));
|
||||
addRegistry(asPluginRegistry(state.channel.registry));
|
||||
return registries;
|
||||
}
|
||||
|
||||
function syncPluginAgentEventBridge(): void {
|
||||
state.agentEventBridgeUnsubscribe?.();
|
||||
state.agentEventBridgeUnsubscribe = undefined;
|
||||
if (collectLivePluginAgentEventRegistries().length === 0) {
|
||||
return;
|
||||
}
|
||||
state.agentEventBridgeUnsubscribe = onAgentEvent((event) => {
|
||||
for (const registry of collectLivePluginAgentEventRegistries()) {
|
||||
dispatchPluginAgentEventSubscriptions({ registry, event });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,30 +184,22 @@ export function setActivePluginRegistry(
|
||||
workspaceDir?: string,
|
||||
) {
|
||||
const previousRegistry = asPluginRegistry(state.activeRegistry);
|
||||
if (previousRegistry && previousRegistry !== registry) {
|
||||
markPluginRegistryRetired(previousRegistry);
|
||||
}
|
||||
markPluginRegistryActive(registry);
|
||||
state.activeRegistry = registry;
|
||||
markPluginRegistryActive(registry);
|
||||
state.activeVersion += 1;
|
||||
syncTrackedSurface(state.httpRoute, registry, true);
|
||||
syncTrackedSurface(state.channel, registry, true);
|
||||
state.key = cacheKey ?? null;
|
||||
state.workspaceDir = workspaceDir ?? null;
|
||||
state.runtimeSubagentMode = runtimeSubagentMode;
|
||||
syncPluginAgentEventBridge(registry);
|
||||
if (
|
||||
!previousRegistry ||
|
||||
previousRegistry === registry ||
|
||||
!registryHasPluginHostCleanupWork(previousRegistry)
|
||||
) {
|
||||
syncPluginAgentEventBridge();
|
||||
if (!previousRegistry || previousRegistry === registry) {
|
||||
return;
|
||||
}
|
||||
void cleanupPreviousPluginHostRegistry({
|
||||
previousRegistry,
|
||||
}).catch((error) => {
|
||||
log.warn(`plugin host registry cleanup failed: ${String(error)}`);
|
||||
});
|
||||
if (!retirePluginRegistryIfUnused(previousRegistry)) {
|
||||
return;
|
||||
}
|
||||
cleanupRetiredPluginHostRegistry(previousRegistry);
|
||||
}
|
||||
|
||||
export function getActivePluginRegistry(): PluginRegistry | null {
|
||||
@@ -174,6 +213,7 @@ export function getActivePluginRegistryWorkspaceDir(): string | undefined {
|
||||
export function requireActivePluginRegistry(): PluginRegistry {
|
||||
if (!state.activeRegistry) {
|
||||
state.activeRegistry = createEmptyPluginRegistry();
|
||||
markPluginRegistryActive(state.activeRegistry);
|
||||
state.activeVersion += 1;
|
||||
syncTrackedSurface(state.httpRoute, state.activeRegistry);
|
||||
syncTrackedSurface(state.channel, state.activeRegistry);
|
||||
@@ -182,14 +222,25 @@ export function requireActivePluginRegistry(): PluginRegistry {
|
||||
}
|
||||
|
||||
export function pinActivePluginHttpRouteRegistry(registry: PluginRegistry) {
|
||||
const previousRegistry = asPluginRegistry(state.httpRoute.registry);
|
||||
installSurfaceRegistry(state.httpRoute, registry, true);
|
||||
markPluginRegistryActive(registry);
|
||||
syncPluginAgentEventBridge();
|
||||
if (retirePluginRegistryIfUnused(previousRegistry)) {
|
||||
cleanupRetiredPluginHostRegistry(previousRegistry!);
|
||||
}
|
||||
}
|
||||
|
||||
export function releasePinnedPluginHttpRouteRegistry(registry?: PluginRegistry) {
|
||||
if (registry && state.httpRoute.registry !== registry) {
|
||||
return;
|
||||
}
|
||||
const previousRegistry = asPluginRegistry(state.httpRoute.registry);
|
||||
installSurfaceRegistry(state.httpRoute, state.activeRegistry, false);
|
||||
syncPluginAgentEventBridge();
|
||||
if (retirePluginRegistryIfUnused(previousRegistry)) {
|
||||
cleanupRetiredPluginHostRegistry(previousRegistry!);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActivePluginHttpRouteRegistry(): PluginRegistry | null {
|
||||
@@ -231,14 +282,25 @@ export function resolveActivePluginHttpRouteRegistry(fallback: PluginRegistry):
|
||||
* gateway startup after the initial plugin load so that config-schema reads
|
||||
* and other non-primary registry loads cannot evict channel plugins. */
|
||||
export function pinActivePluginChannelRegistry(registry: PluginRegistry) {
|
||||
const previousRegistry = asPluginRegistry(state.channel.registry);
|
||||
installSurfaceRegistry(state.channel, registry, true);
|
||||
markPluginRegistryActive(registry);
|
||||
syncPluginAgentEventBridge();
|
||||
if (retirePluginRegistryIfUnused(previousRegistry)) {
|
||||
cleanupRetiredPluginHostRegistry(previousRegistry!);
|
||||
}
|
||||
}
|
||||
|
||||
export function releasePinnedPluginChannelRegistry(registry?: PluginRegistry) {
|
||||
if (registry && state.channel.registry !== registry) {
|
||||
return;
|
||||
}
|
||||
const previousRegistry = asPluginRegistry(state.channel.registry);
|
||||
installSurfaceRegistry(state.channel, state.activeRegistry, false);
|
||||
syncPluginAgentEventBridge();
|
||||
if (retirePluginRegistryIfUnused(previousRegistry)) {
|
||||
cleanupRetiredPluginHostRegistry(previousRegistry!);
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the registry that should be used for channel plugin resolution.
|
||||
@@ -316,7 +378,7 @@ export function resetPluginRuntimeStateForTest(): void {
|
||||
state.workspaceDir = null;
|
||||
state.runtimeSubagentMode = "default";
|
||||
state.importedPluginIds.clear();
|
||||
syncPluginAgentEventBridge(null);
|
||||
syncPluginAgentEventBridge();
|
||||
// Also clear the plugin host-hook runtime singleton (run context map,
|
||||
// scheduler-job records, pending agent-event handlers, closedRunIds set).
|
||||
// Otherwise per-test bleed-over of those globals can cause flaky behavior
|
||||
|
||||
@@ -16,7 +16,7 @@ type AjvLike = {
|
||||
validate: (value: string) => boolean;
|
||||
},
|
||||
) => AjvLike;
|
||||
compile: (schema: JsonSchemaObject) => ValidateFunction;
|
||||
compile: (schema: JsonSchemaValue) => ValidateFunction;
|
||||
};
|
||||
const ajvSingletons = new Map<"default" | "defaults", AjvLike>();
|
||||
|
||||
@@ -56,13 +56,15 @@ function getAjv(mode: "default" | "defaults"): AjvLike {
|
||||
type CachedValidator = {
|
||||
hasDefaults: boolean;
|
||||
validate: ValidateFunction;
|
||||
schema: JsonSchemaObject;
|
||||
schema: JsonSchemaValue;
|
||||
schemaFingerprint: string;
|
||||
};
|
||||
|
||||
export type JsonSchemaValue = JsonSchemaObject | boolean;
|
||||
|
||||
const schemaCache = new PluginLruCache<CachedValidator>(512);
|
||||
|
||||
function fingerprintSchema(schema: JsonSchemaObject): string {
|
||||
function fingerprintSchema(schema: JsonSchemaValue): string {
|
||||
return JSON.stringify(schema);
|
||||
}
|
||||
|
||||
@@ -198,7 +200,7 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaVa
|
||||
}
|
||||
|
||||
export function validateJsonSchemaValue(params: {
|
||||
schema: JsonSchemaObject;
|
||||
schema: JsonSchemaValue;
|
||||
cacheKey: string;
|
||||
value: unknown;
|
||||
applyDefaults?: boolean;
|
||||
|
||||
@@ -94,6 +94,8 @@ import type {
|
||||
} from "./conversation-binding.types.js";
|
||||
import type { PluginHookHandlerMap, PluginHookName } from "./hook-types.js";
|
||||
import type {
|
||||
PluginAgentEventEmitParams,
|
||||
PluginAgentEventEmitResult,
|
||||
PluginAgentEventSubscriptionRegistration,
|
||||
PluginControlUiDescriptor,
|
||||
PluginJsonValue,
|
||||
@@ -102,9 +104,15 @@ import type {
|
||||
PluginRunContextGetParams,
|
||||
PluginRunContextPatch,
|
||||
PluginRuntimeLifecycleRegistration,
|
||||
PluginSessionActionRegistration,
|
||||
PluginSessionAttachmentParams,
|
||||
PluginSessionAttachmentResult,
|
||||
PluginSessionSchedulerJobHandle,
|
||||
PluginSessionSchedulerJobRegistration,
|
||||
PluginSessionExtensionRegistration,
|
||||
PluginSessionTurnScheduleParams,
|
||||
PluginSessionTurnUnscheduleByTagParams,
|
||||
PluginSessionTurnUnscheduleByTagResult,
|
||||
PluginToolMetadataRegistration,
|
||||
PluginTrustedToolPolicyRegistration,
|
||||
} from "./host-hooks.js";
|
||||
@@ -192,6 +200,8 @@ export type {
|
||||
} from "./cli-backend.types.js";
|
||||
export * from "./hook-types.js";
|
||||
export type {
|
||||
PluginAgentEventEmitParams,
|
||||
PluginAgentEventEmitResult,
|
||||
PluginAgentEventSubscriptionRegistration,
|
||||
PluginAgentTurnPrepareEvent,
|
||||
PluginAgentTurnPrepareResult,
|
||||
@@ -205,10 +215,18 @@ export type {
|
||||
PluginRunContextGetParams,
|
||||
PluginRunContextPatch,
|
||||
PluginRuntimeLifecycleRegistration,
|
||||
PluginSessionAttachmentParams,
|
||||
PluginSessionAttachmentResult,
|
||||
PluginSessionSchedulerJobHandle,
|
||||
PluginSessionSchedulerJobRegistration,
|
||||
PluginSessionExtensionRegistration,
|
||||
PluginSessionExtensionProjection,
|
||||
PluginSessionActionContext,
|
||||
PluginSessionActionRegistration,
|
||||
PluginSessionActionResult,
|
||||
PluginSessionTurnScheduleParams,
|
||||
PluginSessionTurnUnscheduleByTagParams,
|
||||
PluginSessionTurnUnscheduleByTagResult,
|
||||
PluginToolMetadataRegistration,
|
||||
PluginTrustedToolPolicyRegistration,
|
||||
} from "./host-hooks.js";
|
||||
@@ -2432,6 +2450,79 @@ export type PluginSetupAutoEnableProbe = (
|
||||
ctx: PluginSetupAutoEnableContext,
|
||||
) => string | string[] | null | undefined;
|
||||
|
||||
export type OpenClawPluginSessionStateApi = {
|
||||
/** Register plugin-owned session state projected into Gateway session rows. */
|
||||
registerSessionExtension: (extension: PluginSessionExtensionRegistration) => void;
|
||||
};
|
||||
|
||||
export type OpenClawPluginSessionWorkflowApi = {
|
||||
/** Queue one plugin-owned context injection for the next agent turn in a session. */
|
||||
enqueueNextTurnInjection: (
|
||||
injection: PluginNextTurnInjection,
|
||||
) => Promise<PluginNextTurnInjectionEnqueueResult>;
|
||||
/**
|
||||
* Register cleanup metadata for a plugin-owned session scheduler job.
|
||||
* This does not schedule work or create task records; it only lets the host
|
||||
* clean external scheduler state during reset/delete/disable.
|
||||
*/
|
||||
registerSessionSchedulerJob: (
|
||||
job: PluginSessionSchedulerJobRegistration,
|
||||
) => PluginSessionSchedulerJobHandle | undefined;
|
||||
/** Send host-validated files to the active direct-outbound route for a session. */
|
||||
sendSessionAttachment: (
|
||||
params: PluginSessionAttachmentParams,
|
||||
) => Promise<PluginSessionAttachmentResult>;
|
||||
/**
|
||||
* Schedule a future agent turn in a session through Cron.
|
||||
* Cron owns timing and creates the task ledger entry when the turn runs.
|
||||
*/
|
||||
scheduleSessionTurn: (
|
||||
params: PluginSessionTurnScheduleParams,
|
||||
) => Promise<PluginSessionSchedulerJobHandle | undefined>;
|
||||
/** Remove Cron-backed scheduled session turns that share a plugin-owned tag. */
|
||||
unscheduleSessionTurnsByTag: (
|
||||
params: PluginSessionTurnUnscheduleByTagParams,
|
||||
) => Promise<PluginSessionTurnUnscheduleByTagResult>;
|
||||
};
|
||||
|
||||
export type OpenClawPluginSessionControlsApi = {
|
||||
/** Register a typed session action that clients can dispatch through the Gateway. */
|
||||
registerSessionAction: (action: PluginSessionActionRegistration) => void;
|
||||
/** Register a generic Control UI contribution descriptor. */
|
||||
registerControlUiDescriptor: (descriptor: PluginControlUiDescriptor) => void;
|
||||
};
|
||||
|
||||
export type OpenClawPluginSessionApi = {
|
||||
state: OpenClawPluginSessionStateApi;
|
||||
workflow: OpenClawPluginSessionWorkflowApi;
|
||||
controls: OpenClawPluginSessionControlsApi;
|
||||
};
|
||||
|
||||
export type OpenClawPluginAgentEventsApi = {
|
||||
/** Subscribe to sanitized agent events through the host-owned plugin lifecycle. */
|
||||
registerAgentEventSubscription: (subscription: PluginAgentEventSubscriptionRegistration) => void;
|
||||
/** Emit a host-routed, plugin-attributed event for workflow/UI subscribers. */
|
||||
emitAgentEvent: (params: PluginAgentEventEmitParams) => PluginAgentEventEmitResult;
|
||||
};
|
||||
|
||||
export type OpenClawPluginAgentApi = {
|
||||
events: OpenClawPluginAgentEventsApi;
|
||||
};
|
||||
|
||||
export type OpenClawPluginRunContextApi = {
|
||||
/** Store namespaced, JSON-compatible data for the active run. Cleared on run end/error. */
|
||||
setRunContext: (patch: PluginRunContextPatch) => boolean;
|
||||
/** Read namespaced plugin data for a run. */
|
||||
getRunContext: (params: PluginRunContextGetParams) => PluginJsonValue | undefined;
|
||||
/** Clear one namespace or all namespaces this plugin owns for a run. */
|
||||
clearRunContext: (params: { runId: string; namespace?: string }) => void;
|
||||
};
|
||||
|
||||
export type OpenClawPluginLifecycleApi = {
|
||||
/** Register cleanup hooks for plugin-owned host state and background work. */
|
||||
registerRuntimeLifecycle: (lifecycle: PluginRuntimeLifecycleRegistration) => void;
|
||||
};
|
||||
|
||||
/** Main registration API injected into native plugin entry files. */
|
||||
export type OpenClawPluginApi = {
|
||||
id: string;
|
||||
@@ -2451,6 +2542,17 @@ export type OpenClawPluginApi = {
|
||||
*/
|
||||
runtime: PluginRuntime;
|
||||
logger: PluginLogger;
|
||||
/**
|
||||
* Grouped facade over the existing flat session-related plugin API.
|
||||
* Flat methods remain supported for compatibility.
|
||||
*/
|
||||
session: OpenClawPluginSessionApi;
|
||||
/** Grouped facade for agent-event workflow seams. */
|
||||
agent: OpenClawPluginAgentApi;
|
||||
/** Grouped facade for run-scoped plugin scratch state. */
|
||||
runContext: OpenClawPluginRunContextApi;
|
||||
/** Grouped facade for plugin-owned lifecycle cleanup hooks. */
|
||||
lifecycle: OpenClawPluginLifecycleApi;
|
||||
registerTool: (
|
||||
tool: AnyAgentTool | OpenClawPluginToolFactory,
|
||||
opts?: OpenClawPluginToolOptions,
|
||||
@@ -2578,9 +2680,15 @@ export type OpenClawPluginApi = {
|
||||
handler: AgentToolResultMiddleware,
|
||||
options?: AgentToolResultMiddlewareOptions,
|
||||
) => void;
|
||||
/** Register plugin-owned session state that can be projected into Gateway session rows. */
|
||||
/**
|
||||
* Register plugin-owned session state that can be projected into Gateway session rows.
|
||||
* @deprecated Use `api.session.state.registerSessionExtension(...)`.
|
||||
*/
|
||||
registerSessionExtension: (extension: PluginSessionExtensionRegistration) => void;
|
||||
/** Queue one plugin-owned context injection for the next agent turn in a session. */
|
||||
/**
|
||||
* Queue one plugin-owned context injection for the next agent turn in a session.
|
||||
* @deprecated Use `api.session.workflow.enqueueNextTurnInjection(...)`.
|
||||
*/
|
||||
enqueueNextTurnInjection: (
|
||||
injection: PluginNextTurnInjection,
|
||||
) => Promise<PluginNextTurnInjectionEnqueueResult>;
|
||||
@@ -2595,22 +2703,88 @@ export type OpenClawPluginApi = {
|
||||
* cannot decorate other plugins' tools or core tools through this surface.
|
||||
*/
|
||||
registerToolMetadata: (metadata: PluginToolMetadataRegistration) => void;
|
||||
/** Register a generic Control UI contribution descriptor. */
|
||||
/**
|
||||
* Register a generic Control UI contribution descriptor.
|
||||
* @deprecated Use `api.session.controls.registerControlUiDescriptor(...)`.
|
||||
*/
|
||||
registerControlUiDescriptor: (descriptor: PluginControlUiDescriptor) => void;
|
||||
/** Register cleanup hooks for plugin-owned host state and background work. */
|
||||
/**
|
||||
* Register cleanup hooks for plugin-owned host state and background work.
|
||||
* @deprecated Use `api.lifecycle.registerRuntimeLifecycle(...)`.
|
||||
*/
|
||||
registerRuntimeLifecycle: (lifecycle: PluginRuntimeLifecycleRegistration) => void;
|
||||
/** Subscribe to sanitized agent events through the host-owned plugin lifecycle. */
|
||||
/**
|
||||
* Subscribe to sanitized agent events through the host-owned plugin lifecycle.
|
||||
* @deprecated Use `api.agent.events.registerAgentEventSubscription(...)`.
|
||||
*/
|
||||
registerAgentEventSubscription: (subscription: PluginAgentEventSubscriptionRegistration) => void;
|
||||
/** Store namespaced, JSON-compatible data for the active run. Cleared on run end/error. */
|
||||
/**
|
||||
* Emit a host-routed, plugin-attributed agent event for workflow/UI subscribers.
|
||||
* @deprecated Use `api.agent.events.emitAgentEvent(...)`.
|
||||
*/
|
||||
emitAgentEvent: (params: PluginAgentEventEmitParams) => PluginAgentEventEmitResult;
|
||||
/**
|
||||
* Store namespaced, JSON-compatible data for the active run. Cleared on run end/error.
|
||||
* @deprecated Use `api.runContext.setRunContext(...)`.
|
||||
*/
|
||||
setRunContext: (patch: PluginRunContextPatch) => boolean;
|
||||
/** Read namespaced plugin data for a run. */
|
||||
/**
|
||||
* Read namespaced plugin data for a run.
|
||||
* @deprecated Use `api.runContext.getRunContext(...)`.
|
||||
*/
|
||||
getRunContext: (params: PluginRunContextGetParams) => PluginJsonValue | undefined;
|
||||
/** Clear one namespace or all namespaces this plugin owns for a run. */
|
||||
/**
|
||||
* Clear one namespace or all namespaces this plugin owns for a run.
|
||||
* @deprecated Use `api.runContext.clearRunContext(...)`.
|
||||
*/
|
||||
clearRunContext: (params: { runId: string; namespace?: string }) => void;
|
||||
/** Register a plugin-owned session job so reset/delete/disable can clean it deterministically. */
|
||||
/**
|
||||
* Register cleanup metadata for a plugin-owned session scheduler job.
|
||||
* This does not schedule work or create task records; it only lets the host
|
||||
* clean external scheduler state during reset/delete/disable.
|
||||
*
|
||||
* @deprecated Use `api.session.workflow.registerSessionSchedulerJob(...)`.
|
||||
*/
|
||||
registerSessionSchedulerJob: (
|
||||
job: PluginSessionSchedulerJobRegistration,
|
||||
) => PluginSessionSchedulerJobHandle | undefined;
|
||||
/**
|
||||
* Register a typed session action that clients can dispatch through the Gateway.
|
||||
* @deprecated Use `api.session.controls.registerSessionAction(...)`.
|
||||
*/
|
||||
registerSessionAction: (action: PluginSessionActionRegistration) => void;
|
||||
/**
|
||||
* Send one or more host-validated files to the active direct-outbound channel for a session.
|
||||
*
|
||||
* This API is intended for bundled plugins running with the host channel/session
|
||||
* integration available. Calls may resolve to `{ ok: false }` instead of attaching
|
||||
* files when global side effects are disabled or when the required plugin/channel
|
||||
* runtime is not loaded, so callers must handle rejection via the returned result.
|
||||
*
|
||||
* @deprecated Use `api.session.workflow.sendSessionAttachment(...)`.
|
||||
*/
|
||||
sendSessionAttachment: (
|
||||
params: PluginSessionAttachmentParams,
|
||||
) => Promise<PluginSessionAttachmentResult>;
|
||||
/**
|
||||
* Schedule a future agent turn in a session through Cron.
|
||||
* Cron owns timing and creates the task ledger entry when the turn runs.
|
||||
* Bundled plugins only; workspace plugins receive undefined.
|
||||
*
|
||||
* @deprecated Use `api.session.workflow.scheduleSessionTurn(...)`.
|
||||
*/
|
||||
scheduleSessionTurn: (
|
||||
params: PluginSessionTurnScheduleParams,
|
||||
) => Promise<PluginSessionSchedulerJobHandle | undefined>;
|
||||
/**
|
||||
* Remove Cron-backed scheduled session turns that share the same plugin-owned tag.
|
||||
* Bundled plugins only; workspace plugins receive a zero-count result.
|
||||
*
|
||||
* @deprecated Use `api.session.workflow.unscheduleSessionTurnsByTag(...)`.
|
||||
*/
|
||||
unscheduleSessionTurnsByTag: (
|
||||
params: PluginSessionTurnUnscheduleByTagParams,
|
||||
) => Promise<PluginSessionTurnUnscheduleByTagResult>;
|
||||
/** Register the active detached task runtime for this plugin (exclusive slot). */
|
||||
registerDetachedTaskRuntime: (
|
||||
runtime: import("./runtime/runtime-tasks.types.js").DetachedTaskLifecycleRuntime,
|
||||
|
||||
Reference in New Issue
Block a user