diff --git a/CHANGELOG.md b/CHANGELOG.md index 60362275d22..67ae27f4a94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,7 @@ Docs: https://docs.openclaw.ai - Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus. - Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob. - Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc. +- Plugins/runtime-api: pin extension runtime-api export seams with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc. ### Breaking diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index df7d67f1230..996b8ed193c 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -26,7 +26,6 @@ const GUARDED_CHANNEL_EXTENSIONS = new Set([ "msteams", "nostr", "nextcloud-talk", - "nostr", "signal", "slack", "synology-chat", diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts new file mode 100644 index 00000000000..f2079d8691f --- /dev/null +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -0,0 +1,148 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const RUNTIME_API_EXPORT_GUARDS: Record = { + "extensions/discord/runtime-api.ts": [ + 'export * from "./src/audit.js";', + 'export * from "./src/actions/runtime.js";', + 'export * from "./src/actions/runtime.moderation-shared.js";', + 'export * from "./src/actions/runtime.shared.js";', + 'export * from "./src/channel-actions.js";', + 'export * from "./src/directory-live.js";', + 'export * from "./src/monitor.js";', + 'export * from "./src/monitor/gateway-plugin.js";', + 'export * from "./src/monitor/gateway-registry.js";', + 'export * from "./src/monitor/presence-cache.js";', + 'export * from "./src/monitor/thread-bindings.js";', + 'export * from "./src/monitor/thread-bindings.manager.js";', + 'export * from "./src/monitor/timeouts.js";', + 'export * from "./src/probe.js";', + 'export * from "./src/resolve-channels.js";', + 'export * from "./src/resolve-users.js";', + 'export * from "./src/send.js";', + ], + "extensions/imessage/runtime-api.ts": [ + 'export * from "./src/monitor.js";', + 'export * from "./src/probe.js";', + 'export * from "./src/send.js";', + ], + "extensions/nextcloud-talk/runtime-api.ts": [ + 'export * from "openclaw/plugin-sdk/nextcloud-talk";', + ], + "extensions/signal/runtime-api.ts": ['export * from "./src/index.js";'], + "extensions/slack/runtime-api.ts": [ + 'export * from "./src/action-runtime.js";', + 'export * from "./src/directory-live.js";', + 'export * from "./src/index.js";', + 'export * from "./src/resolve-channels.js";', + 'export * from "./src/resolve-users.js";', + ], + "extensions/telegram/runtime-api.ts": [ + 'export * from "./src/audit.js";', + 'export * from "./src/action-runtime.js";', + 'export * from "./src/channel-actions.js";', + 'export * from "./src/monitor.js";', + 'export * from "./src/probe.js";', + 'export * from "./src/send.js";', + 'export * from "./src/thread-bindings.js";', + 'export * from "./src/token.js";', + ], + "extensions/whatsapp/runtime-api.ts": [ + 'export * from "./src/active-listener.js";', + 'export * from "./src/action-runtime.js";', + 'export * from "./src/agent-tools-login.js";', + 'export * from "./src/auth-store.js";', + 'export * from "./src/auto-reply.js";', + 'export * from "./src/inbound.js";', + 'export * from "./src/login.js";', + 'export * from "./src/media.js";', + 'export * from "./src/send.js";', + 'export * from "./src/session.js";', + ], +} as const; + +function collectRuntimeApiFiles(): string[] { + const extensionsDir = resolve(ROOT_DIR, "..", "extensions"); + const files: string[] = []; + const stack = [extensionsDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + for (const entry of readdirSync(current, { withFileTypes: true })) { + const fullPath = resolve(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { + continue; + } + stack.push(fullPath); + continue; + } + if (!entry.isFile() || entry.name !== "runtime-api.ts") { + continue; + } + files.push(relative(resolve(ROOT_DIR, ".."), fullPath).replaceAll("\\", "/")); + } + } + return files; +} + +function readExportStatements(path: string): string[] { + const sourceText = readFileSync(resolve(ROOT_DIR, "..", path), "utf8"); + const sourceFile = ts.createSourceFile(path, sourceText, ts.ScriptTarget.Latest, true); + + return sourceFile.statements.flatMap((statement) => { + if (!ts.isExportDeclaration(statement)) { + if (!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) { + return []; + } + return [statement.getText(sourceFile).replaceAll(/\s+/g, " ").trim()]; + } + + const moduleSpecifier = statement.moduleSpecifier; + if (!moduleSpecifier || !ts.isStringLiteral(moduleSpecifier)) { + return [statement.getText(sourceFile).replaceAll(/\s+/g, " ").trim()]; + } + + if (!statement.exportClause) { + const prefix = statement.isTypeOnly ? "export type *" : "export *"; + return [`${prefix} from ${moduleSpecifier.getText(sourceFile)};`]; + } + + if (!ts.isNamedExports(statement.exportClause)) { + return [statement.getText(sourceFile).replaceAll(/\s+/g, " ").trim()]; + } + + const specifiers = statement.exportClause.elements.map((element) => { + const imported = element.propertyName?.text; + const exported = element.name.text; + const alias = imported ? `${imported} as ${exported}` : exported; + return element.isTypeOnly ? `type ${alias}` : alias; + }); + const exportPrefix = statement.isTypeOnly ? "export type" : "export"; + return [ + `${exportPrefix} { ${specifiers.join(", ")} } from ${moduleSpecifier.getText(sourceFile)};`, + ]; + }); +} + +describe("runtime api guardrails", () => { + it("keeps runtime api seams on an explicit export allowlist", () => { + const runtimeApiFiles = collectRuntimeApiFiles(); + expect(runtimeApiFiles).toEqual( + expect.arrayContaining(Object.keys(RUNTIME_API_EXPORT_GUARDS).toSorted()), + ); + + for (const file of Object.keys(RUNTIME_API_EXPORT_GUARDS).toSorted()) { + expect(readExportStatements(file), `${file} runtime api exports changed`).toEqual( + RUNTIME_API_EXPORT_GUARDS[file], + ); + } + }); +});