From dbc9d3dd70b836600db5f83effe6cf096280789d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 09:25:13 -0700 Subject: [PATCH 01/15] fix(plugin-sdk): restore root diagnostic compat --- scripts/check-plugin-sdk-exports.mjs | 3 +- scripts/release-check.ts | 2 ++ src/infra/tsdown-config.test.ts | 1 + src/plugin-sdk/compat.ts | 2 ++ src/plugin-sdk/index.test.ts | 2 ++ src/plugin-sdk/index.ts | 2 ++ src/plugin-sdk/root-alias.cjs | 45 ++++++++++++++++++++++++++++ src/plugin-sdk/root-alias.test.ts | 24 +++++++++++++++ tsdown.config.ts | 2 ++ 9 files changed, 82 insertions(+), 1 deletion(-) diff --git a/scripts/check-plugin-sdk-exports.mjs b/scripts/check-plugin-sdk-exports.mjs index 60c89056ca0..90d784235f5 100755 --- a/scripts/check-plugin-sdk-exports.mjs +++ b/scripts/check-plugin-sdk-exports.mjs @@ -42,7 +42,7 @@ const exportedNames = exportMatch[1] const exportSet = new Set(exportedNames); -const requiredRuntimeShimEntries = ["root-alias.cjs"]; +const requiredRuntimeShimEntries = ["compat.js", "root-alias.cjs"]; // Critical functions that channel extension plugins import from openclaw/plugin-sdk. // If any of these are missing, plugins will fail at runtime with: @@ -65,6 +65,7 @@ const requiredExports = [ "resolveChannelMediaMaxBytes", "warnMissingProviderGroupPolicyFallbackOnce", "emptyPluginConfigSchema", + "onDiagnosticEvent", "normalizePluginHttpPath", "registerPluginHttpRoute", "DEFAULT_ACCOUNT_ID", diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 72d729cc1cd..f7f36373a49 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -21,6 +21,7 @@ const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], ["dist/entry.js", "dist/entry.mjs"], ...listPluginSdkDistArtifacts(), + "dist/plugin-sdk/compat.js", "dist/plugin-sdk/root-alias.cjs", "dist/build-info.json", ]; @@ -228,6 +229,7 @@ const requiredPluginSdkExports = [ "resolveChannelMediaMaxBytes", "warnMissingProviderGroupPolicyFallbackOnce", "emptyPluginConfigSchema", + "onDiagnosticEvent", "normalizePluginHttpPath", "registerPluginHttpRoute", "DEFAULT_ACCOUNT_ID", diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 94332c5b307..c47bbcb2192 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -36,6 +36,7 @@ describe("tsdown config", () => { expect.arrayContaining([ "index", "plugins/runtime/index", + "plugin-sdk/compat", "plugin-sdk/index", "extensions/openai/index", "bundled/boot-md/handler", diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 5e2bcd11f58..99e2066633c 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -20,6 +20,8 @@ if (shouldWarnCompatImport) { export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; +export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js"; +export { onDiagnosticEvent } from "../infra/diagnostic-events.js"; export { createAccountStatusSink } from "./channel-lifecycle.js"; export { createPluginRuntimeStore } from "./runtime-store.js"; diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 30040416729..db54ebbd1ff 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -50,9 +50,11 @@ describe("plugin-sdk exports", () => { it("keeps the root runtime surface intentionally small", () => { expect(typeof sdk.emptyPluginConfigSchema).toBe("function"); expect(typeof sdk.delegateCompactionToRuntime).toBe("function"); + expect(typeof sdk.onDiagnosticEvent).toBe("function"); expect(Object.prototype.hasOwnProperty.call(sdk, "resolveControlCommandGate")).toBe(false); expect(Object.prototype.hasOwnProperty.call(sdk, "buildAgentSessionKey")).toBe(false); expect(Object.prototype.hasOwnProperty.call(sdk, "isDangerousNameMatchingEnabled")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(sdk, "emitDiagnosticEvent")).toBe(false); }); it("keeps package.json plugin-sdk exports synced with the manifest", async () => { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 5bb67920734..20f8a34672a 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -64,7 +64,9 @@ export type { HookEntry } from "../hooks/types.js"; export type { ReplyPayload } from "../auto-reply/types.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export type { ContextEngineFactory } from "../context-engine/registry.js"; +export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerContextEngine } from "../context-engine/registry.js"; export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; +export { onDiagnosticEvent } from "../infra/diagnostic-events.js"; diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 23e583f8c4d..669586bb80c 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -5,6 +5,7 @@ const fs = require("node:fs"); let monolithicSdk = null; const jitiLoaders = new Map(); +const pluginSdkSubpathsCache = new Map(); function emptyPluginConfigSchema() { function error(message) { @@ -61,6 +62,49 @@ function resolveControlCommandGate(params) { return { commandAuthorized, shouldBlock }; } +function getPackageRoot() { + return path.resolve(__dirname, "..", ".."); +} + +function listPluginSdkExportedSubpaths() { + const packageRoot = getPackageRoot(); + if (pluginSdkSubpathsCache.has(packageRoot)) { + return pluginSdkSubpathsCache.get(packageRoot); + } + + let subpaths = []; + try { + const packageJsonPath = path.join(packageRoot, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + subpaths = Object.keys(packageJson.exports ?? {}) + .filter((key) => key.startsWith("./plugin-sdk/")) + .map((key) => key.slice("./plugin-sdk/".length)); + } catch { + subpaths = []; + } + + pluginSdkSubpathsCache.set(packageRoot, subpaths); + return subpaths; +} + +function buildPluginSdkAliasMap(useDist) { + const packageRoot = getPackageRoot(); + const pluginSdkDir = path.join(packageRoot, useDist ? "dist" : "src", "plugin-sdk"); + const ext = useDist ? ".js" : ".ts"; + const aliasMap = { + "openclaw/plugin-sdk": __filename, + }; + + for (const subpath of listPluginSdkExportedSubpaths()) { + const candidate = path.join(pluginSdkDir, `${subpath}${ext}`); + if (fs.existsSync(candidate)) { + aliasMap[`openclaw/plugin-sdk/${subpath}`] = candidate; + } + } + + return aliasMap; +} + function getJiti(tryNative) { if (jitiLoaders.has(tryNative)) { return jitiLoaders.get(tryNative); @@ -68,6 +112,7 @@ function getJiti(tryNative) { const { createJiti } = require("jiti"); const jitiLoader = createJiti(__filename, { + alias: buildPluginSdkAliasMap(tryNative), interopDefault: true, // Prefer Node's native sync ESM loader for built dist/plugin-sdk/*.js files // so local plugins do not create a second transpiled OpenClaw core graph. diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 83937c34b44..48ae4a7b43c 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -48,6 +48,12 @@ function loadRootAliasWithStubs(options?: { } if (id === "node:fs") { return { + readFileSync: () => + JSON.stringify({ + exports: { + "./plugin-sdk/group-access": { default: "./dist/plugin-sdk/group-access.js" }, + }, + }), existsSync: () => options?.distExists ?? false, }; } @@ -164,8 +170,23 @@ describe("plugin-sdk root alias", () => { expect("delegateCompactionToRuntime" in lazyRootSdk).toBe(true); }); + it("forwards onDiagnosticEvent through the compat-backed root alias", () => { + const onDiagnosticEvent = () => () => undefined; + const lazyModule = loadRootAliasWithStubs({ + monolithicExports: { + onDiagnosticEvent, + }, + }); + const lazyRootSdk = lazyModule.moduleExports; + + expect(typeof lazyRootSdk.onDiagnosticEvent).toBe("function"); + expect(lazyRootSdk.onDiagnosticEvent).toBe(onDiagnosticEvent); + expect("onDiagnosticEvent" in lazyRootSdk).toBe(true); + }); + it("loads legacy root exports through the merged root wrapper", { timeout: 240_000 }, () => { expect(typeof rootSdk.resolveControlCommandGate).toBe("function"); + expect(typeof rootSdk.onDiagnosticEvent).toBe("function"); expect(typeof rootSdk.default).toBe("object"); expect(rootSdk.default).toBe(rootSdk); expect(rootSdk.__esModule).toBe(true); @@ -173,9 +194,12 @@ describe("plugin-sdk root alias", () => { it("preserves reflection semantics for lazily resolved exports", { timeout: 240_000 }, () => { expect("resolveControlCommandGate" in rootSdk).toBe(true); + expect("onDiagnosticEvent" in rootSdk).toBe(true); const keys = Object.keys(rootSdk); expect(keys).toContain("resolveControlCommandGate"); + expect(keys).toContain("onDiagnosticEvent"); const descriptor = Object.getOwnPropertyDescriptor(rootSdk, "resolveControlCommandGate"); expect(descriptor).toBeDefined(); + expect(Object.getOwnPropertyDescriptor(rootSdk, "onDiagnosticEvent")).toBeDefined(); }); }); diff --git a/tsdown.config.ts b/tsdown.config.ts index 746c6e883bc..98dd9e3d341 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -186,6 +186,8 @@ const coreDistEntries = buildCoreDistEntries(); function buildUnifiedDistEntries(): Record { return { ...coreDistEntries, + // Internal compat artifact for the root-alias.cjs lazy loader. + "plugin-sdk/compat": "src/plugin-sdk/compat.ts", ...Object.fromEntries( Object.entries(buildPluginSdkEntrySources()).map(([entry, source]) => [ `plugin-sdk/${entry}`, From d3ffa1e4e742d52abd7225b4f1cf45cad0389ab6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 09:29:38 -0700 Subject: [PATCH 02/15] refactor(errors): share api error payload parsing --- ...d-helpers.formatassistanterrortext.test.ts | 13 ++++ src/agents/pi-embedded-helpers/errors.ts | 61 +------------------ src/shared/assistant-error-format.ts | 2 +- 3 files changed, 15 insertions(+), 61 deletions(-) diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 8fc8ac1fddc..35fc741db58 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -4,7 +4,9 @@ import { BILLING_ERROR_USER_MESSAGE, formatBillingErrorMessage, formatAssistantErrorText, + getApiErrorPayloadFingerprint, formatRawAssistantErrorForUi, + isRawApiErrorPayload, } from "./pi-embedded-helpers.js"; import { makeAssistantMessageFixture } from "./test-helpers/assistant-message-fixtures.js"; @@ -159,3 +161,14 @@ describe("formatRawAssistantErrorForUi", () => { ); }); }); + +describe("raw API error payload helpers", () => { + it("recognizes provider-prefixed JSON payloads for observation fingerprints", () => { + const raw = + 'Ollama API error: {"type":"error","error":{"type":"server_error","message":"Boom"},"request_id":"req_123"}'; + + expect(isRawApiErrorPayload(raw)).toBe(true); + expect(getApiErrorPayloadFingerprint(raw)).toContain("server_error"); + expect(getApiErrorPayloadFingerprint(raw)).toContain("req_123"); + }); +}); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 2fec27a45e2..7719ecb41a0 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -5,6 +5,7 @@ import { extractLeadingHttpStatus, formatRawAssistantErrorForUi, isCloudflareOrHtmlErrorPage, + parseApiErrorPayload, } from "../../shared/assistant-error-format.js"; export { extractLeadingHttpStatus, @@ -223,9 +224,6 @@ export function extractObservedOverflowTokenCount(errorMessage?: string): number return undefined; } -// Allow provider-wrapped API payloads such as "Ollama API error 400: {...}". -const ERROR_PAYLOAD_PREFIX_RE = - /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)(?:\s+\d{3})?[:\s-]+/i; const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi; const ERROR_PREFIX_RE = /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i; @@ -482,63 +480,6 @@ function shouldRewriteContextOverflowText(raw: string): boolean { ); } -type ErrorPayload = Record; - -function isErrorPayloadObject(payload: unknown): payload is ErrorPayload { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return false; - } - const record = payload as ErrorPayload; - if (record.type === "error") { - return true; - } - if (typeof record.request_id === "string" || typeof record.requestId === "string") { - return true; - } - if ("error" in record) { - const err = record.error; - if (err && typeof err === "object" && !Array.isArray(err)) { - const errRecord = err as ErrorPayload; - if ( - typeof errRecord.message === "string" || - typeof errRecord.type === "string" || - typeof errRecord.code === "string" - ) { - return true; - } - } - } - return false; -} - -function parseApiErrorPayload(raw: string): ErrorPayload | null { - if (!raw) { - return null; - } - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - const candidates = [trimmed]; - if (ERROR_PAYLOAD_PREFIX_RE.test(trimmed)) { - candidates.push(trimmed.replace(ERROR_PAYLOAD_PREFIX_RE, "").trim()); - } - for (const candidate of candidates) { - if (!candidate.startsWith("{") || !candidate.endsWith("}")) { - continue; - } - try { - const parsed = JSON.parse(candidate) as unknown; - if (isErrorPayloadObject(parsed)) { - return parsed; - } - } catch { - // ignore parse errors - } - } - return null; -} - export function getApiErrorPayloadFingerprint(raw?: string): string | null { if (!raw) { return null; diff --git a/src/shared/assistant-error-format.ts b/src/shared/assistant-error-format.ts index 6564cf5c641..b07d5b2ac53 100644 --- a/src/shared/assistant-error-format.ts +++ b/src/shared/assistant-error-format.ts @@ -41,7 +41,7 @@ function isErrorPayloadObject(payload: unknown): payload is ErrorPayload { return false; } -function parseApiErrorPayload(raw: string): ErrorPayload | null { +export function parseApiErrorPayload(raw?: string): ErrorPayload | null { if (!raw) { return null; } From faa9faa767dde1c7ce4971c9b9fedf854a32f310 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 09:29:46 -0700 Subject: [PATCH 03/15] refactor(web-search): share provider clients and config helpers --- extensions/firecrawl/src/firecrawl-client.ts | 111 ++++------ extensions/tavily/src/tavily-client.ts | 77 ++----- .../xai/src/grok-web-search-provider.ts | 181 +++------------- extensions/xai/src/web-search-shared.ts | 171 +++++++++++++++ extensions/xai/web-search.ts | 205 ++++-------------- .../tools/web-search-provider-common.ts | 39 ++++ src/plugin-sdk/provider-web-search.ts | 1 + src/plugins/config-state.ts | 2 +- src/plugins/providers.ts | 33 +-- src/plugins/web-search-providers.shared.ts | 38 +--- 10 files changed, 355 insertions(+), 503 deletions(-) create mode 100644 extensions/xai/src/web-search-shared.ts diff --git a/extensions/firecrawl/src/firecrawl-client.ts b/extensions/firecrawl/src/firecrawl-client.ts index 565e1d6aac3..fa38c5bdabe 100644 --- a/extensions/firecrawl/src/firecrawl-client.ts +++ b/extensions/firecrawl/src/firecrawl-client.ts @@ -1,11 +1,10 @@ import { markdownToText, truncateText } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { withTrustedWebToolsEndpoint } from "openclaw/plugin-sdk/provider-web-search"; import { DEFAULT_CACHE_TTL_MINUTES, normalizeCacheKey, + postTrustedWebToolsJson, readCache, - readResponseText, resolveCacheTtlMs, writeCache, } from "openclaw/plugin-sdk/provider-web-search"; @@ -29,7 +28,6 @@ const SCRAPE_CACHE = new Map< >(); const DEFAULT_SEARCH_COUNT = 5; const DEFAULT_SCRAPE_MAX_CHARS = 50_000; -const DEFAULT_ERROR_MAX_BYTES = 64_000; type FirecrawlSearchItem = { title: string; @@ -88,51 +86,6 @@ function resolveSiteName(urlRaw: string): string | undefined { } } -async function postFirecrawlJson(params: { - baseUrl: string; - pathname: "/v2/search" | "/v2/scrape"; - apiKey: string; - body: Record; - timeoutSeconds: number; - errorLabel: string; -}): Promise> { - const endpoint = resolveEndpoint(params.baseUrl, params.pathname); - return await withTrustedWebToolsEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(params.body), - }, - }, - async ({ response }) => { - if (!response.ok) { - const detail = await readResponseText(response, { maxBytes: DEFAULT_ERROR_MAX_BYTES }); - throw new Error( - `${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`, - ); - } - const payload = (await response.json()) as Record; - if (payload.success === false) { - const error = - typeof payload.error === "string" - ? payload.error - : typeof payload.message === "string" - ? payload.message - : "unknown error"; - throw new Error(`${params.errorLabel} API error: ${error}`); - } - return payload; - }, - ); -} - function resolveSearchItems(payload: Record): FirecrawlSearchItem[] { const candidates = [ payload.data, @@ -279,14 +232,28 @@ export async function runFirecrawlSearch( } const start = Date.now(); - const payload = await postFirecrawlJson({ - baseUrl, - pathname: "/v2/search", - apiKey, - body, - timeoutSeconds, - errorLabel: "Firecrawl Search", - }); + const payload = await postTrustedWebToolsJson( + { + url: resolveEndpoint(baseUrl, "/v2/search"), + timeoutSeconds, + apiKey, + body, + errorLabel: "Firecrawl Search", + }, + async (response) => { + const payload = (await response.json()) as Record; + if (payload.success === false) { + const error = + typeof payload.error === "string" + ? payload.error + : typeof payload.message === "string" + ? payload.message + : "unknown error"; + throw new Error(`Firecrawl Search API error: ${error}`); + } + return payload; + }, + ); const result = buildSearchPayload({ query: params.query, provider: "firecrawl", @@ -409,22 +376,24 @@ export async function runFirecrawlScrape( return { ...cached.value, cached: true }; } - const payload = await postFirecrawlJson({ - baseUrl, - pathname: "/v2/scrape", - apiKey, - timeoutSeconds, - errorLabel: "Firecrawl", - body: { - url: params.url, - formats: ["markdown"], - onlyMainContent, - timeout: timeoutSeconds * 1000, - maxAge: maxAgeMs, - proxy, - storeInCache, + const payload = await postTrustedWebToolsJson( + { + url: resolveEndpoint(baseUrl, "/v2/scrape"), + timeoutSeconds, + apiKey, + errorLabel: "Firecrawl", + body: { + url: params.url, + formats: ["markdown"], + onlyMainContent, + timeout: timeoutSeconds * 1000, + maxAge: maxAgeMs, + proxy, + storeInCache, + }, }, - }); + async (response) => (await response.json()) as Record, + ); const result = parseFirecrawlScrapePayload({ payload, url: params.url, diff --git a/extensions/tavily/src/tavily-client.ts b/extensions/tavily/src/tavily-client.ts index 8308f8b8772..c57f5850af3 100644 --- a/extensions/tavily/src/tavily-client.ts +++ b/extensions/tavily/src/tavily-client.ts @@ -1,10 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { withTrustedWebToolsEndpoint } from "openclaw/plugin-sdk/provider-web-search"; import { DEFAULT_CACHE_TTL_MINUTES, normalizeCacheKey, + postTrustedWebToolsJson, readCache, - readResponseText, resolveCacheTtlMs, writeCache, } from "openclaw/plugin-sdk/provider-web-search"; @@ -26,7 +25,6 @@ const EXTRACT_CACHE = new Map< { value: Record; expiresAt: number; insertedAt: number } >(); const DEFAULT_SEARCH_COUNT = 5; -const DEFAULT_ERROR_MAX_BYTES = 64_000; export type TavilySearchParams = { cfg?: OpenClawConfig; @@ -67,41 +65,6 @@ function resolveEndpoint(baseUrl: string, pathname: string): string { } } -async function postTavilyJson(params: { - baseUrl: string; - pathname: string; - apiKey: string; - body: Record; - timeoutSeconds: number; - errorLabel: string; -}): Promise> { - const endpoint = resolveEndpoint(params.baseUrl, params.pathname); - return await withTrustedWebToolsEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(params.body), - }, - }, - async ({ response }) => { - if (!response.ok) { - const detail = await readResponseText(response, { maxBytes: DEFAULT_ERROR_MAX_BYTES }); - throw new Error( - `${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`, - ); - } - return (await response.json()) as Record; - }, - ); -} - export async function runTavilySearch( params: TavilySearchParams, ): Promise> { @@ -149,14 +112,16 @@ export async function runTavilySearch( if (params.excludeDomains?.length) body.exclude_domains = params.excludeDomains; const start = Date.now(); - const payload = await postTavilyJson({ - baseUrl, - pathname: "/search", - apiKey, - body, - timeoutSeconds, - errorLabel: "Tavily Search", - }); + const payload = await postTrustedWebToolsJson( + { + url: resolveEndpoint(baseUrl, "/search"), + timeoutSeconds, + apiKey, + body, + errorLabel: "Tavily Search", + }, + async (response) => (await response.json()) as Record, + ); const rawResults = Array.isArray(payload.results) ? payload.results : []; const results = rawResults.map((r: Record) => ({ @@ -228,14 +193,16 @@ export async function runTavilyExtract( if (params.includeImages) body.include_images = true; const start = Date.now(); - const payload = await postTavilyJson({ - baseUrl, - pathname: "/extract", - apiKey, - body, - timeoutSeconds, - errorLabel: "Tavily Extract", - }); + const payload = await postTrustedWebToolsJson( + { + url: resolveEndpoint(baseUrl, "/extract"), + timeoutSeconds, + apiKey, + body, + errorLabel: "Tavily Extract", + }, + async (response) => (await response.json()) as Record, + ); const rawResults = Array.isArray(payload.results) ? payload.results : []; const results = rawResults.map((r: Record) => ({ @@ -282,5 +249,5 @@ export async function runTavilyExtract( } export const __testing = { - postTavilyJson, + resolveEndpoint, }; diff --git a/extensions/xai/src/grok-web-search-provider.ts b/extensions/xai/src/grok-web-search-provider.ts index d9a6f0f8d46..705a8299917 100644 --- a/extensions/xai/src/grok-web-search-provider.ts +++ b/extensions/xai/src/grok-web-search-provider.ts @@ -5,12 +5,12 @@ import { DEFAULT_SEARCH_COUNT, getScopedCredentialValue, MAX_SEARCH_COUNT, - mergeScopedSearchConfig, readCachedSearchPayload, readConfiguredSecretString, readNumberParam, readProviderEnvValue, readStringParam, + mergeScopedSearchConfig, resolveProviderWebSearchPluginConfig, resolveSearchCacheTtlMs, resolveSearchCount, @@ -20,151 +20,24 @@ import { type SearchConfigRecord, type WebSearchProviderPlugin, type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; +import { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + requestXaiWebSearch, + resolveXaiInlineCitations, + resolveXaiSearchConfig, + resolveXaiWebSearchModel, +} from "./web-search-shared.js"; -const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; -const DEFAULT_GROK_MODEL = "grok-4-1-fast"; - -type GrokConfig = { - apiKey?: string; - model?: string; - inlineCitations?: boolean; -}; - -type GrokSearchResponse = { - output?: Array<{ - type?: string; - role?: string; - text?: string; - content?: Array<{ - type?: string; - text?: string; - annotations?: Array<{ - type?: string; - url?: string; - start_index?: number; - end_index?: number; - }>; - }>; - annotations?: Array<{ - type?: string; - url?: string; - start_index?: number; - end_index?: number; - }>; - }>; - output_text?: string; - citations?: string[]; - inline_citations?: Array<{ - start_index: number; - end_index: number; - url: string; - }>; -}; - -function resolveGrokConfig(searchConfig?: SearchConfigRecord): GrokConfig { - const grok = searchConfig?.grok; - return grok && typeof grok === "object" && !Array.isArray(grok) ? (grok as GrokConfig) : {}; -} - -function resolveGrokApiKey(grok?: GrokConfig): string | undefined { +function resolveGrokApiKey(grok?: Record): string | undefined { return ( readConfiguredSecretString(grok?.apiKey, "tools.web.search.grok.apiKey") ?? readProviderEnvValue(["XAI_API_KEY"]) ); } -function resolveGrokModel(grok?: GrokConfig): string { - const model = typeof grok?.model === "string" ? grok.model.trim() : ""; - return model || DEFAULT_GROK_MODEL; -} - -function resolveGrokInlineCitations(grok?: GrokConfig): boolean { - return grok?.inlineCitations === true; -} - -function extractGrokContent(data: GrokSearchResponse): { - text: string | undefined; - annotationCitations: string[]; -} { - for (const output of data.output ?? []) { - if (output.type === "message") { - for (const block of output.content ?? []) { - if (block.type === "output_text" && typeof block.text === "string" && block.text) { - const urls = (block.annotations ?? []) - .filter( - (annotation) => - annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); - return { text: block.text, annotationCitations: [...new Set(urls)] }; - } - } - } - if (output.type === "output_text" && typeof output.text === "string" && output.text) { - const urls = (Array.isArray(output.annotations) ? output.annotations : []) - .filter( - (annotation) => annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); - return { text: output.text, annotationCitations: [...new Set(urls)] }; - } - } - - return { - text: typeof data.output_text === "string" ? data.output_text : undefined, - annotationCitations: [], - }; -} - -async function runGrokSearch(params: { - query: string; - apiKey: string; - model: string; - timeoutSeconds: number; - inlineCitations: boolean; -}): Promise<{ - content: string; - citations: string[]; - inlineCitations?: GrokSearchResponse["inline_citations"]; -}> { - return withTrustedWebSearchEndpoint( - { - url: XAI_API_ENDPOINT, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - }, - body: JSON.stringify({ - model: params.model, - input: [{ role: "user", content: params.query }], - tools: [{ type: "web_search" }], - }), - }, - }, - async (res) => { - if (!res.ok) { - const detail = await res.text(); - throw new Error(`xAI API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as GrokSearchResponse; - const { text, annotationCitations } = extractGrokContent(data); - return { - content: text ?? "No response", - citations: (data.citations ?? []).length > 0 ? data.citations! : annotationCitations, - inlineCitations: data.inline_citations, - }; - }, - ); -} - function createGrokSchema() { return Type.Object({ query: Type.String({ description: "Search query string." }), @@ -197,7 +70,7 @@ function createGrokToolDefinition( return unsupportedResponse; } - const grokConfig = resolveGrokConfig(searchConfig); + const grokConfig = resolveXaiSearchConfig(searchConfig); const apiKey = resolveGrokApiKey(grokConfig); if (!apiKey) { return { @@ -213,8 +86,8 @@ function createGrokToolDefinition( readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; - const model = resolveGrokModel(grokConfig); - const inlineCitations = resolveGrokInlineCitations(grokConfig); + const model = resolveXaiWebSearchModel(searchConfig); + const inlineCitations = resolveXaiInlineCitations(searchConfig); const cacheKey = buildSearchCacheKey([ "grok", query, @@ -228,28 +101,22 @@ function createGrokToolDefinition( } const start = Date.now(); - const result = await runGrokSearch({ + const result = await requestXaiWebSearch({ query, apiKey, model, timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), inlineCitations, }); - const payload = { + const payload = buildXaiWebSearchPayload({ query, provider: "grok", model, tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "grok", - wrapped: true, - }, - content: wrapWebContent(result.content), + content: result.content, citations: result.citations, inlineCitations: result.inlineCitations, - }; + }); writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); return payload; }, @@ -289,7 +156,15 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin { export const __testing = { resolveGrokApiKey, - resolveGrokModel, - resolveGrokInlineCitations, - extractGrokContent, + resolveGrokModel: (grok?: Record) => + resolveXaiWebSearchModel(grok ? { grok } : undefined), + resolveGrokInlineCitations: (grok?: Record) => + resolveXaiInlineCitations(grok ? { grok } : undefined), + extractGrokContent: extractXaiWebSearchContent, + extractXaiWebSearchContent, + resolveXaiInlineCitations, + resolveXaiSearchConfig, + resolveXaiWebSearchModel, + requestXaiWebSearch, + buildXaiWebSearchPayload, } as const; diff --git a/extensions/xai/src/web-search-shared.ts b/extensions/xai/src/web-search-shared.ts new file mode 100644 index 00000000000..47616bcf13c --- /dev/null +++ b/extensions/xai/src/web-search-shared.ts @@ -0,0 +1,171 @@ +import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search"; + +export const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses"; +export const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast"; + +export type XaiWebSearchResponse = { + output?: Array<{ + type?: string; + text?: string; + content?: Array<{ + type?: string; + text?: string; + annotations?: Array<{ + type?: string; + url?: string; + }>; + }>; + annotations?: Array<{ + type?: string; + url?: string; + }>; + }>; + output_text?: string; + citations?: string[]; + inline_citations?: Array<{ + start_index: number; + end_index: number; + url: string; + }>; +}; + +type XaiWebSearchConfig = Record & { + model?: unknown; + inlineCitations?: unknown; +}; + +export type XaiWebSearchResult = { + content: string; + citations: string[]; + inlineCitations?: XaiWebSearchResponse["inline_citations"]; +}; + +export function buildXaiWebSearchPayload(params: { + query: string; + provider: string; + model: string; + tookMs: number; + content: string; + citations: string[]; + inlineCitations?: XaiWebSearchResponse["inline_citations"]; +}): Record { + return { + query: params.query, + provider: params.provider, + model: params.model, + tookMs: params.tookMs, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(params.content, "web_search"), + citations: params.citations, + ...(params.inlineCitations ? { inlineCitations: params.inlineCitations } : {}), + }; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function resolveXaiSearchConfig(searchConfig?: Record): XaiWebSearchConfig { + return (asRecord(searchConfig?.grok) as XaiWebSearchConfig | undefined) ?? {}; +} + +export function resolveXaiWebSearchModel(searchConfig?: Record): string { + const config = resolveXaiSearchConfig(searchConfig); + return typeof config.model === "string" && config.model.trim() + ? config.model.trim() + : XAI_DEFAULT_WEB_SEARCH_MODEL; +} + +export function resolveXaiInlineCitations(searchConfig?: Record): boolean { + return resolveXaiSearchConfig(searchConfig).inlineCitations === true; +} + +export function extractXaiWebSearchContent(data: XaiWebSearchResponse): { + text: string | undefined; + annotationCitations: string[]; +} { + for (const output of data.output ?? []) { + if (output.type === "message") { + for (const block of output.content ?? []) { + if (block.type === "output_text" && typeof block.text === "string" && block.text) { + const urls = (block.annotations ?? []) + .filter( + (annotation) => + annotation.type === "url_citation" && typeof annotation.url === "string", + ) + .map((annotation) => annotation.url as string); + return { text: block.text, annotationCitations: [...new Set(urls)] }; + } + } + } + + if (output.type === "output_text" && typeof output.text === "string" && output.text) { + const urls = (output.annotations ?? []) + .filter( + (annotation) => annotation.type === "url_citation" && typeof annotation.url === "string", + ) + .map((annotation) => annotation.url as string); + return { text: output.text, annotationCitations: [...new Set(urls)] }; + } + } + + return { + text: typeof data.output_text === "string" ? data.output_text : undefined, + annotationCitations: [], + }; +} + +export async function requestXaiWebSearch(params: { + query: string; + model: string; + apiKey: string; + timeoutSeconds: number; + inlineCitations: boolean; +}): Promise { + return await postTrustedWebToolsJson( + { + url: XAI_WEB_SEARCH_ENDPOINT, + timeoutSeconds: params.timeoutSeconds, + apiKey: params.apiKey, + body: { + model: params.model, + input: [{ role: "user", content: params.query }], + tools: [{ type: "web_search" }], + }, + errorLabel: "xAI", + }, + async (response) => { + const data = (await response.json()) as XaiWebSearchResponse; + const { text, annotationCitations } = extractXaiWebSearchContent(data); + const citations = + Array.isArray(data.citations) && data.citations.length > 0 + ? data.citations + : annotationCitations; + return { + content: text ?? "No response", + citations, + inlineCitations: + params.inlineCitations && Array.isArray(data.inline_citations) + ? data.inline_citations + : undefined, + }; + }, + ); +} + +export const __testing = { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + resolveXaiInlineCitations, + resolveXaiSearchConfig, + resolveXaiWebSearchModel, + requestXaiWebSearch, + XAI_DEFAULT_WEB_SEARCH_MODEL, +} as const; diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts index c1d97652d54..d160892c0c5 100644 --- a/extensions/xai/web-search.ts +++ b/extensions/xai/web-search.ts @@ -5,133 +5,29 @@ import { getScopedCredentialValue, normalizeCacheKey, readCache, - readResponseText, + readNumberParam, + readStringParam, resolveCacheTtlMs, resolveTimeoutSeconds, resolveWebSearchProviderCredential, setScopedCredentialValue, type WebSearchProviderPlugin, - withTrustedWebToolsEndpoint, - wrapWebContent, writeCache, } from "openclaw/plugin-sdk/provider-web-search"; +import { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + requestXaiWebSearch, + resolveXaiInlineCitations, + resolveXaiWebSearchModel, +} from "./src/web-search-shared.js"; -const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses"; -const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast"; const XAI_WEB_SEARCH_CACHE = new Map< string, { value: Record; insertedAt: number; expiresAt: number } >(); -type XaiWebSearchResponse = { - output?: Array<{ - type?: string; - text?: string; - content?: Array<{ - type?: string; - text?: string; - annotations?: Array<{ - type?: string; - url?: string; - }>; - }>; - annotations?: Array<{ - type?: string; - url?: string; - }>; - }>; - output_text?: string; - citations?: string[]; - inline_citations?: Array<{ - start_index: number; - end_index: number; - url: string; - }>; -}; - -function extractXaiWebSearchContent(data: XaiWebSearchResponse): { - text: string | undefined; - annotationCitations: string[]; -} { - for (const output of data.output ?? []) { - if (output.type === "message") { - for (const block of output.content ?? []) { - if (block.type === "output_text" && typeof block.text === "string" && block.text) { - const urls = (block.annotations ?? []) - .filter( - (annotation) => - annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); - return { text: block.text, annotationCitations: [...new Set(urls)] }; - } - } - } - - if (output.type === "output_text" && typeof output.text === "string" && output.text) { - const urls = (output.annotations ?? []) - .filter( - (annotation) => annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); - return { text: output.text, annotationCitations: [...new Set(urls)] }; - } - } - - return { - text: typeof data.output_text === "string" ? data.output_text : undefined, - annotationCitations: [], - }; -} - -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function resolveXaiWebSearchConfig( - searchConfig?: Record, -): Record { - return asRecord(searchConfig?.grok) ?? {}; -} - -function resolveXaiWebSearchModel(searchConfig?: Record): string { - const config = resolveXaiWebSearchConfig(searchConfig); - return typeof config.model === "string" && config.model.trim() - ? config.model.trim() - : XAI_DEFAULT_WEB_SEARCH_MODEL; -} - -function resolveXaiInlineCitations(searchConfig?: Record): boolean { - return resolveXaiWebSearchConfig(searchConfig).inlineCitations === true; -} - -function readQuery(args: Record): string { - const value = typeof args.query === "string" ? args.query.trim() : ""; - if (!value) { - throw new Error("query required"); - } - return value; -} - -function readCount(args: Record): number { - const raw = args.count; - const parsed = - typeof raw === "number" && Number.isFinite(raw) - ? raw - : typeof raw === "string" && raw.trim() - ? Number.parseFloat(raw) - : 5; - return Math.max(1, Math.min(10, Math.trunc(parsed))); -} - -async function throwXaiWebSearchApiError(res: Response): Promise { - const detailResult = await readResponseText(res, { maxBytes: 64_000 }); - throw new Error(`xAI API error (${res.status}): ${detailResult.text || res.statusText}`); -} - -async function runXaiWebSearch(params: { +function runXaiWebSearch(params: { query: string; model: string; apiKey: string; @@ -144,61 +40,31 @@ async function runXaiWebSearch(params: { ); const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey); if (cached) { - return { ...cached.value, cached: true }; + return Promise.resolve({ ...cached.value, cached: true }); } - const startedAt = Date.now(); - const payload = await withTrustedWebToolsEndpoint( - { - url: XAI_WEB_SEARCH_ENDPOINT, + return (async () => { + const startedAt = Date.now(); + const result = await requestXaiWebSearch({ + query: params.query, + model: params.model, + apiKey: params.apiKey, timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - }, - body: JSON.stringify({ - model: params.model, - input: [{ role: "user", content: params.query }], - tools: [{ type: "web_search" }], - }), - }, - }, - async ({ response }) => { - if (!response.ok) { - return await throwXaiWebSearchApiError(response); - } + inlineCitations: params.inlineCitations, + }); + const payload = buildXaiWebSearchPayload({ + query: params.query, + provider: "grok", + model: params.model, + tookMs: Date.now() - startedAt, + content: result.content, + citations: result.citations, + inlineCitations: result.inlineCitations, + }); - const data = (await response.json()) as XaiWebSearchResponse; - const { text, annotationCitations } = extractXaiWebSearchContent(data); - const citations = - Array.isArray(data.citations) && data.citations.length > 0 - ? data.citations - : annotationCitations; - - return { - query: params.query, - provider: "grok", - model: params.model, - tookMs: Date.now() - startedAt, - externalContent: { - untrusted: true, - source: "web_search", - provider: "grok", - wrapped: true, - }, - content: wrapWebContent(text ?? "No response", "web_search"), - citations, - ...(params.inlineCitations && Array.isArray(data.inline_citations) - ? { inlineCitations: data.inline_citations } - : {}), - }; - }, - ); - - writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; + writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + })(); } export function createXaiWebSearchProvider(): WebSearchProviderPlugin { @@ -246,8 +112,9 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin { }; } - const query = readQuery(args); - const count = readCount(args); + const query = readStringParam(args, "query", { required: true }); + void readNumberParam(args, "count", { integer: true }); + return await runXaiWebSearch({ query, model: resolveXaiWebSearchModel(ctx.searchConfig), @@ -268,7 +135,9 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin { } export const __testing = { + buildXaiWebSearchPayload, extractXaiWebSearchContent, - resolveXaiWebSearchModel, resolveXaiInlineCitations, + resolveXaiWebSearchModel, + requestXaiWebSearch, }; diff --git a/src/agents/tools/web-search-provider-common.ts b/src/agents/tools/web-search-provider-common.ts index f69876ed04a..79827ef7cb8 100644 --- a/src/agents/tools/web-search-provider-common.ts +++ b/src/agents/tools/web-search-provider-common.ts @@ -92,6 +92,45 @@ export async function withTrustedWebSearchEndpoint( ); } +export async function postTrustedWebToolsJson( + params: { + url: string; + timeoutSeconds: number; + apiKey: string; + body: Record; + errorLabel: string; + maxErrorBytes?: number; + }, + parseResponse: (response: Response) => Promise, +): Promise { + return withTrustedWebToolsEndpoint( + { + url: params.url, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params.body), + }, + }, + async ({ response }) => { + if (!response.ok) { + const detail = await readResponseText(response, { + maxBytes: params.maxErrorBytes ?? 64_000, + }); + throw new Error( + `${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`, + ); + } + return await parseResponse(response); + }, + ); +} + export async function throwWebSearchApiError(res: Response, providerLabel: string): Promise { const detailResult = await readResponseText(res, { maxBytes: 64_000 }); const detail = detailResult.text; diff --git a/src/plugin-sdk/provider-web-search.ts b/src/plugin-sdk/provider-web-search.ts index 258d26e7ee4..9ed067cbf23 100644 --- a/src/plugin-sdk/provider-web-search.ts +++ b/src/plugin-sdk/provider-web-search.ts @@ -23,6 +23,7 @@ export { resolveSearchCount, resolveSearchTimeoutSeconds, resolveSiteName, + postTrustedWebToolsJson, throwWebSearchApiError, withTrustedWebSearchEndpoint, writeCachedSearchPayload, diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 26827e50aa3..986f038e4cd 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -193,7 +193,7 @@ const hasExplicitMemorySlot = (plugins?: OpenClawConfig["plugins"]) => const hasExplicitMemoryEntry = (plugins?: OpenClawConfig["plugins"]) => Boolean(plugins?.entries && Object.prototype.hasOwnProperty.call(plugins.entries, "memory-core")); -const hasExplicitPluginConfig = (plugins?: OpenClawConfig["plugins"]) => { +export const hasExplicitPluginConfig = (plugins?: OpenClawConfig["plugins"]) => { if (!plugins) { return false; } diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index e966e9d4128..7c69aa7ca41 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -4,6 +4,7 @@ import { withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, } from "./bundled-compat.js"; +import { hasExplicitPluginConfig } from "./config-state.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; @@ -12,39 +13,17 @@ import type { ProviderPlugin } from "./types.js"; const log = createSubsystemLogger("plugins"); -function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean { - const plugins = config?.plugins; - if (!plugins) { - return false; - } - if (typeof plugins.enabled === "boolean") { - return true; - } - if (Array.isArray(plugins.allow) && plugins.allow.length > 0) { - return true; - } - if (Array.isArray(plugins.deny) && plugins.deny.length > 0) { - return true; - } - if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) { - return true; - } - if (plugins.entries && Object.keys(plugins.entries).length > 0) { - return true; - } - if (plugins.slots && Object.keys(plugins.slots).length > 0) { - return true; - } - return false; -} - function withBundledProviderVitestCompat(params: { config: PluginLoadOptions["config"]; pluginIds: readonly string[]; env?: PluginLoadOptions["env"]; }): PluginLoadOptions["config"] { const env = params.env ?? process.env; - if (!env.VITEST || hasExplicitPluginConfig(params.config) || params.pluginIds.length === 0) { + if ( + !env.VITEST || + hasExplicitPluginConfig(params.config?.plugins) || + params.pluginIds.length === 0 + ) { return params.config; } diff --git a/src/plugins/web-search-providers.shared.ts b/src/plugins/web-search-providers.shared.ts index 29ba9527590..31a90f50915 100644 --- a/src/plugins/web-search-providers.shared.ts +++ b/src/plugins/web-search-providers.shared.ts @@ -3,36 +3,14 @@ import { withBundledPluginEnablementCompat, } from "./bundled-compat.js"; import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js"; -import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; +import { + hasExplicitPluginConfig, + normalizePluginsConfig, + type NormalizedPluginsConfig, +} from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; import type { PluginWebSearchProviderEntry } from "./types.js"; -export function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean { - const plugins = config?.plugins; - if (!plugins) { - return false; - } - if (typeof plugins.enabled === "boolean") { - return true; - } - if (Array.isArray(plugins.allow) && plugins.allow.length > 0) { - return true; - } - if (Array.isArray(plugins.deny) && plugins.deny.length > 0) { - return true; - } - if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) { - return true; - } - if (plugins.entries && Object.keys(plugins.entries).length > 0) { - return true; - } - if (plugins.slots && Object.keys(plugins.slots).length > 0) { - return true; - } - return false; -} - function resolveBundledWebSearchCompatPluginIds(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; @@ -52,7 +30,11 @@ function withBundledWebSearchVitestCompat(params: { }): PluginLoadOptions["config"] { const env = params.env ?? process.env; const isVitest = Boolean(env.VITEST || process.env.VITEST); - if (!isVitest || hasExplicitPluginConfig(params.config) || params.pluginIds.length === 0) { + if ( + !isVitest || + hasExplicitPluginConfig(params.config?.plugins) || + params.pluginIds.length === 0 + ) { return params.config; } From 9b6f286ac21412fd8f6f105d0f5dada5f3bd5458 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 09:29:58 -0700 Subject: [PATCH 04/15] refactor(channels): share route format and binding helpers --- extensions/line/src/channel-shared.ts | 66 ++++++++++++ extensions/line/src/channel.setup.ts | 47 +------- extensions/line/src/channel.ts | 43 +------- extensions/matrix/src/matrix/format.ts | 33 +----- .../src/matrix/thread-bindings-shared.ts | 28 +---- extensions/telegram/src/format.ts | 42 +------- extensions/telegram/src/thread-bindings.ts | 29 +---- extensions/zalouser/src/channel.ts | 102 ++---------------- extensions/zalouser/src/session-route.ts | 53 ++++++++- src/channels/thread-bindings-policy.ts | 52 +++++++++ src/plugin-sdk/text-runtime.ts | 1 + src/shared/text/auto-linked-file-ref.ts | 27 +++++ 12 files changed, 217 insertions(+), 306 deletions(-) create mode 100644 extensions/line/src/channel-shared.ts create mode 100644 src/shared/text/auto-linked-file-ref.ts diff --git a/extensions/line/src/channel-shared.ts b/extensions/line/src/channel-shared.ts new file mode 100644 index 00000000000..593824f3070 --- /dev/null +++ b/extensions/line/src/channel-shared.ts @@ -0,0 +1,66 @@ +import type { ChannelPlugin } from "../api.js"; +import { + resolveLineAccount, + type OpenClawConfig, + type ResolvedLineAccount, +} from "../runtime-api.js"; +import { lineConfigAdapter } from "./config-adapter.js"; +import { LineChannelConfigSchema } from "./config-schema.js"; + +export const lineChannelMeta = { + id: "line", + label: "LINE", + selectionLabel: "LINE (Messaging API)", + detailLabel: "LINE Bot", + docsPath: "/channels/line", + docsLabel: "line", + blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", + systemImage: "message.fill", +} as const; + +export const lineChannelPluginCommon = { + meta: { + ...lineChannelMeta, + quickstartAllowFrom: true, + }, + capabilities: { + chatTypes: ["direct", "group"], + reactions: false, + threads: false, + media: true, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.line"] }, + configSchema: LineChannelConfigSchema, + config: { + ...lineConfigAdapter, + isConfigured: (account: ResolvedLineAccount) => + Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), + describeAccount: (account: ResolvedLineAccount) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), + tokenSource: account.tokenSource ?? undefined, + }), + }, +} satisfies Pick< + ChannelPlugin, + "meta" | "capabilities" | "reload" | "configSchema" | "config" +>; + +export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const resolved = resolveLineAccount({ cfg, accountId }); + return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); +} + +export function parseLineAllowFromId(raw: string): string | null { + const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); + if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { + return null; + } + return trimmed; +} + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../runtime-api.js"; diff --git a/extensions/line/src/channel.setup.ts b/extensions/line/src/channel.setup.ts index bae717a205d..cbd36f44446 100644 --- a/extensions/line/src/channel.setup.ts +++ b/extensions/line/src/channel.setup.ts @@ -1,52 +1,11 @@ -import { - buildChannelConfigSchema, - LineConfigSchema, - type ChannelPlugin, - type ResolvedLineAccount, -} from "../api.js"; -import { lineConfigAdapter } from "./config-adapter.js"; +import { type ChannelPlugin, type ResolvedLineAccount } from "../api.js"; +import { lineChannelPluginCommon } from "./channel-shared.js"; import { lineSetupAdapter } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; -const meta = { - id: "line", - label: "LINE", - selectionLabel: "LINE (Messaging API)", - detailLabel: "LINE Bot", - docsPath: "/channels/line", - docsLabel: "line", - blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", - systemImage: "message.fill", -} as const; - export const lineSetupPlugin: ChannelPlugin = { id: "line", - meta: { - ...meta, - quickstartAllowFrom: true, - }, - capabilities: { - chatTypes: ["direct", "group"], - reactions: false, - threads: false, - media: true, - nativeCommands: false, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.line"] }, - configSchema: buildChannelConfigSchema(LineConfigSchema), - config: { - ...lineConfigAdapter, - isConfigured: (account) => - Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), - tokenSource: account.tokenSource ?? undefined, - }), - }, + ...lineChannelPluginCommon, setupWizard: lineSetupWizard, setup: lineSetupAdapter, }; diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index d983d2a0172..54cd54ff7bf 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -9,12 +9,10 @@ import { } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload"; import { - buildChannelConfigSchema, buildComputedAccountStatusSnapshot, buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, - LineConfigSchema, processLineMessage, type ChannelPlugin, type ChannelStatusIssue, @@ -23,24 +21,12 @@ import { type OpenClawConfig, type ResolvedLineAccount, } from "../api.js"; -import { lineConfigAdapter } from "./config-adapter.js"; +import { lineChannelPluginCommon } from "./channel-shared.js"; import { resolveLineGroupRequireMention } from "./group-policy.js"; import { getLineRuntime } from "./runtime.js"; import { lineSetupAdapter } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; -// LINE channel metadata -const meta = { - id: "line", - label: "LINE", - selectionLabel: "LINE (Messaging API)", - detailLabel: "LINE Bot", - docsPath: "/channels/line", - docsLabel: "line", - blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", - systemImage: "message.fill", -}; - const resolveLineDmPolicy = createScopedDmSecurityResolver({ channelKey: "line", resolvePolicy: (account) => account.config.dmPolicy, @@ -63,10 +49,7 @@ const collectLineSecurityWarnings = export const linePlugin: ChannelPlugin = { id: "line", - meta: { - ...meta, - quickstartAllowFrom: true, - }, + ...lineChannelPluginCommon, pairing: createTextPairingAdapter({ idLabel: "lineUserId", message: "OpenClaw: your access has been approved.", @@ -83,29 +66,7 @@ export const linePlugin: ChannelPlugin = { }); }, }), - capabilities: { - chatTypes: ["direct", "group"], - reactions: false, - threads: false, - media: true, - nativeCommands: false, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.line"] }, - configSchema: buildChannelConfigSchema(LineConfigSchema), setupWizard: lineSetupWizard, - config: { - ...lineConfigAdapter, - isConfigured: (account) => - Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), - tokenSource: account.tokenSource ?? undefined, - }), - }, security: { resolveDmPolicy: resolveLineDmPolicy, collectWarnings: collectLineSecurityWarnings, diff --git a/extensions/matrix/src/matrix/format.ts b/extensions/matrix/src/matrix/format.ts index 31bddcc5292..efb81ebff2a 100644 --- a/extensions/matrix/src/matrix/format.ts +++ b/extensions/matrix/src/matrix/format.ts @@ -1,4 +1,5 @@ import MarkdownIt from "markdown-it"; +import { isAutoLinkedFileRef } from "openclaw/plugin-sdk/text-runtime"; const md = new MarkdownIt({ html: false, @@ -10,38 +11,6 @@ const md = new MarkdownIt({ md.enable("strikethrough"); const { escapeHtml } = md.utils; - -/** - * Keep bare file references like README.md from becoming external http:// links. - * Telegram already hardens this path; Matrix should not turn common code/docs - * filenames into clickable registrar-style URLs either. - */ -const FILE_EXTENSIONS_WITH_TLD = new Set(["md", "go", "py", "pl", "sh", "am", "at", "be", "cc"]); - -function isAutoLinkedFileRef(href: string, label: string): boolean { - const stripped = href.replace(/^https?:\/\//i, ""); - if (stripped !== label) { - return false; - } - const dotIndex = label.lastIndexOf("."); - if (dotIndex < 1) { - return false; - } - const ext = label.slice(dotIndex + 1).toLowerCase(); - if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) { - return false; - } - const segments = label.split("/"); - if (segments.length > 1) { - for (let i = 0; i < segments.length - 1; i += 1) { - if (segments[i]?.includes(".")) { - return false; - } - } - } - return true; -} - function shouldSuppressAutoLink( tokens: Parameters>[0], idx: number, diff --git a/extensions/matrix/src/matrix/thread-bindings-shared.ts b/extensions/matrix/src/matrix/thread-bindings-shared.ts index f8c9c2b9e3f..7b5adb5eeda 100644 --- a/extensions/matrix/src/matrix/thread-bindings-shared.ts +++ b/extensions/matrix/src/matrix/thread-bindings-shared.ts @@ -1,3 +1,4 @@ +import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/channel-runtime"; import type { BindingTargetKind, SessionBindingRecord, @@ -74,32 +75,7 @@ export function resolveEffectiveBindingExpiry(params: { expiresAt?: number; reason?: "idle-expired" | "max-age-expired"; } { - const idleTimeoutMs = - typeof params.record.idleTimeoutMs === "number" - ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) - : params.defaultIdleTimeoutMs; - const maxAgeMs = - typeof params.record.maxAgeMs === "number" - ? Math.max(0, Math.floor(params.record.maxAgeMs)) - : params.defaultMaxAgeMs; - const inactivityExpiresAt = - idleTimeoutMs > 0 - ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs - : undefined; - const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; - - if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { - return inactivityExpiresAt <= maxAgeExpiresAt - ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } - : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - if (inactivityExpiresAt != null) { - return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; - } - if (maxAgeExpiresAt != null) { - return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - return {}; + return resolveThreadBindingLifecycle(params); } export function toSessionBindingRecord( diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index a9a10965243..4d14f179b2f 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -1,6 +1,8 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { chunkMarkdownIR, + FILE_REF_EXTENSIONS_WITH_TLD, + isAutoLinkedFileRef, markdownToIR, type MarkdownLinkSpan, type MarkdownIR, @@ -31,44 +33,6 @@ function escapeHtmlAttr(text: string): string { * * Excluded: .ai, .io, .tv, .fm (popular domain TLDs like x.ai, vercel.io, github.io) */ -const FILE_EXTENSIONS_WITH_TLD = new Set([ - "md", // Markdown (Moldova) - very common in repos - "go", // Go language - common in Go projects - "py", // Python (Paraguay) - common in Python projects - "pl", // Perl (Poland) - common in Perl projects - "sh", // Shell (Saint Helena) - common for scripts - "am", // Automake files (Armenia) - "at", // Assembly (Austria) - "be", // Backend files (Belgium) - "cc", // C++ source (Cocos Islands) -]); - -/** Detects when markdown-it linkify auto-generated a link from a bare filename (e.g. README.md → http://README.md) */ -function isAutoLinkedFileRef(href: string, label: string): boolean { - const stripped = href.replace(/^https?:\/\//i, ""); - if (stripped !== label) { - return false; - } - const dotIndex = label.lastIndexOf("."); - if (dotIndex < 1) { - return false; - } - const ext = label.slice(dotIndex + 1).toLowerCase(); - if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) { - return false; - } - // Reject if any path segment before the filename contains a dot (looks like a domain) - const segments = label.split("/"); - if (segments.length > 1) { - for (let i = 0; i < segments.length - 1; i++) { - if (segments[i].includes(".")) { - return false; - } - } - } - return true; -} - function buildTelegramLink(link: MarkdownLinkSpan, text: string) { const href = link.href.trim(); if (!href) { @@ -139,7 +103,7 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -const FILE_EXTENSIONS_PATTERN = Array.from(FILE_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|"); +const FILE_EXTENSIONS_PATTERN = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|"); const AUTO_LINKED_ANCHOR_PATTERN = /]*>\1<\/a>/gi; const FILE_REFERENCE_PATTERN = new RegExp( `(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`, diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts index 8b7be041197..0078c3362e6 100644 --- a/extensions/telegram/src/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveThreadBindingEffectiveExpiresAt } from "openclaw/plugin-sdk/channel-runtime"; import { formatThreadBindingDurationLabel } from "openclaw/plugin-sdk/channel-runtime"; import { registerSessionBindingAdapter, @@ -115,32 +116,6 @@ function toTelegramTargetKind(raw: BindingTargetKind): TelegramBindingTargetKind return raw === "subagent" ? "subagent" : "acp"; } -function resolveEffectiveBindingExpiresAt(params: { - record: TelegramThreadBindingRecord; - defaultIdleTimeoutMs: number; - defaultMaxAgeMs: number; -}): number | undefined { - const idleTimeoutMs = - typeof params.record.idleTimeoutMs === "number" - ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) - : params.defaultIdleTimeoutMs; - const maxAgeMs = - typeof params.record.maxAgeMs === "number" - ? Math.max(0, Math.floor(params.record.maxAgeMs)) - : params.defaultMaxAgeMs; - - const inactivityExpiresAt = - idleTimeoutMs > 0 - ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs - : undefined; - const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; - - if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { - return Math.min(inactivityExpiresAt, maxAgeExpiresAt); - } - return inactivityExpiresAt ?? maxAgeExpiresAt; -} - function toSessionBindingRecord( record: TelegramThreadBindingRecord, defaults: { idleTimeoutMs: number; maxAgeMs: number }, @@ -159,7 +134,7 @@ function toSessionBindingRecord( }, status: "active", boundAt: record.boundAt, - expiresAt: resolveEffectiveBindingExpiresAt({ + expiresAt: resolveThreadBindingEffectiveExpiresAt({ record, defaultIdleTimeoutMs: defaults.idleTimeoutMs, defaultMaxAgeMs: defaults.maxAgeMs, diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 24e46323a8d..571ad31c164 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -39,7 +39,12 @@ import { probeZalouser } from "./probe.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; -import { resolveZalouserOutboundSessionRoute } from "./session-route.js"; +import { + normalizeZalouserTarget, + parseZalouserDirectoryGroupId, + parseZalouserOutboundTarget, + resolveZalouserOutboundSessionRoute, +} from "./session-route.js"; import { zalouserSetupAdapter } from "./setup-core.js"; import { zalouserSetupWizard } from "./setup-surface.js"; import { createZalouserPluginBase } from "./shared.js"; @@ -56,97 +61,6 @@ import { const ZALOUSER_TEXT_CHUNK_LIMIT = 2000; -function stripZalouserTargetPrefix(raw: string): string { - return raw - .trim() - .replace(/^(zalouser|zlu):/i, "") - .trim(); -} - -function normalizePrefixedTarget(raw: string): string | undefined { - const trimmed = stripZalouserTargetPrefix(raw); - if (!trimmed) { - return undefined; - } - - const lower = trimmed.toLowerCase(); - if (lower.startsWith("group:")) { - const id = trimmed.slice("group:".length).trim(); - return id ? `group:${id}` : undefined; - } - if (lower.startsWith("g:")) { - const id = trimmed.slice("g:".length).trim(); - return id ? `group:${id}` : undefined; - } - if (lower.startsWith("user:")) { - const id = trimmed.slice("user:".length).trim(); - return id ? `user:${id}` : undefined; - } - if (lower.startsWith("dm:")) { - const id = trimmed.slice("dm:".length).trim(); - return id ? `user:${id}` : undefined; - } - if (lower.startsWith("u:")) { - const id = trimmed.slice("u:".length).trim(); - return id ? `user:${id}` : undefined; - } - if (/^g-\S+$/i.test(trimmed)) { - return `group:${trimmed}`; - } - if (/^u-\S+$/i.test(trimmed)) { - return `user:${trimmed}`; - } - - return trimmed; -} - -function parseZalouserOutboundTarget(raw: string): { - threadId: string; - isGroup: boolean; -} { - const normalized = normalizePrefixedTarget(raw); - if (!normalized) { - throw new Error("Zalouser target is required"); - } - const lowered = normalized.toLowerCase(); - if (lowered.startsWith("group:")) { - const threadId = normalized.slice("group:".length).trim(); - if (!threadId) { - throw new Error("Zalouser group target is missing group id"); - } - return { threadId, isGroup: true }; - } - if (lowered.startsWith("user:")) { - const threadId = normalized.slice("user:".length).trim(); - if (!threadId) { - throw new Error("Zalouser user target is missing user id"); - } - return { threadId, isGroup: false }; - } - // Backward-compatible fallback for bare IDs. - // Group sends should use explicit `group:` targets. - return { threadId: normalized, isGroup: false }; -} - -function parseZalouserDirectoryGroupId(raw: string): string { - const normalized = normalizePrefixedTarget(raw); - if (!normalized) { - throw new Error("Zalouser group target is required"); - } - const lowered = normalized.toLowerCase(); - if (lowered.startsWith("group:")) { - const groupId = normalized.slice("group:".length).trim(); - if (!groupId) { - throw new Error("Zalouser group target is missing group id"); - } - return groupId; - } - if (lowered.startsWith("user:")) { - throw new Error("Zalouser group members lookup requires a group target (group:)"); - } - return normalized; -} - function resolveZalouserQrProfile(accountId?: string | null): string { const normalized = normalizeAccountId(accountId); if (!normalized || normalized === DEFAULT_ACCOUNT_ID) { @@ -318,11 +232,11 @@ export const zalouserPlugin: ChannelPlugin = { }, actions: zalouserMessageActions, messaging: { - normalizeTarget: (raw) => normalizePrefixedTarget(raw), + normalizeTarget: (raw) => normalizeZalouserTarget(raw), resolveOutboundSessionRoute: (params) => resolveZalouserOutboundSessionRoute(params), targetResolver: { looksLikeId: (raw) => { - const normalized = normalizePrefixedTarget(raw); + const normalized = normalizeZalouserTarget(raw); if (!normalized) { return false; } diff --git a/extensions/zalouser/src/session-route.ts b/extensions/zalouser/src/session-route.ts index c6a1761818d..1356ec434c0 100644 --- a/extensions/zalouser/src/session-route.ts +++ b/extensions/zalouser/src/session-route.ts @@ -3,14 +3,14 @@ import { type ChannelOutboundSessionRouteParams, } from "openclaw/plugin-sdk/core"; -function stripZalouserTargetPrefix(raw: string): string { +export function stripZalouserTargetPrefix(raw: string): string { return raw .trim() .replace(/^(zalouser|zlu):/i, "") .trim(); } -function normalizePrefixedTarget(raw: string): string | undefined { +export function normalizeZalouserTarget(raw: string): string | undefined { const trimmed = stripZalouserTargetPrefix(raw); if (!trimmed) { return undefined; @@ -47,8 +47,55 @@ function normalizePrefixedTarget(raw: string): string | undefined { return trimmed; } +export function parseZalouserOutboundTarget(raw: string): { + threadId: string; + isGroup: boolean; +} { + const normalized = normalizeZalouserTarget(raw); + if (!normalized) { + throw new Error("Zalouser target is required"); + } + const lowered = normalized.toLowerCase(); + if (lowered.startsWith("group:")) { + const threadId = normalized.slice("group:".length).trim(); + if (!threadId) { + throw new Error("Zalouser group target is missing group id"); + } + return { threadId, isGroup: true }; + } + if (lowered.startsWith("user:")) { + const threadId = normalized.slice("user:".length).trim(); + if (!threadId) { + throw new Error("Zalouser user target is missing user id"); + } + return { threadId, isGroup: false }; + } + // Backward-compatible fallback for bare IDs. + // Group sends should use explicit `group:` targets. + return { threadId: normalized, isGroup: false }; +} + +export function parseZalouserDirectoryGroupId(raw: string): string { + const normalized = normalizeZalouserTarget(raw); + if (!normalized) { + throw new Error("Zalouser group target is required"); + } + const lowered = normalized.toLowerCase(); + if (lowered.startsWith("group:")) { + const groupId = normalized.slice("group:".length).trim(); + if (!groupId) { + throw new Error("Zalouser group target is missing group id"); + } + return groupId; + } + if (lowered.startsWith("user:")) { + throw new Error("Zalouser group members lookup requires a group target (group:)"); + } + return normalized; +} + export function resolveZalouserOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { - const normalized = normalizePrefixedTarget(params.target); + const normalized = normalizeZalouserTarget(params.target); if (!normalized) { return null; } diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 5fe30994da0..730984d61df 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -73,6 +73,58 @@ export function resolveThreadBindingMaxAgeMs(params: { return Math.floor(maxAgeHours * 60 * 60 * 1000); } +type ThreadBindingLifecycleRecord = { + boundAt: number; + lastActivityAt: number; + idleTimeoutMs?: number; + maxAgeMs?: number; +}; + +export function resolveThreadBindingLifecycle(params: { + record: ThreadBindingLifecycleRecord; + defaultIdleTimeoutMs: number; + defaultMaxAgeMs: number; +}): { + expiresAt?: number; + reason?: "idle-expired" | "max-age-expired"; +} { + const idleTimeoutMs = + typeof params.record.idleTimeoutMs === "number" + ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) + : params.defaultIdleTimeoutMs; + const maxAgeMs = + typeof params.record.maxAgeMs === "number" + ? Math.max(0, Math.floor(params.record.maxAgeMs)) + : params.defaultMaxAgeMs; + + const inactivityExpiresAt = + idleTimeoutMs > 0 + ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs + : undefined; + const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; + + if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { + return inactivityExpiresAt <= maxAgeExpiresAt + ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } + : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + if (inactivityExpiresAt != null) { + return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; + } + if (maxAgeExpiresAt != null) { + return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + return {}; +} + +export function resolveThreadBindingEffectiveExpiresAt(params: { + record: ThreadBindingLifecycleRecord; + defaultIdleTimeoutMs: number; + defaultMaxAgeMs: number; +}): number | undefined { + return resolveThreadBindingLifecycle(params).expiresAt; +} + export function resolveThreadBindingsEnabled(params: { channelEnabledRaw: unknown; sessionEnabledRaw: unknown; diff --git a/src/plugin-sdk/text-runtime.ts b/src/plugin-sdk/text-runtime.ts index bfdb2db690f..5dd70cdcc3c 100644 --- a/src/plugin-sdk/text-runtime.ts +++ b/src/plugin-sdk/text-runtime.ts @@ -13,6 +13,7 @@ export * from "../shared/global-singleton.js"; export * from "../shared/string-normalization.js"; export * from "../shared/string-sample.js"; export * from "../shared/text/assistant-visible-text.js"; +export * from "../shared/text/auto-linked-file-ref.js"; export * from "../shared/text/code-regions.js"; export * from "../shared/text/reasoning-tags.js"; export * from "../terminal/safe-text.js"; diff --git a/src/shared/text/auto-linked-file-ref.ts b/src/shared/text/auto-linked-file-ref.ts new file mode 100644 index 00000000000..6fd5693202b --- /dev/null +++ b/src/shared/text/auto-linked-file-ref.ts @@ -0,0 +1,27 @@ +const FILE_REF_EXTENSIONS = ["md", "go", "py", "pl", "sh", "am", "at", "be", "cc"] as const; + +export const FILE_REF_EXTENSIONS_WITH_TLD = new Set(FILE_REF_EXTENSIONS); + +export function isAutoLinkedFileRef(href: string, label: string): boolean { + const stripped = href.replace(/^https?:\/\//i, ""); + if (stripped !== label) { + return false; + } + const dotIndex = label.lastIndexOf("."); + if (dotIndex < 1) { + return false; + } + const ext = label.slice(dotIndex + 1).toLowerCase(); + if (!FILE_REF_EXTENSIONS_WITH_TLD.has(ext)) { + return false; + } + const segments = label.split("/"); + if (segments.length > 1) { + for (let i = 0; i < segments.length - 1; i += 1) { + if (segments[i]?.includes(".")) { + return false; + } + } + } + return true; +} From aa78a0c00e5fc7eca6393a7977c7528d3cff560c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 09:30:09 -0700 Subject: [PATCH 05/15] refactor(plugin-sdk): formalize runtime contract barrels --- extensions/acpx/runtime-api.ts | 39 +++- extensions/google/runtime-api.ts | 2 +- extensions/lobster/runtime-api.ts | 13 +- extensions/matrix/runtime-api.ts | 1 + extensions/zai/runtime-api.ts | 6 +- package.json | 16 ++ ...check-plugin-extension-import-boundary.mjs | 5 +- scripts/lib/plugin-sdk-entrypoints.json | 4 + src/plugin-sdk/acp-runtime.ts | 1 + src/plugin-sdk/core.ts | 2 + src/plugin-sdk/matrix.ts | 179 +----------------- src/plugin-sdk/provider-env-vars.ts | 6 + src/plugin-sdk/provider-google.ts | 4 + src/plugin-sdk/provider-zai-endpoint.ts | 7 + src/plugin-sdk/runtime-api-guardrails.test.ts | 1 + src/plugin-sdk/telegram.ts | 128 +------------ .../runtime/runtime-matrix-contract.ts | 178 +++++++++++++++++ .../runtime/runtime-telegram-contract.ts | 130 +++++++++++++ src/plugins/runtime/types-channel.ts | 116 ++++++------ test/plugin-extension-import-boundary.test.ts | 5 +- 20 files changed, 474 insertions(+), 369 deletions(-) create mode 100644 src/plugin-sdk/provider-env-vars.ts create mode 100644 src/plugin-sdk/provider-google.ts create mode 100644 src/plugin-sdk/provider-zai-endpoint.ts create mode 100644 src/plugins/runtime/runtime-matrix-contract.ts create mode 100644 src/plugins/runtime/runtime-telegram-contract.ts diff --git a/extensions/acpx/runtime-api.ts b/extensions/acpx/runtime-api.ts index 9a019cdd0e6..7a8a555a9a7 100644 --- a/extensions/acpx/runtime-api.ts +++ b/extensions/acpx/runtime-api.ts @@ -1 +1,38 @@ -export * from "../../src/plugin-sdk/acpx.js"; +export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime"; +export { + AcpRuntimeError, + registerAcpRuntimeBackend, + unregisterAcpRuntimeBackend, +} from "openclaw/plugin-sdk/acp-runtime"; +export type { + AcpRuntime, + AcpRuntimeCapabilities, + AcpRuntimeDoctorReport, + AcpRuntimeEnsureInput, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimeStatus, + AcpRuntimeTurnInput, + AcpSessionUpdateTag, +} from "openclaw/plugin-sdk/acp-runtime"; +export type { + OpenClawPluginApi, + OpenClawPluginConfigSchema, + OpenClawPluginService, + OpenClawPluginServiceContext, + PluginLogger, +} from "openclaw/plugin-sdk/core"; +export type { + WindowsSpawnProgram, + WindowsSpawnProgramCandidate, + WindowsSpawnResolution, +} from "openclaw/plugin-sdk/windows-spawn"; +export { + applyWindowsSpawnProgramPolicy, + materializeWindowsSpawnProgram, + resolveWindowsSpawnProgramCandidate, +} from "openclaw/plugin-sdk/windows-spawn"; +export { + listKnownProviderAuthEnvVarNames, + omitEnvKeysCaseInsensitive, +} from "openclaw/plugin-sdk/provider-env-vars"; diff --git a/extensions/google/runtime-api.ts b/extensions/google/runtime-api.ts index 60e25c7303e..9b2b8047998 100644 --- a/extensions/google/runtime-api.ts +++ b/extensions/google/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/google.js"; +export { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/provider-google"; diff --git a/extensions/lobster/runtime-api.ts b/extensions/lobster/runtime-api.ts index 24898e04cf5..d883e0853b3 100644 --- a/extensions/lobster/runtime-api.ts +++ b/extensions/lobster/runtime-api.ts @@ -1 +1,12 @@ -export * from "../../src/plugin-sdk/lobster.js"; +export { definePluginEntry } from "openclaw/plugin-sdk/core"; +export type { + AnyAgentTool, + OpenClawPluginApi, + OpenClawPluginToolContext, + OpenClawPluginToolFactory, +} from "openclaw/plugin-sdk/core"; +export { + applyWindowsSpawnProgramPolicy, + materializeWindowsSpawnProgram, + resolveWindowsSpawnProgramCandidate, +} from "openclaw/plugin-sdk/windows-spawn"; diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index 1aaee387fc8..865936cb6ff 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -3,3 +3,4 @@ // matrix-js-sdk during plain runtime-api import. export * from "./src/auth-precedence.js"; export * from "./helper-api.js"; +export * from "./thread-bindings-runtime.js"; diff --git a/extensions/zai/runtime-api.ts b/extensions/zai/runtime-api.ts index 16d46dd4362..f512627cde8 100644 --- a/extensions/zai/runtime-api.ts +++ b/extensions/zai/runtime-api.ts @@ -1 +1,5 @@ -export * from "../../src/plugin-sdk/zai.js"; +export { + detectZaiEndpoint, + type ZaiDetectedEndpoint, + type ZaiEndpointId, +} from "openclaw/plugin-sdk/provider-zai-endpoint"; diff --git a/package.json b/package.json index b8fe827b3e7..a522ced8380 100644 --- a/package.json +++ b/package.json @@ -169,6 +169,10 @@ "types": "./dist/plugin-sdk/process-runtime.d.ts", "default": "./dist/plugin-sdk/process-runtime.js" }, + "./plugin-sdk/windows-spawn": { + "types": "./dist/plugin-sdk/windows-spawn.d.ts", + "default": "./dist/plugin-sdk/windows-spawn.js" + }, "./plugin-sdk/acp-runtime": { "types": "./dist/plugin-sdk/acp-runtime.d.ts", "default": "./dist/plugin-sdk/acp-runtime.js" @@ -357,6 +361,14 @@ "types": "./dist/plugin-sdk/provider-catalog.d.ts", "default": "./dist/plugin-sdk/provider-catalog.js" }, + "./plugin-sdk/provider-env-vars": { + "types": "./dist/plugin-sdk/provider-env-vars.d.ts", + "default": "./dist/plugin-sdk/provider-env-vars.js" + }, + "./plugin-sdk/provider-google": { + "types": "./dist/plugin-sdk/provider-google.d.ts", + "default": "./dist/plugin-sdk/provider-google.js" + }, "./plugin-sdk/provider-models": { "types": "./dist/plugin-sdk/provider-models.d.ts", "default": "./dist/plugin-sdk/provider-models.js" @@ -377,6 +389,10 @@ "types": "./dist/plugin-sdk/provider-web-search.d.ts", "default": "./dist/plugin-sdk/provider-web-search.js" }, + "./plugin-sdk/provider-zai-endpoint": { + "types": "./dist/plugin-sdk/provider-zai-endpoint.d.ts", + "default": "./dist/plugin-sdk/provider-zai-endpoint.js" + }, "./plugin-sdk/image-generation": { "types": "./dist/plugin-sdk/image-generation.d.ts", "default": "./dist/plugin-sdk/image-generation.js" diff --git a/scripts/check-plugin-extension-import-boundary.mjs b/scripts/check-plugin-extension-import-boundary.mjs index 13c4fa596a3..bbe9f9702f5 100644 --- a/scripts/check-plugin-extension-import-boundary.mjs +++ b/scripts/check-plugin-extension-import-boundary.mjs @@ -194,7 +194,10 @@ function scanWebSearchRegistrySmells(sourceFile, filePath) { function shouldSkipFile(filePath) { const relativeFile = normalizePath(filePath); - return relativeFile.startsWith("src/plugins/contracts/"); + return ( + relativeFile.startsWith("src/plugins/contracts/") || + /^src\/plugins\/runtime\/runtime-[^/]+-contract\.[cm]?[jt]s$/u.test(relativeFile) + ); } export async function collectPluginExtensionImportBoundaryInventory() { diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index e1991f4ab76..e5dad4777eb 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -32,6 +32,7 @@ "cli-runtime", "hook-runtime", "process-runtime", + "windows-spawn", "acp-runtime", "telegram", "telegram-core", @@ -79,11 +80,14 @@ "provider-auth-login", "plugin-entry", "provider-catalog", + "provider-env-vars", + "provider-google", "provider-models", "provider-onboard", "provider-stream", "provider-usage", "provider-web-search", + "provider-zai-endpoint", "image-generation", "reply-history", "media-understanding", diff --git a/src/plugin-sdk/acp-runtime.ts b/src/plugin-sdk/acp-runtime.ts index 84435bb896a..7767d042f9b 100644 --- a/src/plugin-sdk/acp-runtime.ts +++ b/src/plugin-sdk/acp-runtime.ts @@ -2,6 +2,7 @@ export { getAcpSessionManager } from "../acp/control-plane/manager.js"; export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js"; +export { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../acp/runtime/registry.js"; export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js"; export type { AcpRuntime, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 38509cef4ab..c8c7980fbd2 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -51,6 +51,8 @@ export type { ProviderAuthMethodNonInteractiveContext, ProviderAuthMethod, ProviderAuthResult, + OpenClawPluginToolContext, + OpenClawPluginToolFactory, OpenClawPluginCommandDefinition, OpenClawPluginDefinition, PluginCommandContext, diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 660fe7183fb..3d6ff402d59 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -1,178 +1 @@ -// Narrow plugin-sdk surface for the bundled matrix plugin. -// Keep this list additive and scoped to symbols used under extensions/matrix. - -import { createOptionalChannelSetupSurface } from "./channel-setup.js"; - -export { - createActionGate, - jsonResult, - readNumberParam, - readReactionParams, - readStringArrayParam, - readStringParam, -} from "../agents/tools/common.js"; -export type { ReplyPayload } from "../auto-reply/types.js"; -export { resolveAckReaction } from "../agents/identity.js"; -export { - compileAllowlist, - resolveCompiledAllowlistMatch, - resolveAllowlistCandidates, - resolveAllowlistMatchByCandidates, -} from "../channels/allowlist-match.js"; -export { - addAllowlistUserEntriesFromConfigEntry, - buildAllowlistResolutionSummary, - canonicalizeAllowlistWithResolvedIds, - mergeAllowlist, - patchAllowlistUsersInConfigEntries, - summarizeMapping, -} from "../channels/allowlists/resolve-utils.js"; -export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js"; -export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js"; -export { resolveControlCommandGate } from "../channels/command-gating.js"; -export type { NormalizedLocation } from "../channels/location.js"; -export { formatLocationText, toLocationContext } from "../channels/location.js"; -export { logInboundDrop, logTypingFailure } from "../channels/logging.js"; -export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; -export { formatAllowlistMatchMeta } from "../channels/plugins/allowlist-match.js"; -export { - buildChannelKeyCandidates, - resolveChannelEntryMatch, -} from "../channels/plugins/channel-config.js"; -export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { - buildSingleChannelSecretPromptState, - addWildcardAllowFrom, - mergeAllowFromEntries, - promptAccountId, - promptSingleChannelSecretInput, - setTopLevelChannelGroupPolicy, -} from "../channels/plugins/setup-wizard-helpers.js"; -export { promptChannelAccessConfig } from "../channels/plugins/setup-group-access.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export { - applyAccountNameToChannelSection, - moveSingleAccountChannelSectionToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export type { - BaseProbeResult, - ChannelDirectoryEntry, - ChannelGroupContext, - ChannelMessageActionAdapter, - ChannelMessageActionContext, - ChannelMessageActionName, - ChannelMessageToolDiscovery, - ChannelMessageToolSchemaContribution, - ChannelOutboundAdapter, - ChannelResolveKind, - ChannelResolveResult, - ChannelSetupInput, - ChannelToolSend, -} from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; -export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; -export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js"; -export { - resolveThreadBindingIdleTimeoutMsForChannel, - resolveThreadBindingMaxAgeMsForChannel, -} from "../channels/thread-bindings-policy.js"; -export { - setMatrixThreadBindingIdleTimeoutBySessionKey, - setMatrixThreadBindingMaxAgeBySessionKey, -} from "../../extensions/matrix/thread-bindings-runtime.js"; -export { createTypingCallbacks } from "../channels/typing.js"; -export { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; -export type { OpenClawConfig } from "../config/config.js"; -export { - GROUP_POLICY_BLOCKED_LABEL, - resolveAllowlistProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, - warnMissingProviderGroupPolicyFallbackOnce, -} from "../config/runtime-group-policy.js"; -export type { - DmPolicy, - GroupPolicy, - GroupToolPolicyConfig, - MarkdownTableMode, -} from "../config/types.js"; -export type { SecretInput } from "./secret-input.js"; -export { - buildSecretInputSchema, - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "./secret-input.js"; -export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; -export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; -export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js"; -export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; -export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js"; -export { - getSessionBindingService, - registerSessionBindingAdapter, - unregisterSessionBindingAdapter, -} from "../infra/outbound/session-binding-service.js"; -export { resolveOutboundSendDep } from "../infra/outbound/send-deps.js"; -export type { - BindingTargetKind, - SessionBindingRecord, -} from "../infra/outbound/session-binding-service.js"; -export { isPrivateOrLoopbackHost } from "../gateway/net.js"; -export { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; -export type { PollInput } from "../polls.js"; -export { normalizePollInput } from "../polls.js"; -export { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - normalizeOptionalAccountId, - resolveAgentIdFromSessionKey, -} from "../routing/session-key.js"; -export type { RuntimeEnv } from "../runtime.js"; -export { normalizeStringEntries } from "../shared/string-normalization.js"; -export { formatDocsLink } from "../terminal/links.js"; -export { redactSensitiveText } from "../logging/redact.js"; -export type { WizardPrompter } from "../wizard/prompts.js"; -export { - evaluateGroupRouteAccessForPolicy, - resolveSenderScopedGroupPolicy, -} from "./group-access.js"; -export { createChannelPairingController } from "./channel-pairing.js"; -export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; -export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; -export { runPluginCommandWithTimeout } from "./run-command.js"; -export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js"; -export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; -export { - buildProbeChannelStatusSummary, - collectStatusIssuesFromLastError, -} from "./status-helpers.js"; -export { - resolveMatrixAccountStorageRoot, - resolveMatrixCredentialsDir, - resolveMatrixCredentialsPath, - resolveMatrixLegacyFlatStoragePaths, -} from "../../extensions/matrix/helper-api.js"; -export { getMatrixScopedEnvVarNames } from "../../extensions/matrix/helper-api.js"; -export { - requiresExplicitMatrixDefaultAccount, - resolveMatrixDefaultOrOnlyAccountId, -} from "../../extensions/matrix/helper-api.js"; - -const matrixSetup = createOptionalChannelSetupSurface({ - channel: "matrix", - label: "Matrix", - npmSpec: "@openclaw/matrix", - docsPath: "/channels/matrix", -}); - -export const matrixSetupWizard = matrixSetup.setupWizard; -export const matrixSetupAdapter = matrixSetup.setupAdapter; +export * from "../plugins/runtime/runtime-matrix-contract.js"; diff --git a/src/plugin-sdk/provider-env-vars.ts b/src/plugin-sdk/provider-env-vars.ts new file mode 100644 index 00000000000..fb4d0271bf1 --- /dev/null +++ b/src/plugin-sdk/provider-env-vars.ts @@ -0,0 +1,6 @@ +// Public provider auth environment variable helpers for plugin runtimes. + +export { + listKnownProviderAuthEnvVarNames, + omitEnvKeysCaseInsensitive, +} from "../secrets/provider-env-vars.js"; diff --git a/src/plugin-sdk/provider-google.ts b/src/plugin-sdk/provider-google.ts new file mode 100644 index 00000000000..43130b853ca --- /dev/null +++ b/src/plugin-sdk/provider-google.ts @@ -0,0 +1,4 @@ +// Public Google provider helpers shared by bundled Google extensions. + +export { normalizeGoogleModelId } from "../agents/model-id-normalization.js"; +export { parseGeminiAuth } from "../infra/gemini-auth.js"; diff --git a/src/plugin-sdk/provider-zai-endpoint.ts b/src/plugin-sdk/provider-zai-endpoint.ts new file mode 100644 index 00000000000..d2c288b7ed6 --- /dev/null +++ b/src/plugin-sdk/provider-zai-endpoint.ts @@ -0,0 +1,7 @@ +// Public Z.AI endpoint detection helpers for provider plugins. + +export { + detectZaiEndpoint, + type ZaiDetectedEndpoint, + type ZaiEndpointId, +} from "../plugins/provider-zai-endpoint.js"; diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index 78a39d7ccb3..2158edff7d0 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -38,6 +38,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { "extensions/matrix/runtime-api.ts": [ 'export * from "./src/auth-precedence.js";', 'export * from "./helper-api.js";', + 'export * from "./thread-bindings-runtime.js";', ], "extensions/nextcloud-talk/runtime-api.ts": [ 'export * from "../../src/plugin-sdk/nextcloud-talk.js";', diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 4b1d41df386..6a579af19f4 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -1,127 +1 @@ -export type { - ChannelAccountSnapshot, - ChannelGatewayContext, - ChannelMessageActionAdapter, - ChannelPlugin, -} from "../channels/plugins/types.js"; -export type { OpenClawConfig } from "../config/config.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; -export type { - TelegramAccountConfig, - TelegramActionConfig, - TelegramNetworkConfig, -} from "../config/types.js"; -export type { - ChannelConfiguredBindingProvider, - ChannelConfiguredBindingConversationRef, - ChannelConfiguredBindingMatch, -} from "../channels/plugins/types.adapters.js"; -export type { InspectedTelegramAccount } from "../../extensions/telegram/api.js"; -export type { ResolvedTelegramAccount } from "../../extensions/telegram/api.js"; -export type { TelegramProbe } from "../../extensions/telegram/runtime-api.js"; -export type { TelegramButtonStyle, TelegramInlineButtons } from "../../extensions/telegram/api.js"; -export type { StickerMetadata } from "../../extensions/telegram/api.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -export { parseTelegramTopicConversation } from "../acp/conversation-id.js"; -export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; -export { resolveTelegramPollVisibility } from "../poll-params.js"; - -export { - PAIRING_APPROVED_MESSAGE, - applyAccountNameToChannelSection, - buildChannelConfigSchema, - deleteAccountFromConfigSection, - formatPairingApproveHint, - getChatChannelMeta, - migrateBaseNameToDefaultAccount, - setAccountEnabledInConfigSection, -} from "./channel-plugin-common.js"; - -export { - projectCredentialSnapshotFields, - resolveConfiguredFromCredentialStatuses, -} from "../channels/account-snapshot-fields.js"; -export { - resolveAllowlistProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, -} from "../config/runtime-group-policy.js"; -export { - listTelegramDirectoryGroupsFromConfig, - listTelegramDirectoryPeersFromConfig, -} from "../../extensions/telegram/api.js"; -export { - resolveTelegramGroupRequireMention, - resolveTelegramGroupToolPolicy, -} from "../../extensions/telegram/api.js"; -export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; - -export { buildTokenChannelStatusSummary } from "./status-helpers.js"; - -export { - createTelegramActionGate, - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramPollActionGateState, -} from "../../extensions/telegram/api.js"; -export { inspectTelegramAccount } from "../../extensions/telegram/api.js"; -export { - looksLikeTelegramTargetId, - normalizeTelegramMessagingTarget, -} from "../../extensions/telegram/api.js"; -export { - parseTelegramReplyToMessageId, - parseTelegramThreadId, -} from "../../extensions/telegram/api.js"; -export { - isNumericTelegramUserId, - normalizeTelegramAllowFromEntry, -} from "../../extensions/telegram/api.js"; -export { fetchTelegramChatId } from "../../extensions/telegram/api.js"; -export { - resolveTelegramInlineButtonsScope, - resolveTelegramTargetChatType, -} from "../../extensions/telegram/api.js"; -export { resolveTelegramReactionLevel } from "../../extensions/telegram/api.js"; -export { - auditTelegramGroupMembership, - collectTelegramUnmentionedGroupIds, - createForumTopicTelegram, - deleteMessageTelegram, - editForumTopicTelegram, - editMessageReplyMarkupTelegram, - editMessageTelegram, - monitorTelegramProvider, - pinMessageTelegram, - reactMessageTelegram, - renameForumTopicTelegram, - probeTelegram, - sendMessageTelegram, - sendPollTelegram, - sendStickerTelegram, - sendTypingTelegram, - unpinMessageTelegram, -} from "../../extensions/telegram/runtime-api.js"; -export { getCacheStats, searchStickers } from "../../extensions/telegram/api.js"; -export { resolveTelegramToken } from "../../extensions/telegram/runtime-api.js"; -export { telegramMessageActions } from "../../extensions/telegram/runtime-api.js"; -export { - setTelegramThreadBindingIdleTimeoutBySessionKey, - setTelegramThreadBindingMaxAgeBySessionKey, -} from "../../extensions/telegram/runtime-api.js"; -export { collectTelegramStatusIssues } from "../../extensions/telegram/api.js"; -export { sendTelegramPayloadMessages } from "../../extensions/telegram/api.js"; -export { - buildBrowseProvidersButton, - buildModelsKeyboard, - buildProviderKeyboard, - calculateTotalPages, - getModelsPageSize, - type ProviderInfo, -} from "../../extensions/telegram/api.js"; -export { - isTelegramExecApprovalApprover, - isTelegramExecApprovalClientEnabled, -} from "../../extensions/telegram/api.js"; +export * from "../plugins/runtime/runtime-telegram-contract.js"; diff --git a/src/plugins/runtime/runtime-matrix-contract.ts b/src/plugins/runtime/runtime-matrix-contract.ts new file mode 100644 index 00000000000..ec33e96ef2f --- /dev/null +++ b/src/plugins/runtime/runtime-matrix-contract.ts @@ -0,0 +1,178 @@ +// Narrow plugin-sdk surface for the bundled matrix plugin. +// Keep this list additive and scoped to symbols used under extensions/matrix. + +import { createOptionalChannelSetupSurface } from "../../plugin-sdk/channel-setup.js"; + +export { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringParam, +} from "../../agents/tools/common.js"; +export type { ReplyPayload } from "../../auto-reply/types.js"; +export { resolveAckReaction } from "../../agents/identity.js"; +export { + compileAllowlist, + resolveCompiledAllowlistMatch, + resolveAllowlistCandidates, + resolveAllowlistMatchByCandidates, +} from "../../channels/allowlist-match.js"; +export { + addAllowlistUserEntriesFromConfigEntry, + buildAllowlistResolutionSummary, + canonicalizeAllowlistWithResolvedIds, + mergeAllowlist, + patchAllowlistUsersInConfigEntries, + summarizeMapping, +} from "../../channels/allowlists/resolve-utils.js"; +export { ensureConfiguredAcpBindingReady } from "../../acp/persistent-bindings.lifecycle.js"; +export { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.resolve.js"; +export { resolveControlCommandGate } from "../../channels/command-gating.js"; +export type { NormalizedLocation } from "../../channels/location.js"; +export { formatLocationText, toLocationContext } from "../../channels/location.js"; +export { logInboundDrop, logTypingFailure } from "../../channels/logging.js"; +export type { AllowlistMatch } from "../../channels/plugins/allowlist-match.js"; +export { formatAllowlistMatchMeta } from "../../channels/plugins/allowlist-match.js"; +export { + buildChannelKeyCandidates, + resolveChannelEntryMatch, +} from "../../channels/plugins/channel-config.js"; +export { createAccountListHelpers } from "../../channels/plugins/account-helpers.js"; +export { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../../channels/plugins/config-helpers.js"; +export { buildChannelConfigSchema } from "../../channels/plugins/config-schema.js"; +export { formatPairingApproveHint } from "../../channels/plugins/helpers.js"; +export { + buildSingleChannelSecretPromptState, + addWildcardAllowFrom, + mergeAllowFromEntries, + promptAccountId, + promptSingleChannelSecretInput, + setTopLevelChannelGroupPolicy, +} from "../../channels/plugins/setup-wizard-helpers.js"; +export { promptChannelAccessConfig } from "../../channels/plugins/setup-group-access.js"; +export { PAIRING_APPROVED_MESSAGE } from "../../channels/plugins/pairing-message.js"; +export { + applyAccountNameToChannelSection, + moveSingleAccountChannelSectionToDefaultAccount, +} from "../../channels/plugins/setup-helpers.js"; +export type { + BaseProbeResult, + ChannelDirectoryEntry, + ChannelGroupContext, + ChannelMessageActionAdapter, + ChannelMessageActionContext, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, + ChannelOutboundAdapter, + ChannelResolveKind, + ChannelResolveResult, + ChannelSetupInput, + ChannelToolSend, +} from "../../channels/plugins/types.js"; +export type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; +export { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; +export { resolveThreadBindingFarewellText } from "../../channels/thread-bindings-messages.js"; +export { + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, +} from "../../channels/thread-bindings-policy.js"; +export { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "../../../extensions/matrix/runtime-api.js"; +export { createTypingCallbacks } from "../../channels/typing.js"; +export { createChannelReplyPipeline } from "../../plugin-sdk/channel-reply-pipeline.js"; +export type { OpenClawConfig } from "../../config/config.js"; +export { + GROUP_POLICY_BLOCKED_LABEL, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../../config/runtime-group-policy.js"; +export type { + DmPolicy, + GroupPolicy, + GroupToolPolicyConfig, + MarkdownTableMode, +} from "../../config/types.js"; +export type { SecretInput } from "../../plugin-sdk/secret-input.js"; +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "../../plugin-sdk/secret-input.js"; +export { ToolPolicySchema } from "../../config/zod-schema.agent-runtime.js"; +export { MarkdownConfigSchema } from "../../config/zod-schema.core.js"; +export { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; +export { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; +export { maybeCreateMatrixMigrationSnapshot } from "../../infra/matrix-migration-snapshot.js"; +export { + getSessionBindingService, + registerSessionBindingAdapter, + unregisterSessionBindingAdapter, +} from "../../infra/outbound/session-binding-service.js"; +export { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js"; +export type { + BindingTargetKind, + SessionBindingRecord, +} from "../../infra/outbound/session-binding-service.js"; +export { isPrivateOrLoopbackHost } from "../../gateway/net.js"; +export { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; +export { emptyPluginConfigSchema } from "../config-schema.js"; +export type { PluginRuntime, RuntimeLogger } from "./types.js"; +export type { OpenClawPluginApi } from "../types.js"; +export type { PollInput } from "../../polls.js"; +export { normalizePollInput } from "../../polls.js"; +export { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, + resolveAgentIdFromSessionKey, +} from "../../routing/session-key.js"; +export type { RuntimeEnv } from "../../runtime.js"; +export { normalizeStringEntries } from "../../shared/string-normalization.js"; +export { formatDocsLink } from "../../terminal/links.js"; +export { redactSensitiveText } from "../../logging/redact.js"; +export type { WizardPrompter } from "../../wizard/prompts.js"; +export { + evaluateGroupRouteAccessForPolicy, + resolveSenderScopedGroupPolicy, +} from "../../plugin-sdk/group-access.js"; +export { createChannelPairingController } from "../../plugin-sdk/channel-pairing.js"; +export { readJsonFileWithFallback, writeJsonFileAtomically } from "../../plugin-sdk/json-store.js"; +export { formatResolvedUnresolvedNote } from "../../plugin-sdk/resolution-notes.js"; +export { runPluginCommandWithTimeout } from "../../plugin-sdk/run-command.js"; +export { createLoggerBackedRuntime, resolveRuntimeEnv } from "../../plugin-sdk/runtime.js"; +export { dispatchReplyFromConfigWithSettledDispatcher } from "../../plugin-sdk/inbound-reply-dispatch.js"; +export { + buildProbeChannelStatusSummary, + collectStatusIssuesFromLastError, +} from "../../plugin-sdk/status-helpers.js"; +export { + resolveMatrixAccountStorageRoot, + resolveMatrixCredentialsDir, + resolveMatrixCredentialsPath, + resolveMatrixLegacyFlatStoragePaths, +} from "../../../extensions/matrix/runtime-api.js"; +export { getMatrixScopedEnvVarNames } from "../../../extensions/matrix/runtime-api.js"; +export { + requiresExplicitMatrixDefaultAccount, + resolveMatrixDefaultOrOnlyAccountId, +} from "../../../extensions/matrix/runtime-api.js"; + +const matrixSetup = createOptionalChannelSetupSurface({ + channel: "matrix", + label: "Matrix", + npmSpec: "@openclaw/matrix", + docsPath: "/channels/matrix", +}); + +export const matrixSetupWizard = matrixSetup.setupWizard; +export const matrixSetupAdapter = matrixSetup.setupAdapter; diff --git a/src/plugins/runtime/runtime-telegram-contract.ts b/src/plugins/runtime/runtime-telegram-contract.ts new file mode 100644 index 00000000000..6700ae25429 --- /dev/null +++ b/src/plugins/runtime/runtime-telegram-contract.ts @@ -0,0 +1,130 @@ +export type { + ChannelAccountSnapshot, + ChannelGatewayContext, + ChannelMessageActionAdapter, +} from "../../channels/plugins/types.js"; +export type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; +export type { OpenClawConfig } from "../../config/config.js"; +export type { PluginRuntime } from "./types.js"; +export type { OpenClawPluginApi } from "../types.js"; +export type { + TelegramAccountConfig, + TelegramActionConfig, + TelegramNetworkConfig, +} from "../../config/types.js"; +export type { + ChannelConfiguredBindingProvider, + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, +} from "../../channels/plugins/types.adapters.js"; +export type { InspectedTelegramAccount } from "../../../extensions/telegram/api.js"; +export type { ResolvedTelegramAccount } from "../../../extensions/telegram/api.js"; +export type { TelegramProbe } from "../../../extensions/telegram/runtime-api.js"; +export type { + TelegramButtonStyle, + TelegramInlineButtons, +} from "../../../extensions/telegram/api.js"; +export type { StickerMetadata } from "../../../extensions/telegram/api.js"; + +export { emptyPluginConfigSchema } from "../config-schema.js"; +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +export { parseTelegramTopicConversation } from "../../acp/conversation-id.js"; +export { clearAccountEntryFields } from "../../channels/plugins/config-helpers.js"; +export { resolveTelegramPollVisibility } from "../../poll-params.js"; + +export { + PAIRING_APPROVED_MESSAGE, + applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + setAccountEnabledInConfigSection, +} from "../../plugin-sdk/channel-plugin-common.js"; + +export { + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, +} from "../../channels/account-snapshot-fields.js"; +export { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, +} from "../../config/runtime-group-policy.js"; +export { + listTelegramDirectoryGroupsFromConfig, + listTelegramDirectoryPeersFromConfig, +} from "../../../extensions/telegram/api.js"; +export { + resolveTelegramGroupRequireMention, + resolveTelegramGroupToolPolicy, +} from "../../../extensions/telegram/api.js"; +export { TelegramConfigSchema } from "../../config/zod-schema.providers-core.js"; + +export { buildTokenChannelStatusSummary } from "../../plugin-sdk/status-helpers.js"; + +export { + createTelegramActionGate, + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramPollActionGateState, +} from "../../../extensions/telegram/api.js"; +export { inspectTelegramAccount } from "../../../extensions/telegram/api.js"; +export { + looksLikeTelegramTargetId, + normalizeTelegramMessagingTarget, +} from "../../../extensions/telegram/api.js"; +export { + parseTelegramReplyToMessageId, + parseTelegramThreadId, +} from "../../../extensions/telegram/api.js"; +export { + isNumericTelegramUserId, + normalizeTelegramAllowFromEntry, +} from "../../../extensions/telegram/api.js"; +export { fetchTelegramChatId } from "../../../extensions/telegram/api.js"; +export { + resolveTelegramInlineButtonsScope, + resolveTelegramTargetChatType, +} from "../../../extensions/telegram/api.js"; +export { resolveTelegramReactionLevel } from "../../../extensions/telegram/api.js"; +export { + auditTelegramGroupMembership, + collectTelegramUnmentionedGroupIds, + createForumTopicTelegram, + deleteMessageTelegram, + editForumTopicTelegram, + editMessageReplyMarkupTelegram, + editMessageTelegram, + monitorTelegramProvider, + pinMessageTelegram, + reactMessageTelegram, + renameForumTopicTelegram, + probeTelegram, + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, + sendTypingTelegram, + unpinMessageTelegram, +} from "../../../extensions/telegram/runtime-api.js"; +export { getCacheStats, searchStickers } from "../../../extensions/telegram/api.js"; +export { resolveTelegramToken } from "../../../extensions/telegram/runtime-api.js"; +export { telegramMessageActions } from "../../../extensions/telegram/runtime-api.js"; +export { + setTelegramThreadBindingIdleTimeoutBySessionKey, + setTelegramThreadBindingMaxAgeBySessionKey, +} from "../../../extensions/telegram/runtime-api.js"; +export { collectTelegramStatusIssues } from "../../../extensions/telegram/api.js"; +export { sendTelegramPayloadMessages } from "../../../extensions/telegram/api.js"; +export { + buildBrowseProvidersButton, + buildModelsKeyboard, + buildProviderKeyboard, + calculateTotalPages, + getModelsPageSize, + type ProviderInfo, +} from "../../../extensions/telegram/api.js"; +export { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, +} from "../../../extensions/telegram/api.js"; diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 1a44e0e45f1..5712f50eb31 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -94,29 +94,29 @@ export type PluginRuntimeChannel = { shouldHandleTextCommands: typeof import("../../auto-reply/commands-registry.js").shouldHandleTextCommands; }; discord: { - messageActions: typeof import("../../../extensions/discord/runtime-api.js").discordMessageActions; - auditChannelPermissions: typeof import("../../../extensions/discord/runtime-api.js").auditDiscordChannelPermissions; - listDirectoryGroupsLive: typeof import("../../../extensions/discord/runtime-api.js").listDiscordDirectoryGroupsLive; - listDirectoryPeersLive: typeof import("../../../extensions/discord/runtime-api.js").listDiscordDirectoryPeersLive; - probeDiscord: typeof import("../../../extensions/discord/runtime-api.js").probeDiscord; - resolveChannelAllowlist: typeof import("../../../extensions/discord/runtime-api.js").resolveDiscordChannelAllowlist; - resolveUserAllowlist: typeof import("../../../extensions/discord/runtime-api.js").resolveDiscordUserAllowlist; - sendComponentMessage: typeof import("../../../extensions/discord/runtime-api.js").sendDiscordComponentMessage; - sendMessageDiscord: typeof import("../../../extensions/discord/runtime-api.js").sendMessageDiscord; - sendPollDiscord: typeof import("../../../extensions/discord/runtime-api.js").sendPollDiscord; - monitorDiscordProvider: typeof import("../../../extensions/discord/runtime-api.js").monitorDiscordProvider; + messageActions: typeof import("../../plugin-sdk/discord.js").discordMessageActions; + auditChannelPermissions: typeof import("../../plugin-sdk/discord.js").auditDiscordChannelPermissions; + listDirectoryGroupsLive: typeof import("../../plugin-sdk/discord.js").listDiscordDirectoryGroupsLive; + listDirectoryPeersLive: typeof import("../../plugin-sdk/discord.js").listDiscordDirectoryPeersLive; + probeDiscord: typeof import("../../plugin-sdk/discord.js").probeDiscord; + resolveChannelAllowlist: typeof import("../../plugin-sdk/discord.js").resolveDiscordChannelAllowlist; + resolveUserAllowlist: typeof import("../../plugin-sdk/discord.js").resolveDiscordUserAllowlist; + sendComponentMessage: typeof import("../../plugin-sdk/discord.js").sendDiscordComponentMessage; + sendMessageDiscord: typeof import("../../plugin-sdk/discord.js").sendMessageDiscord; + sendPollDiscord: typeof import("../../plugin-sdk/discord.js").sendPollDiscord; + monitorDiscordProvider: typeof import("../../plugin-sdk/discord.js").monitorDiscordProvider; threadBindings: { - getManager: typeof import("../../../extensions/discord/runtime-api.js").getThreadBindingManager; - resolveIdleTimeoutMs: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingIdleTimeoutMs; - resolveInactivityExpiresAt: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingInactivityExpiresAt; - resolveMaxAgeMs: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingMaxAgeMs; - resolveMaxAgeExpiresAt: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingMaxAgeExpiresAt; - setIdleTimeoutBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").setThreadBindingIdleTimeoutBySessionKey; - setMaxAgeBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").setThreadBindingMaxAgeBySessionKey; - unbindBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").unbindThreadBindingsBySessionKey; + getManager: typeof import("../../plugin-sdk/discord.js").getThreadBindingManager; + resolveIdleTimeoutMs: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingIdleTimeoutMs; + resolveInactivityExpiresAt: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingInactivityExpiresAt; + resolveMaxAgeMs: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingMaxAgeMs; + resolveMaxAgeExpiresAt: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingMaxAgeExpiresAt; + setIdleTimeoutBySessionKey: typeof import("../../plugin-sdk/discord.js").setThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../plugin-sdk/discord.js").setThreadBindingMaxAgeBySessionKey; + unbindBySessionKey: typeof import("../../plugin-sdk/discord.js").unbindThreadBindingsBySessionKey; }; typing: { - pulse: typeof import("../../../extensions/discord/runtime-api.js").sendTypingDiscord; + pulse: typeof import("../../plugin-sdk/discord.js").sendTypingDiscord; start: (params: { channelId: string; accountId?: string; @@ -128,39 +128,39 @@ export type PluginRuntimeChannel = { }>; }; conversationActions: { - editMessage: typeof import("../../../extensions/discord/runtime-api.js").editMessageDiscord; - deleteMessage: typeof import("../../../extensions/discord/runtime-api.js").deleteMessageDiscord; - pinMessage: typeof import("../../../extensions/discord/runtime-api.js").pinMessageDiscord; - unpinMessage: typeof import("../../../extensions/discord/runtime-api.js").unpinMessageDiscord; - createThread: typeof import("../../../extensions/discord/runtime-api.js").createThreadDiscord; - editChannel: typeof import("../../../extensions/discord/runtime-api.js").editChannelDiscord; + editMessage: typeof import("../../plugin-sdk/discord.js").editMessageDiscord; + deleteMessage: typeof import("../../plugin-sdk/discord.js").deleteMessageDiscord; + pinMessage: typeof import("../../plugin-sdk/discord.js").pinMessageDiscord; + unpinMessage: typeof import("../../plugin-sdk/discord.js").unpinMessageDiscord; + createThread: typeof import("../../plugin-sdk/discord.js").createThreadDiscord; + editChannel: typeof import("../../plugin-sdk/discord.js").editChannelDiscord; }; }; slack: { - listDirectoryGroupsLive: typeof import("../../../extensions/slack/runtime-api.js").listSlackDirectoryGroupsLive; - listDirectoryPeersLive: typeof import("../../../extensions/slack/runtime-api.js").listSlackDirectoryPeersLive; - probeSlack: typeof import("../../../extensions/slack/runtime-api.js").probeSlack; - resolveChannelAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackChannelAllowlist; - resolveUserAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackUserAllowlist; - sendMessageSlack: typeof import("../../../extensions/slack/runtime-api.js").sendMessageSlack; - monitorSlackProvider: typeof import("../../../extensions/slack/runtime-api.js").monitorSlackProvider; - handleSlackAction: typeof import("../../../extensions/slack/runtime-api.js").handleSlackAction; + listDirectoryGroupsLive: typeof import("../../plugin-sdk/slack.js").listSlackDirectoryGroupsLive; + listDirectoryPeersLive: typeof import("../../plugin-sdk/slack.js").listSlackDirectoryPeersLive; + probeSlack: typeof import("../../plugin-sdk/slack.js").probeSlack; + resolveChannelAllowlist: typeof import("../../plugin-sdk/slack.js").resolveSlackChannelAllowlist; + resolveUserAllowlist: typeof import("../../plugin-sdk/slack.js").resolveSlackUserAllowlist; + sendMessageSlack: typeof import("../../plugin-sdk/slack.js").sendMessageSlack; + monitorSlackProvider: typeof import("../../plugin-sdk/slack.js").monitorSlackProvider; + handleSlackAction: typeof import("../../plugin-sdk/slack.js").handleSlackAction; }; telegram: { - auditGroupMembership: typeof import("../../../extensions/telegram/runtime-api.js").auditTelegramGroupMembership; - collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/runtime-api.js").collectTelegramUnmentionedGroupIds; - probeTelegram: typeof import("../../../extensions/telegram/runtime-api.js").probeTelegram; - resolveTelegramToken: typeof import("../../../extensions/telegram/runtime-api.js").resolveTelegramToken; - sendMessageTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendMessageTelegram; - sendPollTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendPollTelegram; - monitorTelegramProvider: typeof import("../../../extensions/telegram/runtime-api.js").monitorTelegramProvider; - messageActions: typeof import("../../../extensions/telegram/runtime-api.js").telegramMessageActions; + auditGroupMembership: typeof import("../../plugin-sdk/telegram.js").auditTelegramGroupMembership; + collectUnmentionedGroupIds: typeof import("../../plugin-sdk/telegram.js").collectTelegramUnmentionedGroupIds; + probeTelegram: typeof import("../../plugin-sdk/telegram.js").probeTelegram; + resolveTelegramToken: typeof import("../../plugin-sdk/telegram.js").resolveTelegramToken; + sendMessageTelegram: typeof import("../../plugin-sdk/telegram.js").sendMessageTelegram; + sendPollTelegram: typeof import("../../plugin-sdk/telegram.js").sendPollTelegram; + monitorTelegramProvider: typeof import("../../plugin-sdk/telegram.js").monitorTelegramProvider; + messageActions: typeof import("../../plugin-sdk/telegram.js").telegramMessageActions; threadBindings: { - setIdleTimeoutBySessionKey: typeof import("../../../extensions/telegram/runtime-api.js").setTelegramThreadBindingIdleTimeoutBySessionKey; - setMaxAgeBySessionKey: typeof import("../../../extensions/telegram/runtime-api.js").setTelegramThreadBindingMaxAgeBySessionKey; + setIdleTimeoutBySessionKey: typeof import("../../plugin-sdk/telegram.js").setTelegramThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../plugin-sdk/telegram.js").setTelegramThreadBindingMaxAgeBySessionKey; }; typing: { - pulse: typeof import("../../../extensions/telegram/runtime-api.js").sendTypingTelegram; + pulse: typeof import("../../plugin-sdk/telegram.js").sendTypingTelegram; start: (params: { to: string; accountId?: string; @@ -173,8 +173,8 @@ export type PluginRuntimeChannel = { }>; }; conversationActions: { - editMessage: typeof import("../../../extensions/telegram/runtime-api.js").editMessageTelegram; - editReplyMarkup: typeof import("../../../extensions/telegram/runtime-api.js").editMessageReplyMarkupTelegram; + editMessage: typeof import("../../plugin-sdk/telegram.js").editMessageTelegram; + editReplyMarkup: typeof import("../../plugin-sdk/telegram.js").editMessageReplyMarkupTelegram; clearReplyMarkup: ( chatIdInput: string | number, messageIdInput: string | number, @@ -187,10 +187,10 @@ export type PluginRuntimeChannel = { cfg?: ReturnType; }, ) => Promise<{ ok: true; messageId: string; chatId: string }>; - deleteMessage: typeof import("../../../extensions/telegram/runtime-api.js").deleteMessageTelegram; - renameTopic: typeof import("../../../extensions/telegram/runtime-api.js").renameForumTopicTelegram; - pinMessage: typeof import("../../../extensions/telegram/runtime-api.js").pinMessageTelegram; - unpinMessage: typeof import("../../../extensions/telegram/runtime-api.js").unpinMessageTelegram; + deleteMessage: typeof import("../../plugin-sdk/telegram.js").deleteMessageTelegram; + renameTopic: typeof import("../../plugin-sdk/telegram.js").renameForumTopicTelegram; + pinMessage: typeof import("../../plugin-sdk/telegram.js").pinMessageTelegram; + unpinMessage: typeof import("../../plugin-sdk/telegram.js").unpinMessageTelegram; }; }; matrix: { @@ -200,15 +200,15 @@ export type PluginRuntimeChannel = { }; }; signal: { - probeSignal: typeof import("../../../extensions/signal/runtime-api.js").probeSignal; - sendMessageSignal: typeof import("../../../extensions/signal/runtime-api.js").sendMessageSignal; - monitorSignalProvider: typeof import("../../../extensions/signal/runtime-api.js").monitorSignalProvider; - messageActions: typeof import("../../../extensions/signal/runtime-api.js").signalMessageActions; + probeSignal: typeof import("../../plugin-sdk/signal.js").probeSignal; + sendMessageSignal: typeof import("../../plugin-sdk/signal.js").sendMessageSignal; + monitorSignalProvider: typeof import("../../plugin-sdk/signal.js").monitorSignalProvider; + messageActions: typeof import("../../plugin-sdk/signal.js").signalMessageActions; }; imessage: { - monitorIMessageProvider: typeof import("../../../extensions/imessage/runtime-api.js").monitorIMessageProvider; - probeIMessage: typeof import("../../../extensions/imessage/runtime-api.js").probeIMessage; - sendMessageIMessage: typeof import("../../../extensions/imessage/runtime-api.js").sendMessageIMessage; + monitorIMessageProvider: typeof import("../../plugin-sdk/imessage.js").monitorIMessageProvider; + probeIMessage: typeof import("../../plugin-sdk/imessage.js").probeIMessage; + sendMessageIMessage: typeof import("../../plugin-sdk/imessage.js").sendMessageIMessage; }; whatsapp: { getActiveWebListener: typeof import("./runtime-whatsapp-boundary.js").getActiveWebListener; diff --git a/test/plugin-extension-import-boundary.test.ts b/test/plugin-extension-import-boundary.test.ts index 254b3613797..c2bd07b5e00 100644 --- a/test/plugin-extension-import-boundary.test.ts +++ b/test/plugin-extension-import-boundary.test.ts @@ -29,13 +29,16 @@ describe("plugin extension import boundary inventory", () => { ); }); - it("ignores plugin-sdk boundary shims by scope", async () => { + it("ignores boundary shims by scope", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); expect(inventory.some((entry) => entry.file.startsWith("src/plugin-sdk/"))).toBe(false); expect(inventory.some((entry) => entry.file.startsWith("src/plugin-sdk-internal/"))).toBe( false, ); + expect(inventory.some((entry) => entry.file.startsWith("src/plugins/runtime/runtime-"))).toBe( + false, + ); }); it("produces stable sorted output", async () => { From 4c614c230dd64af7eb8ccd67d016d0231bff6064 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 15:53:03 +0000 Subject: [PATCH 06/15] fix: restore local gate --- docs/install/azure.md | 2 ++ extensions/msteams/src/graph-upload.test.ts | 2 +- extensions/msteams/src/messenger.test.ts | 11 +++++++++++ .../msteams/src/monitor-handler.file-consent.test.ts | 4 ++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/install/azure.md b/docs/install/azure.md index 7c6abae64fe..012434bc43f 100644 --- a/docs/install/azure.md +++ b/docs/install/azure.md @@ -284,10 +284,12 @@ Azure Bastion Standard SKU runs approximately **\$140/month** and the VM (Standa To reduce costs: - **Deallocate the VM** when not in use (stops compute billing; disk charges remain). The OpenClaw Gateway will not be reachable while the VM is deallocated — restart it when you need it live again: + ```bash az vm deallocate -g "${RG}" -n "${VM_NAME}" az vm start -g "${RG}" -n "${VM_NAME}" # restart later ``` + - **Delete Bastion when not needed** and recreate it when you need SSH access. Bastion is the largest cost component and takes only a few minutes to provision. - **Use the Basic Bastion SKU** (~\$38/month) if you only need Portal-based SSH and don't require CLI tunneling (`az network bastion ssh`). diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts index 9da78c1ed61..43a66e95c3f 100644 --- a/extensions/msteams/src/graph-upload.test.ts +++ b/extensions/msteams/src/graph-upload.test.ts @@ -141,7 +141,7 @@ describe("resolveGraphChatId", () => { }), ); // Should filter by user AAD object ID - const callUrl = (fetchFn.mock.calls[0] as [string, unknown])[0]; + const callUrl = (fetchFn.mock.calls[0] as unknown as [string, unknown])[0]; expect(callUrl).toContain("user-aad-object-id-123"); expect(result).toBe("19:dm-chat-id@unq.gbl.spaces"); }); diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 2644092f127..92f161341de 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -50,9 +50,14 @@ const runtimeStub: PluginRuntime = createPluginRuntimeMock({ }, }); +const noopUpdateActivity = async () => {}; +const noopDeleteActivity = async () => {}; + const createNoopAdapter = (): MSTeamsAdapter => ({ continueConversation: async () => {}, process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, }); const createRecordedSendActivity = ( @@ -81,6 +86,8 @@ const createFallbackAdapter = (proactiveSent: string[]): MSTeamsAdapter => ({ }); }, process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, }); describe("msteams messenger", () => { @@ -195,6 +202,8 @@ describe("msteams messenger", () => { }); }, process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, }; const ids = await sendMSTeamsMessages({ @@ -366,6 +375,8 @@ describe("msteams messenger", () => { await logic({ sendActivity: createRecordedSendActivity(attempts, 503) }); }, process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, }; const ids = await sendMSTeamsMessages({ diff --git a/extensions/msteams/src/monitor-handler.file-consent.test.ts b/extensions/msteams/src/monitor-handler.file-consent.test.ts index 5e610bfcfa6..39b6ea1b1ff 100644 --- a/extensions/msteams/src/monitor-handler.file-consent.test.ts +++ b/extensions/msteams/src/monitor-handler.file-consent.test.ts @@ -42,6 +42,8 @@ function createDeps(): MSTeamsMessageHandlerDeps { const adapter: MSTeamsAdapter = { continueConversation: async () => {}, process: async () => {}, + updateActivity: async () => {}, + deleteActivity: async () => {}, }; const conversationStore: MSTeamsConversationStore = { upsert: async () => {}, @@ -82,6 +84,8 @@ function createActivityHandler(): MSTeamsActivityHandler { handler = { onMessage: () => handler, onMembersAdded: () => handler, + onReactionsAdded: () => handler, + onReactionsRemoved: () => handler, run: async () => {}, }; return handler; From cb89325cd8754225f7f4c42805bc95465ffb9181 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 16:29:00 +0000 Subject: [PATCH 07/15] fix: restore latest main gate --- src/infra/archive.test.ts | 55 ++++++++++++++++++++++++++------- src/plugin-sdk/acp-runtime.ts | 6 ++++ src/plugin-sdk/core.ts | 2 ++ src/plugin-sdk/provider-auth.ts | 4 +++ 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/infra/archive.test.ts b/src/infra/archive.test.ts index d77b1e0bdb4..5f62200314e 100644 --- a/src/infra/archive.test.ts +++ b/src/infra/archive.test.ts @@ -11,6 +11,7 @@ import { extractArchive, resolvePackedRootDir } from "./archive.js"; let fixtureRoot = ""; let fixtureCount = 0; const directorySymlinkType = process.platform === "win32" ? "junction" : undefined; +const ARCHIVE_EXTRACT_TIMEOUT_MS = 15_000; async function makeTempDir(prefix = "case") { const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); @@ -67,7 +68,7 @@ async function expectExtractedSizeBudgetExceeded(params: { extractArchive({ archivePath: params.archivePath, destDir: params.destDir, - timeoutMs: params.timeoutMs ?? 5_000, + timeoutMs: params.timeoutMs ?? ARCHIVE_EXTRACT_TIMEOUT_MS, limits: { maxExtractedBytes: params.maxExtractedBytes }, }), ).rejects.toThrow("archive extracted size exceeds limit"); @@ -93,7 +94,11 @@ describe("archive utils", () => { fileName: "hello.txt", content: "hi", }); - await extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }); + await extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }); const rootDir = await resolvePackedRootDir(extractDir); const content = await fs.readFile(path.join(rootDir, "hello.txt"), "utf-8"); expect(content).toBe("hi"); @@ -118,7 +123,11 @@ describe("archive utils", () => { await createDirectorySymlink(realExtractDir, extractDir); await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toMatchObject({ code: "destination-symlink", } satisfies Partial); @@ -135,7 +144,11 @@ describe("archive utils", () => { await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" })); await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toThrow(/(escapes destination|absolute)/i); }); }); @@ -151,7 +164,11 @@ describe("archive utils", () => { await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" })); await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toMatchObject({ code: "destination-symlink-traversal", } satisfies Partial); @@ -186,7 +203,11 @@ describe("archive utils", () => { timing: "after-realpath", run: async () => { await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toMatchObject({ code: "destination-symlink-traversal", } satisfies Partial); @@ -222,7 +243,11 @@ describe("archive utils", () => { try { await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toMatchObject({ code: "destination-symlink-traversal", } satisfies Partial); @@ -245,7 +270,11 @@ describe("archive utils", () => { await tar.c({ cwd: insideDir, file: archivePath }, ["../outside.txt"]); await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toThrow(/escapes destination/i); }); }); @@ -261,7 +290,11 @@ describe("archive utils", () => { await tar.c({ cwd: archiveRoot, file: archivePath }, ["escape"]); await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toMatchObject({ code: "destination-symlink-traversal", } satisfies Partial); @@ -308,7 +341,7 @@ describe("archive utils", () => { extractArchive({ archivePath, destDir: extractDir, - timeoutMs: 5_000, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, limits: { maxArchiveBytes: Math.max(1, stat.size - 1) }, }), ).rejects.toThrow("archive size exceeds limit"); @@ -328,7 +361,7 @@ describe("archive utils", () => { extractArchive({ archivePath, destDir: extractDir, - timeoutMs: 5_000, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, }), ).rejects.toThrow(/absolute|drive path|escapes destination/i); }); diff --git a/src/plugin-sdk/acp-runtime.ts b/src/plugin-sdk/acp-runtime.ts index 7767d042f9b..88088867b2a 100644 --- a/src/plugin-sdk/acp-runtime.ts +++ b/src/plugin-sdk/acp-runtime.ts @@ -4,6 +4,12 @@ export { getAcpSessionManager } from "../acp/control-plane/manager.js"; export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js"; export { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../acp/runtime/registry.js"; export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js"; +export { + getAcpRuntimeBackend, + registerAcpRuntimeBackend, + requireAcpRuntimeBackend, + unregisterAcpRuntimeBackend, +} from "../acp/runtime/registry.js"; export type { AcpRuntime, AcpRuntimeCapabilities, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index c8c7980fbd2..b5b149e25b0 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -58,6 +58,8 @@ export type { PluginCommandContext, PluginLogger, PluginInteractiveTelegramHandlerContext, + OpenClawPluginToolContext, + OpenClawPluginToolFactory, } from "../plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export { isSecretRef } from "../config/types.secrets.js"; diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 13125b7704c..bdc73f50793 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -43,3 +43,7 @@ export { normalizeOptionalSecretInput, normalizeSecretInput, } from "../utils/normalize-secret-input.js"; +export { + listKnownProviderAuthEnvVarNames, + omitEnvKeysCaseInsensitive, +} from "../secrets/provider-env-vars.js"; From 18fa2992f92b216654a8f27b2d828d08dc725f92 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 16:45:35 +0000 Subject: [PATCH 08/15] fix: restore plugin sdk runtime barrels --- extensions/feishu/runtime-api.ts | 2 +- extensions/googlechat/runtime-api.ts | 2 +- extensions/irc/src/runtime-api.ts | 2 +- extensions/line/runtime-api.ts | 6 +- extensions/line/src/config-adapter.ts | 2 +- extensions/line/src/group-policy.ts | 2 +- extensions/line/src/setup-core.ts | 4 +- extensions/line/src/setup-surface.ts | 4 +- extensions/mattermost/runtime-api.ts | 2 +- extensions/msteams/runtime-api.ts | 2 +- extensions/nextcloud-talk/runtime-api.ts | 2 +- extensions/nostr/runtime-api.ts | 2 +- extensions/signal/src/runtime-api.ts | 2 +- extensions/tlon/runtime-api.ts | 2 +- extensions/twitch/runtime-api.ts | 2 +- extensions/voice-call/runtime-api.ts | 2 +- extensions/zalo/runtime-api.ts | 2 +- extensions/zalouser/runtime-api.ts | 2 +- package.json | 60 +++++++++++++++++++ scripts/lib/plugin-sdk-entrypoints.json | 15 +++++ src/plugin-sdk/line-core.ts | 2 +- src/plugin-sdk/root-alias.cjs | 9 +++ src/plugin-sdk/root-alias.test.ts | 6 +- src/plugin-sdk/runtime-api-guardrails.test.ts | 4 +- src/plugin-sdk/subpaths.test.ts | 15 ----- 25 files changed, 114 insertions(+), 41 deletions(-) diff --git a/extensions/feishu/runtime-api.ts b/extensions/feishu/runtime-api.ts index cde6bbf5569..ece8df41cca 100644 --- a/extensions/feishu/runtime-api.ts +++ b/extensions/feishu/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Feishu extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/feishu.js"; +export * from "openclaw/plugin-sdk/feishu"; diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index cd47c0e56c7..df946f8ec4a 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Google Chat extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/googlechat.js"; +export * from "openclaw/plugin-sdk/googlechat"; diff --git a/extensions/irc/src/runtime-api.ts b/extensions/irc/src/runtime-api.ts index 96e4bdbbe90..40f35e1ad53 100644 --- a/extensions/irc/src/runtime-api.ts +++ b/extensions/irc/src/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled IRC extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../../src/plugin-sdk/irc.js"; +export * from "openclaw/plugin-sdk/irc"; diff --git a/extensions/line/runtime-api.ts b/extensions/line/runtime-api.ts index 53f1be0c51c..b40e5c76e0e 100644 --- a/extensions/line/runtime-api.ts +++ b/extensions/line/runtime-api.ts @@ -1,12 +1,12 @@ // Private runtime barrel for the bundled LINE extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/line.js"; -export { resolveExactLineGroupConfigKey } from "../../src/plugin-sdk/line-core.js"; +export * from "openclaw/plugin-sdk/line"; +export { resolveExactLineGroupConfigKey } from "openclaw/plugin-sdk/line-core"; export { formatDocsLink, setSetupChannelEnabled, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, -} from "../../src/plugin-sdk/line-core.js"; +} from "openclaw/plugin-sdk/line-core"; diff --git a/extensions/line/src/config-adapter.ts b/extensions/line/src/config-adapter.ts index 1b10989b45c..3894210f0a6 100644 --- a/extensions/line/src/config-adapter.ts +++ b/extensions/line/src/config-adapter.ts @@ -5,7 +5,7 @@ import { resolveLineAccount, type OpenClawConfig, type ResolvedLineAccount, -} from "../runtime-api.js"; +} from "openclaw/plugin-sdk/line-core"; export function normalizeLineAllowFrom(entry: string): string { return entry.replace(/^line:(?:user:)?/i, ""); diff --git a/extensions/line/src/group-policy.ts b/extensions/line/src/group-policy.ts index eaf30e04cf7..e6b4fa0ba95 100644 --- a/extensions/line/src/group-policy.ts +++ b/extensions/line/src/group-policy.ts @@ -1,5 +1,5 @@ import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; -import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "../runtime-api.js"; +import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "openclaw/plugin-sdk/line-core"; type LineGroupContext = { cfg: OpenClawConfig; diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 7e894d2b87a..363b4dcb2a1 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,11 +1,11 @@ -import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; import { DEFAULT_ACCOUNT_ID, listLineAccountIds, normalizeAccountId, resolveLineAccount, type LineConfig, -} from "../runtime-api.js"; +} from "openclaw/plugin-sdk/line-core"; +import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; const channel = "line" as const; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 6f46cc92217..640ad3812b8 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,4 +1,3 @@ -import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup"; import { DEFAULT_ACCOUNT_ID, formatDocsLink, @@ -7,7 +6,8 @@ import { splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, -} from "../runtime-api.js"; +} from "openclaw/plugin-sdk/line-core"; +import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup"; import { isLineConfigured, listLineAccountIds, diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts index 2bc65439262..d4e591c8c1e 100644 --- a/extensions/mattermost/runtime-api.ts +++ b/extensions/mattermost/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Mattermost extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/mattermost.js"; +export * from "openclaw/plugin-sdk/mattermost"; diff --git a/extensions/msteams/runtime-api.ts b/extensions/msteams/runtime-api.ts index e2b75780399..d32cb7b65d5 100644 --- a/extensions/msteams/runtime-api.ts +++ b/extensions/msteams/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Microsoft Teams extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/msteams.js"; +export * from "openclaw/plugin-sdk/msteams"; diff --git a/extensions/nextcloud-talk/runtime-api.ts b/extensions/nextcloud-talk/runtime-api.ts index 80bc1b1dc7b..b2093a7a057 100644 --- a/extensions/nextcloud-talk/runtime-api.ts +++ b/extensions/nextcloud-talk/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Nextcloud Talk extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/nextcloud-talk.js"; +export * from "openclaw/plugin-sdk/nextcloud-talk"; diff --git a/extensions/nostr/runtime-api.ts b/extensions/nostr/runtime-api.ts index 602b0ac81b7..29825771891 100644 --- a/extensions/nostr/runtime-api.ts +++ b/extensions/nostr/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Nostr extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/nostr.js"; +export * from "openclaw/plugin-sdk/nostr"; diff --git a/extensions/signal/src/runtime-api.ts b/extensions/signal/src/runtime-api.ts index 172943641f8..6aeeef0adb1 100644 --- a/extensions/signal/src/runtime-api.ts +++ b/extensions/signal/src/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Signal extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../../src/plugin-sdk/signal.js"; +export * from "openclaw/plugin-sdk/signal"; diff --git a/extensions/tlon/runtime-api.ts b/extensions/tlon/runtime-api.ts index 3ba9718868f..3c2c83655c5 100644 --- a/extensions/tlon/runtime-api.ts +++ b/extensions/tlon/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Tlon extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/tlon.js"; +export * from "openclaw/plugin-sdk/tlon"; diff --git a/extensions/twitch/runtime-api.ts b/extensions/twitch/runtime-api.ts index 9d055202a39..87433b1997f 100644 --- a/extensions/twitch/runtime-api.ts +++ b/extensions/twitch/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Twitch extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/twitch.js"; +export * from "openclaw/plugin-sdk/twitch"; diff --git a/extensions/voice-call/runtime-api.ts b/extensions/voice-call/runtime-api.ts index f0b32548645..9dd4fb0f3bc 100644 --- a/extensions/voice-call/runtime-api.ts +++ b/extensions/voice-call/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Voice Call extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/voice-call.js"; +export * from "openclaw/plugin-sdk/voice-call"; diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts index 082f65d43b8..90ced0da803 100644 --- a/extensions/zalo/runtime-api.ts +++ b/extensions/zalo/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Zalo extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/zalo.js"; +export * from "openclaw/plugin-sdk/zalo"; diff --git a/extensions/zalouser/runtime-api.ts b/extensions/zalouser/runtime-api.ts index 1b63edaea42..7d931f2d118 100644 --- a/extensions/zalouser/runtime-api.ts +++ b/extensions/zalouser/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Zalo Personal extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "../../src/plugin-sdk/zalouser.js"; +export * from "openclaw/plugin-sdk/zalouser"; diff --git a/package.json b/package.json index a522ced8380..91abc6172a7 100644 --- a/package.json +++ b/package.json @@ -193,10 +193,50 @@ "types": "./dist/plugin-sdk/discord-core.d.ts", "default": "./dist/plugin-sdk/discord-core.js" }, + "./plugin-sdk/feishu": { + "types": "./dist/plugin-sdk/feishu.d.ts", + "default": "./dist/plugin-sdk/feishu.js" + }, + "./plugin-sdk/googlechat": { + "types": "./dist/plugin-sdk/googlechat.d.ts", + "default": "./dist/plugin-sdk/googlechat.js" + }, + "./plugin-sdk/irc": { + "types": "./dist/plugin-sdk/irc.d.ts", + "default": "./dist/plugin-sdk/irc.js" + }, + "./plugin-sdk/line": { + "types": "./dist/plugin-sdk/line.d.ts", + "default": "./dist/plugin-sdk/line.js" + }, + "./plugin-sdk/line-core": { + "types": "./dist/plugin-sdk/line-core.d.ts", + "default": "./dist/plugin-sdk/line-core.js" + }, "./plugin-sdk/matrix": { "types": "./dist/plugin-sdk/matrix.d.ts", "default": "./dist/plugin-sdk/matrix.js" }, + "./plugin-sdk/mattermost": { + "types": "./dist/plugin-sdk/mattermost.d.ts", + "default": "./dist/plugin-sdk/mattermost.js" + }, + "./plugin-sdk/msteams": { + "types": "./dist/plugin-sdk/msteams.d.ts", + "default": "./dist/plugin-sdk/msteams.js" + }, + "./plugin-sdk/nextcloud-talk": { + "types": "./dist/plugin-sdk/nextcloud-talk.d.ts", + "default": "./dist/plugin-sdk/nextcloud-talk.js" + }, + "./plugin-sdk/nostr": { + "types": "./dist/plugin-sdk/nostr.d.ts", + "default": "./dist/plugin-sdk/nostr.js" + }, + "./plugin-sdk/signal": { + "types": "./dist/plugin-sdk/signal.d.ts", + "default": "./dist/plugin-sdk/signal.js" + }, "./plugin-sdk/slack": { "types": "./dist/plugin-sdk/slack.d.ts", "default": "./dist/plugin-sdk/slack.js" @@ -205,6 +245,26 @@ "types": "./dist/plugin-sdk/slack-core.d.ts", "default": "./dist/plugin-sdk/slack-core.js" }, + "./plugin-sdk/tlon": { + "types": "./dist/plugin-sdk/tlon.d.ts", + "default": "./dist/plugin-sdk/tlon.js" + }, + "./plugin-sdk/twitch": { + "types": "./dist/plugin-sdk/twitch.d.ts", + "default": "./dist/plugin-sdk/twitch.js" + }, + "./plugin-sdk/voice-call": { + "types": "./dist/plugin-sdk/voice-call.d.ts", + "default": "./dist/plugin-sdk/voice-call.js" + }, + "./plugin-sdk/zalo": { + "types": "./dist/plugin-sdk/zalo.d.ts", + "default": "./dist/plugin-sdk/zalo.js" + }, + "./plugin-sdk/zalouser": { + "types": "./dist/plugin-sdk/zalouser.d.ts", + "default": "./dist/plugin-sdk/zalouser.js" + }, "./plugin-sdk/imessage": { "types": "./dist/plugin-sdk/imessage.d.ts", "default": "./dist/plugin-sdk/imessage.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index e5dad4777eb..1dc306bd9b7 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -38,9 +38,24 @@ "telegram-core", "discord", "discord-core", + "feishu", + "googlechat", + "irc", + "line", + "line-core", "matrix", + "mattermost", + "msteams", + "nextcloud-talk", + "nostr", + "signal", "slack", "slack-core", + "tlon", + "twitch", + "voice-call", + "zalo", + "zalouser", "imessage", "imessage-core", "whatsapp", diff --git a/src/plugin-sdk/line-core.ts b/src/plugin-sdk/line-core.ts index 04b2950a50d..596593fc8f4 100644 --- a/src/plugin-sdk/line-core.ts +++ b/src/plugin-sdk/line-core.ts @@ -3,11 +3,11 @@ export type { LineConfig } from "../line/types.js"; export { createTopLevelChannelDmPolicy, DEFAULT_ACCOUNT_ID, - formatDocsLink, setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, splitSetupEntries, } from "./setup.js"; +export { formatDocsLink } from "../terminal/links.js"; export type { ChannelSetupAdapter, ChannelSetupDmPolicy, ChannelSetupWizard } from "./setup.js"; export { listLineAccountIds, diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 669586bb80c..11ffc459ef2 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -62,6 +62,14 @@ function resolveControlCommandGate(params) { return { commandAuthorized, shouldBlock }; } +function onDiagnosticEvent(listener) { + const monolithic = loadMonolithicSdk(); + if (!monolithic || typeof monolithic.onDiagnosticEvent !== "function") { + throw new Error("openclaw/plugin-sdk root alias could not resolve onDiagnosticEvent"); + } + return monolithic.onDiagnosticEvent(listener); +} + function getPackageRoot() { return path.resolve(__dirname, "..", ".."); } @@ -152,6 +160,7 @@ function tryLoadMonolithicSdk() { const fastExports = { emptyPluginConfigSchema, + onDiagnosticEvent, resolveControlCommandGate, }; diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 48ae4a7b43c..37072f9ded7 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -180,7 +180,11 @@ describe("plugin-sdk root alias", () => { const lazyRootSdk = lazyModule.moduleExports; expect(typeof lazyRootSdk.onDiagnosticEvent).toBe("function"); - expect(lazyRootSdk.onDiagnosticEvent).toBe(onDiagnosticEvent); + expect( + typeof (lazyRootSdk.onDiagnosticEvent as (listener: () => void) => () => void)( + () => undefined, + ), + ).toBe("function"); expect("onDiagnosticEvent" in lazyRootSdk).toBe(true); }); diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index 2158edff7d0..afa32af0b7f 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -34,14 +34,14 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export { probeIMessage } from "./src/probe.js";', 'export { sendMessageIMessage } from "./src/send.js";', ], - "extensions/googlechat/runtime-api.ts": ['export * from "../../src/plugin-sdk/googlechat.js";'], + "extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'], "extensions/matrix/runtime-api.ts": [ 'export * from "./src/auth-precedence.js";', 'export * from "./helper-api.js";', 'export * from "./thread-bindings-runtime.js";', ], "extensions/nextcloud-talk/runtime-api.ts": [ - 'export * from "../../src/plugin-sdk/nextcloud-talk.js";', + 'export * from "openclaw/plugin-sdk/nextcloud-talk";', ], "extensions/signal/runtime-api.ts": ['export * from "./src/runtime-api.js";'], "extensions/slack/runtime-api.ts": [ diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index ab8c16d71f7..566dc6645e1 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -61,30 +61,15 @@ describe("plugin-sdk subpath exports", () => { expect(pluginSdkSubpaths).not.toContain("acpx"); expect(pluginSdkSubpaths).not.toContain("compat"); expect(pluginSdkSubpaths).not.toContain("device-pair"); - expect(pluginSdkSubpaths).not.toContain("feishu"); expect(pluginSdkSubpaths).not.toContain("google"); - expect(pluginSdkSubpaths).not.toContain("googlechat"); - expect(pluginSdkSubpaths).not.toContain("irc"); - expect(pluginSdkSubpaths).not.toContain("line"); - expect(pluginSdkSubpaths).not.toContain("line-core"); expect(pluginSdkSubpaths).not.toContain("lobster"); - expect(pluginSdkSubpaths).not.toContain("mattermost"); - expect(pluginSdkSubpaths).not.toContain("msteams"); - expect(pluginSdkSubpaths).not.toContain("nextcloud-talk"); - expect(pluginSdkSubpaths).not.toContain("nostr"); expect(pluginSdkSubpaths).not.toContain("pairing-access"); expect(pluginSdkSubpaths).not.toContain("qwen-portal-auth"); expect(pluginSdkSubpaths).not.toContain("reply-prefix"); - expect(pluginSdkSubpaths).not.toContain("signal"); expect(pluginSdkSubpaths).not.toContain("signal-core"); expect(pluginSdkSubpaths).not.toContain("synology-chat"); - expect(pluginSdkSubpaths).not.toContain("tlon"); - expect(pluginSdkSubpaths).not.toContain("twitch"); expect(pluginSdkSubpaths).not.toContain("typing"); - expect(pluginSdkSubpaths).not.toContain("voice-call"); - expect(pluginSdkSubpaths).not.toContain("zalo"); expect(pluginSdkSubpaths).not.toContain("zai"); - expect(pluginSdkSubpaths).not.toContain("zalouser"); expect(pluginSdkSubpaths).not.toContain("provider-model-definitions"); }); From fcabecc9a4fbeb47b07d770bb734b4580cecc783 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 16:52:10 +0000 Subject: [PATCH 09/15] fix: remove duplicate plugin sdk exports --- src/plugin-sdk/acp-runtime.ts | 1 - src/plugin-sdk/core.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/plugin-sdk/acp-runtime.ts b/src/plugin-sdk/acp-runtime.ts index 88088867b2a..1657cb7cace 100644 --- a/src/plugin-sdk/acp-runtime.ts +++ b/src/plugin-sdk/acp-runtime.ts @@ -2,7 +2,6 @@ export { getAcpSessionManager } from "../acp/control-plane/manager.js"; export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js"; -export { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../acp/runtime/registry.js"; export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js"; export { getAcpRuntimeBackend, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index b5b149e25b0..c8c7980fbd2 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -58,8 +58,6 @@ export type { PluginCommandContext, PluginLogger, PluginInteractiveTelegramHandlerContext, - OpenClawPluginToolContext, - OpenClawPluginToolFactory, } from "../plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export { isSecretRef } from "../config/types.secrets.js"; From 87eeab703465eb42481946b130b9122c3e0fb587 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:04:11 -0700 Subject: [PATCH 10/15] docs: add plugin SDK migration guide, link deprecation warning to docs --- docs/docs.json | 1 + docs/plugins/sdk-migration.md | 144 ++++++++++++++++++++++++++++++++++ src/plugin-sdk/compat.ts | 4 +- 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 docs/plugins/sdk-migration.md diff --git a/docs/docs.json b/docs/docs.json index c9df5c4f0cc..65e4ed25c1b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1076,6 +1076,7 @@ "group": "Extensions", "pages": [ "plugins/building-extensions", + "plugins/sdk-migration", "plugins/architecture", "plugins/community", "plugins/bundles", diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md new file mode 100644 index 00000000000..7ae4e514c94 --- /dev/null +++ b/docs/plugins/sdk-migration.md @@ -0,0 +1,144 @@ +--- +title: "Plugin SDK Migration" +summary: "Migrate from openclaw/plugin-sdk/compat to focused subpath imports" +read_when: + - You see the OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED warning + - You are updating a plugin from the monolithic plugin-sdk import to scoped subpaths + - You maintain an external OpenClaw plugin +--- + +# Plugin SDK Migration + +OpenClaw is migrating from a single monolithic `openclaw/plugin-sdk/compat` barrel +to **focused subpath imports** (`openclaw/plugin-sdk/`). This page explains +what changed, why, and how to migrate. + +## Why this change + +The monolithic compat barrel re-exported everything from a single entry point. +This caused: + +- **Slow startup**: importing one helper pulled in dozens of unrelated modules. +- **Circular dependency risk**: broad re-exports made it easy to create import cycles. +- **Unclear API surface**: no way to tell which exports were stable vs internal. + +Focused subpaths fix all three: each subpath is a small, self-contained module +with a clear purpose. + +## What triggers the warning + +If your plugin imports from the compat barrel, you will see: + +``` +[OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED] Warning: openclaw/plugin-sdk/compat is +deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/ imports. +``` + +The compat barrel still works at runtime. This is a deprecation warning, not an +error. But new plugins **must not** use it, and existing plugins should migrate +before compat is removed. + +## How to migrate + +### Step 1: Find compat imports + +Search your extension for imports from the compat path: + +```bash +grep -r "plugin-sdk/compat" extensions/my-plugin/ +``` + +### Step 2: Replace with focused subpaths + +Each export from compat maps to a specific subpath. Replace the import source: + +```typescript +// Before (compat barrel) +import { + createChannelReplyPipeline, + createPluginRuntimeStore, + resolveControlCommandGate, +} from "openclaw/plugin-sdk/compat"; + +// After (focused subpaths) +import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; +``` + +### Step 3: Verify + +Run the build and tests: + +```bash +pnpm build +pnpm test -- extensions/my-plugin/ +``` + +## Subpath reference + +| Subpath | Purpose | Key exports | +| ----------------------------------- | ------------------------------------ | ---------------------------------------------------------------------- | +| `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` | +| `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` | +| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` | +| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` | +| `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter`, `createScopedChannelConfigAdapter` | +| `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types | +| `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` | +| `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` | +| `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities | +| `plugin-sdk/channel-send-result` | Send result types | Reply result types | +| `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` | +| `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase`, `formatNormalizedAllowFromEntries` | +| `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` | +| `plugin-sdk/command-auth` | Command gating | `resolveControlCommandGate` | +| `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers | +| `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities | +| `plugin-sdk/reply-payload` | Message reply types | Reply payload types | +| `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers | +| `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` | +| `plugin-sdk/testing` | Test utilities | Test helpers and mocks | + +Use the narrowest subpath that has what you need. If you cannot find an export, +check the source at `src/plugin-sdk/` or ask in Discord. + +## Compat barrel removal timeline + +- **Now**: compat barrel emits a deprecation warning at runtime. +- **Next major release**: compat barrel will be removed. Plugins still using it will + fail to import. + +Bundled plugins (under `extensions/`) have already been migrated. External plugins +should migrate before the next major release. + +## Suppressing the warning temporarily + +If you need to suppress the warning while migrating: + +```bash +OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING=1 openclaw gateway run +``` + +This is a temporary escape hatch, not a permanent solution. + +## Internal barrel pattern + +Within your extension, use local barrel files (`api.ts`, `runtime-api.ts`) for +internal code sharing instead of importing through the plugin SDK: + +```typescript +// extensions/my-plugin/api.ts — public contract for this extension +export { MyConfig } from "./src/config.js"; +export { MyRuntime } from "./src/runtime.js"; +``` + +Never import your own extension back through `openclaw/plugin-sdk/` +from production files. That path is for external consumers only. See +[Building Extensions](/plugins/building-extensions#step-4-use-local-barrels-for-internal-imports). + +## Related + +- [Building Extensions](/plugins/building-extensions) +- [Plugin Architecture](/plugins/architecture) +- [Plugin Manifest](/plugins/manifest) diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 99e2066633c..643557f0960 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -8,11 +8,11 @@ const shouldWarnCompatImport = if (shouldWarnCompatImport) { process.emitWarning( - "openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/ imports.", + "openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/ imports. See https://docs.openclaw.ai/plugins/sdk-migration", { code: "OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED", detail: - "Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating.", + "Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration", }, ); } From 93fbe26adbbcf15fec0b2ddd395478e9100de41e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:10:57 -0700 Subject: [PATCH 11/15] fix(config): tighten json and json5 parsing paths (#51153) --- CHANGELOG.md | 1 + src/agents/subagent-depth.test.ts | 27 ++++++++++++++++++++++++++ src/agents/subagent-depth.ts | 10 +++++++++- src/cli/config-cli.test.ts | 11 +++++++++++ src/cli/config-cli.ts | 8 ++++---- src/config/paths.ts | 2 +- src/cron/store.test.ts | 32 +++++++++++++++++++++++++++++++ src/cron/store.ts | 10 +++++++++- ui/src/ui/views/config.ts | 4 ++-- 9 files changed, 96 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b95fe247361..8e33a2d82a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/config: make `config set --strict-json` enforce real JSON, prefer `JSON.parse` with JSON5 fallback for machine-written cron/subagent stores, and relabel raw config surfaces as `JSON/JSON5` to match actual compatibility. Related: #48415, #43127, #14529, #21332. Thanks @adhitShet and @vincentkoc. - CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD. - Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev. - Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev. diff --git a/src/agents/subagent-depth.test.ts b/src/agents/subagent-depth.test.ts index 5d9427b7818..2c62432a692 100644 --- a/src/agents/subagent-depth.test.ts +++ b/src/agents/subagent-depth.test.ts @@ -76,6 +76,33 @@ describe("getSubagentDepthFromSessionStore", () => { expect(depth).toBe(2); }); + it("accepts JSON5 syntax in the on-disk depth store for backward compatibility", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-json5-")); + const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json"); + const storePath = storeTemplate.replaceAll("{agentId}", "main"); + fs.writeFileSync( + storePath, + `{ + // hand-edited legacy store + "agent:main:subagent:flat": { + sessionId: "subagent-flat", + spawnDepth: 2, + }, + }`, + "utf-8", + ); + + const depth = getSubagentDepthFromSessionStore("subagent:flat", { + cfg: { + session: { + store: storeTemplate, + }, + }, + }); + + expect(depth).toBe(2); + }); + it("falls back to session-key segment counting when metadata is missing", () => { const key = "agent:main:subagent:flat"; const depth = getSubagentDepthFromSessionStore(key, { diff --git a/src/agents/subagent-depth.ts b/src/agents/subagent-depth.ts index 8b62539ac45..9ad03bbbc91 100644 --- a/src/agents/subagent-depth.ts +++ b/src/agents/subagent-depth.ts @@ -11,6 +11,14 @@ type SessionDepthEntry = { spawnedBy?: unknown; }; +function parseSessionDepthStore(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return JSON5.parse(raw); + } +} + function normalizeSpawnDepth(value: unknown): number | undefined { if (typeof value === "number") { return Number.isInteger(value) && value >= 0 ? value : undefined; @@ -37,7 +45,7 @@ function normalizeSessionKey(value: unknown): string | undefined { function readSessionStore(storePath: string): Record { try { const raw = fs.readFileSync(storePath, "utf-8"); - const parsed = JSON5.parse(raw); + const parsed = parseSessionDepthStore(raw); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return parsed as Record; } diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index d30a476004d..6e9cc07bf7e 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -442,6 +442,15 @@ describe("config cli", () => { expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled(); }); + it("rejects JSON5-only object syntax when strict parsing is enabled", async () => { + await expect( + runConfigCommand(["config", "set", "gateway.auth", "{mode:'token'}", "--strict-json"]), + ).rejects.toThrow("__exit__:1"); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled(); + }); + it("accepts --strict-json with batch mode and applies batch payload", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 } }; setSnapshot(resolved, resolved); @@ -470,6 +479,8 @@ describe("config cli", () => { expect(helpText).toContain("--strict-json"); expect(helpText).toContain("--json"); expect(helpText).toContain("Legacy alias for --strict-json"); + expect(helpText).toContain("Value (JSON/JSON5 or raw string)"); + expect(helpText).toContain("Strict JSON parsing (error instead of"); expect(helpText).toContain("--ref-provider"); expect(helpText).toContain("--provider-source"); expect(helpText).toContain("--batch-json"); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 604e27666c9..e7a94ae99ab 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -159,9 +159,9 @@ function parseValue(raw: string, opts: ConfigSetParseOpts): unknown { const trimmed = raw.trim(); if (opts.strictJson) { try { - return JSON5.parse(trimmed); + return JSON.parse(trimmed); } catch (err) { - throw new Error(`Failed to parse JSON5 value: ${String(err)}`, { cause: err }); + throw new Error(`Failed to parse JSON value: ${String(err)}`, { cause: err }); } } @@ -1280,8 +1280,8 @@ export function registerConfigCli(program: Command) { .command("set") .description(CONFIG_SET_DESCRIPTION) .argument("[path]", "Config path (dot or bracket notation)") - .argument("[value]", "Value (JSON5 or raw string)") - .option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false) + .argument("[value]", "Value (JSON/JSON5 or raw string)") + .option("--strict-json", "Strict JSON parsing (error instead of raw string fallback)", false) .option("--json", "Legacy alias for --strict-json", false) .option( "--dry-run", diff --git a/src/config/paths.ts b/src/config/paths.ts index 84c27749bcf..a35a1a3d03d 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -99,7 +99,7 @@ function resolveUserPath( export const STATE_DIR = resolveStateDir(); /** - * Config file path (JSON5). + * Config file path (JSON or JSON5). * Can be overridden via OPENCLAW_CONFIG_PATH. * Default: ~/.openclaw/openclaw.json (or $OPENCLAW_STATE_DIR/openclaw.json) */ diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index f511636fb85..405d04cbe60 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -56,6 +56,38 @@ describe("cron store", () => { await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i); }); + it("accepts JSON5 syntax when loading an existing cron store", async () => { + const store = await makeStorePath(); + await fs.mkdir(path.dirname(store.storePath), { recursive: true }); + await fs.writeFile( + store.storePath, + `{ + // hand-edited legacy store + version: 1, + jobs: [ + { + id: 'job-1', + name: 'Job 1', + enabled: true, + createdAtMs: 1, + updatedAtMs: 1, + schedule: { kind: 'every', everyMs: 60000 }, + sessionTarget: 'main', + wakeMode: 'next-heartbeat', + payload: { kind: 'systemEvent', text: 'tick-job-1' }, + state: {}, + }, + ], + }`, + "utf-8", + ); + + await expect(loadCronStore(store.storePath)).resolves.toMatchObject({ + version: 1, + jobs: [{ id: "job-1", enabled: true }], + }); + }); + it("does not create a backup file when saving unchanged content", async () => { const store = await makeStorePath(); const payload = makeStore("job-1", true); diff --git a/src/cron/store.ts b/src/cron/store.ts index 8e8f0440f35..551a1f3cb64 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -10,6 +10,14 @@ export const DEFAULT_CRON_DIR = path.join(CONFIG_DIR, "cron"); export const DEFAULT_CRON_STORE_PATH = path.join(DEFAULT_CRON_DIR, "jobs.json"); const serializedStoreCache = new Map(); +function parseCronStoreRaw(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return JSON5.parse(raw); + } +} + export function resolveCronStorePath(storePath?: string) { if (storePath?.trim()) { const raw = storePath.trim(); @@ -26,7 +34,7 @@ export async function loadCronStore(storePath: string): Promise { const raw = await fs.promises.readFile(storePath, "utf-8"); let parsed: unknown; try { - parsed = JSON5.parse(raw); + parsed = parseCronStoreRaw(raw); } catch (err) { throw new Error(`Failed to parse cron store at ${storePath}: ${String(err)}`, { cause: err, diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 7c1121e6bb8..6e3db2c6a67 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1062,7 +1062,7 @@ export function renderConfig(props: ConfigProps) { }
- Raw JSON5 + Raw config (JSON/JSON5) ${ sensitiveCount > 0 ? html` @@ -1087,7 +1087,7 @@ export function renderConfig(props: ConfigProps) {