diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 46cf903d718b..8ec34f005e3d 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -102,7 +102,7 @@ ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persist 1bf4d4dfe5a4b264cf6fb8fbd0c7bc76f520ff9845cffad6da4b3a3c2bc3f6f6 module/plugin-config-runtime 86c083e3829e5e9dd11e8b65be31a13fd603a28b564112bb7825fcb16381cda7 module/plugin-entry 7a860d980c9ad73a4286587dd8e2dc7952c16094e15568c444271c0e5b81569f module/plugin-runtime -6a5672fbdf989aaa819cead8f030aaa8a72b05b688d049006e45af057b18f98b module/provider-auth +eb9f7c33c6ad1888d3db4fb54ac2274d6a34bf6efae05c44b83962f637472f6c module/provider-auth ac88277ad893bc1c10ba7cfada20e0e022fe42a08cbdb3e3486b749c820d5138 module/provider-catalog-runtime 8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture 784c3c5c5dbb1e2c33ccccc62f850b740d2adcbde5e10e4e891d8c0f78aaeb99 module/question-gateway-runtime diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 6a0d012cb941..7bfc54773fce 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -44,12 +44,11 @@ import { importTelegramSendModule, installTelegramSendTestHooks, } from "./send.test-harness.js"; +import { recordSentMessage, wasSentByBot } from "./sent-message-cache.js"; import { TELEGRAM_SENT_MESSAGE_CACHE_MAX_ENTRIES, TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE, - recordSentMessage, - wasSentByBot, -} from "./sent-message-cache.js"; +} from "./sent-message-cache.legacy-state.js"; installTelegramSendTestHooks(); diff --git a/extensions/telegram/src/sent-message-cache.legacy-state.ts b/extensions/telegram/src/sent-message-cache.legacy-state.ts new file mode 100644 index 000000000000..711d0ee4af3e --- /dev/null +++ b/extensions/telegram/src/sent-message-cache.legacy-state.ts @@ -0,0 +1,112 @@ +// Telegram sent-message cache row shape, keys, and legacy sidecar reader. +// +// Split from `sent-message-cache.ts`, which also value-loads the plugin runtime +// slot and the logger graph. Doctor enumeration cold-loads this module to plan the +// legacy-state import, so it stays a leaf. +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { resolveStorePath } from "openclaw/plugin-sdk/session-store-paths"; + +export const TTL_MS = 24 * 60 * 60 * 1000; +export const TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE = "telegram.sent-messages"; +export const TELEGRAM_SENT_MESSAGE_CACHE_MAX_ENTRIES = 10_000; + +export type PersistedSentMessage = { + scopeKey: string; + chatId: string; + messageId: string; + timestamp: number; +}; + +export type SentMessageConfig = Pick; + +function resolveSentMessageAgentId(cfg?: SentMessageConfig, agentId?: string): string { + return agentId?.trim() || (cfg?.agents ? resolveDefaultAgentId(cfg as OpenClawConfig) : "main"); +} + +function sentMessageScopeKeyForStorePath(storePath: string): string { + return createHash("sha256").update(storePath, "utf8").digest("hex").slice(0, 24); +} + +export function resolveSentMessageScopeKey(cfg?: SentMessageConfig, agentId?: string): string { + // This 24-hour cache follows the current agent owner. Do not revive a prior owner's + // transient bucket when the configured default changes. + return sentMessageScopeKeyForStorePath( + resolveStorePath(cfg?.session?.store, { + agentId: resolveSentMessageAgentId(cfg, agentId), + }), + ); +} + +export function sentMessageEntryKey(scopeKey: string, chatId: string, messageId: string): string { + return createHash("sha256") + .update(`${scopeKey}\0${chatId}\0${messageId}`, "utf8") + .digest("hex") + .slice(0, 32); +} + +function resolveSentMessageStorePath(cfg?: SentMessageConfig, agentId?: string): string { + return `${resolveStorePath(cfg?.session?.store, { + agentId: resolveSentMessageAgentId(cfg, agentId), + })}.telegram-sent-messages.json`; +} + +// A torn or foreign sidecar yields no entries, exactly as a missing file does; the +// runtime store is authoritative once doctor has migrated. +function readLegacySentMessages(filePath: string): Map> { + const store = new Map>(); + let parsed: Record>; + try { + parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record< + string, + Record + >; + } catch { + return store; + } + const now = Date.now(); + for (const [chatId, entry] of Object.entries(parsed)) { + const messages = new Map(); + for (const [messageId, timestamp] of Object.entries(entry)) { + if (typeof timestamp === "number" && Number.isFinite(timestamp) && now - timestamp < TTL_MS) { + messages.set(messageId, timestamp); + } + } + if (messages.size > 0) { + store.set(chatId, messages); + } + } + return store; +} + +export function listTelegramLegacySentMessageCacheEntries(params: { + cfg?: SentMessageConfig; + agentId?: string; + persistedPath?: string; + targetStorePath?: string; +}): Array<{ key: string; value: PersistedSentMessage; ttlMs?: number; timestamp?: number }> { + const scopeKey = params.targetStorePath + ? sentMessageScopeKeyForStorePath(params.targetStorePath) + : resolveSentMessageScopeKey(params.cfg, params.agentId); + const filePath = params.persistedPath ?? resolveSentMessageStorePath(params.cfg, params.agentId); + const legacy = fs.existsSync(filePath) + ? readLegacySentMessages(filePath) + : new Map>(); + return [...legacy.entries()].flatMap(([chatId, messages]) => + [...messages.entries()].flatMap(([messageId, timestamp]) => { + const ttlMs = TTL_MS - Math.max(0, Date.now() - timestamp); + return ttlMs > 0 + ? [ + { + key: sentMessageEntryKey(scopeKey, chatId, messageId), + value: { scopeKey, chatId, messageId, timestamp }, + ttlMs, + timestamp, + }, + ] + : []; + }), + ); +} diff --git a/extensions/telegram/src/sent-message-cache.ts b/extensions/telegram/src/sent-message-cache.ts index f095614df0f9..3d66e698bd60 100644 --- a/extensions/telegram/src/sent-message-cache.ts +++ b/extensions/telegram/src/sent-message-cache.ts @@ -1,26 +1,20 @@ // Telegram plugin module implements sent message cache behavior. -import { createHash } from "node:crypto"; -import fs from "node:fs"; -import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { resolveStorePath } from "openclaw/plugin-sdk/session-store-paths"; import { getTelegramRuntime } from "./runtime.js"; +import { + resolveSentMessageScopeKey, + sentMessageEntryKey, + TELEGRAM_SENT_MESSAGE_CACHE_MAX_ENTRIES, + TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE, + TTL_MS, + type PersistedSentMessage, + type SentMessageConfig, +} from "./sent-message-cache.legacy-state.js"; -const TTL_MS = 24 * 60 * 60 * 1000; const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; -export const TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE = "telegram.sent-messages"; -export const TELEGRAM_SENT_MESSAGE_CACHE_MAX_ENTRIES = 10_000; const TELEGRAM_SENT_MESSAGES_STATE_KEY = Symbol.for("openclaw.telegramSentMessagesState"); -type PersistedSentMessage = { - scopeKey: string; - chatId: string; - messageId: string; - timestamp: number; -}; - type SentMessageStore = Map>; type SentMessagePersistentStore = PluginStateSyncKeyedStore; @@ -34,8 +28,6 @@ type SentMessageState = { bucketsByScope: Map; }; -type SentMessageConfig = Pick; - function getSentMessageState(): SentMessageState { const globalStore = globalThis as Record; const existing = globalStore[TELEGRAM_SENT_MESSAGES_STATE_KEY] as SentMessageState | undefined; @@ -53,37 +45,6 @@ function createSentMessageStore(): SentMessageStore { return new Map>(); } -function resolveSentMessageAgentId(cfg?: SentMessageConfig, agentId?: string): string { - return agentId?.trim() || (cfg?.agents ? resolveDefaultAgentId(cfg as OpenClawConfig) : "main"); -} - -function resolveSentMessageStorePath(cfg?: SentMessageConfig, agentId?: string): string { - return `${resolveStorePath(cfg?.session?.store, { - agentId: resolveSentMessageAgentId(cfg, agentId), - })}.telegram-sent-messages.json`; -} - -function sentMessageScopeKeyForStorePath(storePath: string): string { - return createHash("sha256").update(storePath, "utf8").digest("hex").slice(0, 24); -} - -function resolveSentMessageScopeKey(cfg?: SentMessageConfig, agentId?: string): string { - // This 24-hour cache follows the current agent owner. Do not revive a prior owner's - // transient bucket when the configured default changes. - return sentMessageScopeKeyForStorePath( - resolveStorePath(cfg?.session?.store, { - agentId: resolveSentMessageAgentId(cfg, agentId), - }), - ); -} - -function sentMessageEntryKey(scopeKey: string, chatId: string, messageId: string): string { - return createHash("sha256") - .update(`${scopeKey}\0${chatId}\0${messageId}`, "utf8") - .digest("hex") - .slice(0, 32); -} - function openSentMessageStore(): SentMessagePersistentStore { return getTelegramRuntime().state.openSyncKeyedStore({ namespace: TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE, @@ -113,34 +74,6 @@ function cleanupExpiredSentMessages(store: SentMessageStore, now: number): void } } -function readLegacySentMessages(filePath: string): SentMessageStore { - try { - const raw = fs.readFileSync(filePath, "utf-8"); - const parsed = JSON.parse(raw) as Record>; - const now = Date.now(); - const store = createSentMessageStore(); - for (const [chatId, entry] of Object.entries(parsed)) { - const messages = new Map(); - for (const [messageId, timestamp] of Object.entries(entry)) { - if ( - typeof timestamp === "number" && - Number.isFinite(timestamp) && - now - timestamp < TTL_MS - ) { - messages.set(messageId, timestamp); - } - } - if (messages.size > 0) { - store.set(chatId, messages); - } - } - return store; - } catch (error) { - logVerbose(`telegram: failed to read sent-message cache: ${String(error)}`); - return createSentMessageStore(); - } -} - function readPersistedSentMessages(scopeKey: string): SentMessageStore { const now = Date.now(); const store = createSentMessageStore(); @@ -237,33 +170,3 @@ export function wasSentByBot( cleanupExpired(store, scopeKey, entry, Date.now()); return entry.has(idKey); } - -export function listTelegramLegacySentMessageCacheEntries(params: { - cfg?: SentMessageConfig; - agentId?: string; - persistedPath?: string; - targetStorePath?: string; -}): Array<{ key: string; value: PersistedSentMessage; ttlMs?: number; timestamp?: number }> { - const scopeKey = params.targetStorePath - ? sentMessageScopeKeyForStorePath(params.targetStorePath) - : resolveSentMessageScopeKey(params.cfg, params.agentId); - const filePath = params.persistedPath ?? resolveSentMessageStorePath(params.cfg, params.agentId); - const legacy = fs.existsSync(filePath) - ? readLegacySentMessages(filePath) - : createSentMessageStore(); - return [...legacy.entries()].flatMap(([chatId, messages]) => - [...messages.entries()].flatMap(([messageId, timestamp]) => { - const ttlMs = TTL_MS - Math.max(0, Date.now() - timestamp); - return ttlMs > 0 - ? [ - { - key: sentMessageEntryKey(scopeKey, chatId, messageId), - value: { scopeKey, chatId, messageId, timestamp }, - ttlMs, - timestamp, - }, - ] - : []; - }), - ); -} diff --git a/extensions/telegram/src/state-migrations.import-boundary.test.ts b/extensions/telegram/src/state-migrations.import-boundary.test.ts index 982cc509546d..85ac419db9ff 100644 --- a/extensions/telegram/src/state-migrations.import-boundary.test.ts +++ b/extensions/telegram/src/state-migrations.import-boundary.test.ts @@ -1,11 +1,72 @@ -import { readFile } from "node:fs/promises"; +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; import { describe, expect, it } from "vitest"; -describe("telegram state migration import boundary", () => { - it("keeps the runtime message cache off the doctor discovery path", async () => { - const source = await readFile(new URL("./state-migrations.ts", import.meta.url), "utf8"); +// Doctor enumeration cold-loads this closure for every operator running `openclaw +// doctor` or a startup migration scan, so it must reach only leaf modules. Each +// runtime store below owns the same rows but also value-loads the plugin runtime +// slot, the logger graph, or the ACP/session-binding graphs; the row shapes and +// sidecar readers live in the matching `*.legacy-state.ts` leaf instead. +const RUNTIME_STORE_MODULES = new Set([ + "message-cache.ts", + "sent-message-cache.ts", + "sticker-cache-store.ts", + "thread-bindings.ts", +]); +const SOURCE_DIR = path.dirname(new URL(import.meta.url).pathname); - expect(source).toContain('from "./message-cache-persistence.js"'); - expect(source).not.toContain('from "./message-cache.js"'); +function listStaticRelativeImports(filePath: string): string[] { + const source = fs.readFileSync(filePath, "utf8"); + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true); + const specifiers: string[] = []; + for (const statement of sourceFile.statements) { + const isTypeOnly = + (ts.isImportDeclaration(statement) && statement.importClause?.isTypeOnly === true) || + (ts.isExportDeclaration(statement) && statement.isTypeOnly); + const moduleSpecifier = + ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement) + ? statement.moduleSpecifier + : undefined; + if (!isTypeOnly && moduleSpecifier && ts.isStringLiteralLike(moduleSpecifier)) { + specifiers.push(moduleSpecifier.text); + } + } + return specifiers.filter((specifier) => specifier.startsWith(".")); +} + +function collectPluginLocalClosure(entryFile: string): string[] { + const visited = new Set(); + const pending = [entryFile]; + while (pending.length > 0) { + const fileName = pending.pop(); + if (!fileName || visited.has(fileName)) { + continue; + } + visited.add(fileName); + for (const specifier of listStaticRelativeImports(path.join(SOURCE_DIR, fileName))) { + const resolved = `${specifier.replace(/^\.\//, "").replace(/\.js$/, "")}.ts`; + if (fs.existsSync(path.join(SOURCE_DIR, resolved))) { + pending.push(resolved); + } + } + } + return [...visited].toSorted(); +} + +describe("telegram state migration import boundary", () => { + it("keeps runtime stores off the doctor discovery closure", () => { + const closure = collectPluginLocalClosure("state-migrations.ts"); + + expect(closure.filter((module) => RUNTIME_STORE_MODULES.has(module))).toStrictEqual([]); + // The leaves are the intended replacements; an empty closure would pass vacuously. + expect(closure).toEqual( + expect.arrayContaining([ + "message-cache-persistence.ts", + "sent-message-cache.legacy-state.ts", + "sticker-cache-store.legacy-state.ts", + "thread-bindings-store.ts", + ]), + ); }); }); diff --git a/extensions/telegram/src/state-migrations.ts b/extensions/telegram/src/state-migrations.ts index ca0944b14966..396c85dd2ae0 100644 --- a/extensions/telegram/src/state-migrations.ts +++ b/extensions/telegram/src/state-migrations.ts @@ -28,12 +28,12 @@ import { listTelegramLegacySentMessageCacheEntries, TELEGRAM_SENT_MESSAGE_CACHE_MAX_ENTRIES, TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE, -} from "./sent-message-cache.js"; +} from "./sent-message-cache.legacy-state.js"; import { listTelegramLegacyStickerCacheEntries, TELEGRAM_STICKER_CACHE_MAX_ENTRIES, TELEGRAM_STICKER_CACHE_NAMESPACE, -} from "./sticker-cache-store.js"; +} from "./sticker-cache-store.legacy-state.js"; import { listTelegramLegacyThreadBindingEntries, resolveTelegramThreadBindingsPath, diff --git a/extensions/telegram/src/sticker-cache-store.legacy-state.ts b/extensions/telegram/src/sticker-cache-store.legacy-state.ts new file mode 100644 index 000000000000..bdf9ce226802 --- /dev/null +++ b/extensions/telegram/src/sticker-cache-store.legacy-state.ts @@ -0,0 +1,59 @@ +// Telegram sticker cache row shape, keys, and legacy sidecar reader. +// +// Split from `sticker-cache-store.ts`, which also value-loads the plugin runtime +// slot and the logger graph. Doctor enumeration cold-loads this module to plan the +// legacy-state import, so it stays a leaf. +import { loadJsonFile } from "openclaw/plugin-sdk/json-store"; + +const CACHE_VERSION = 1; +export const TELEGRAM_STICKER_CACHE_NAMESPACE = "telegram.sticker-cache"; +export const TELEGRAM_STICKER_CACHE_MAX_ENTRIES = 10_000; + +export interface CachedSticker { + fileId: string; + fileUniqueId: string; + emoji?: string; + setName?: string; + description: string; + cachedAt: string; + receivedFrom?: string; +} + +interface StickerCache { + version: number; + stickers: Record; +} + +export function normalizeCachedStickerForStore(sticker: CachedSticker): CachedSticker { + return { + fileId: sticker.fileId, + fileUniqueId: sticker.fileUniqueId, + description: sticker.description, + cachedAt: sticker.cachedAt, + ...(sticker.emoji !== undefined ? { emoji: sticker.emoji } : {}), + ...(sticker.setName !== undefined ? { setName: sticker.setName } : {}), + ...(sticker.receivedFrom !== undefined ? { receivedFrom: sticker.receivedFrom } : {}), + }; +} + +function loadCacheFile(filePath: string): StickerCache { + const data = loadJsonFile(filePath); + if (!data || typeof data !== "object") { + return { version: CACHE_VERSION, stickers: {} }; + } + const cache = data as StickerCache; + if (cache.version !== CACHE_VERSION) { + return { version: CACHE_VERSION, stickers: {} }; + } + return cache; +} + +export function listTelegramLegacyStickerCacheEntries(params: { + persistedPath: string; +}): Array<{ key: string; value: CachedSticker }> { + const cache = loadCacheFile(params.persistedPath); + return Object.entries(cache.stickers).map(([key, value]) => ({ + key, + value: normalizeCachedStickerForStore(value), + })); +} diff --git a/extensions/telegram/src/sticker-cache-store.ts b/extensions/telegram/src/sticker-cache-store.ts index eb1816f2c118..449687555898 100644 --- a/extensions/telegram/src/sticker-cache-store.ts +++ b/extensions/telegram/src/sticker-cache-store.ts @@ -1,36 +1,18 @@ // Telegram plugin module implements sticker cache store behavior. -import path from "node:path"; -import { loadJsonFile } from "openclaw/plugin-sdk/json-store"; import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; import { getTelegramRuntime } from "./runtime.js"; +import { + normalizeCachedStickerForStore, + TELEGRAM_STICKER_CACHE_MAX_ENTRIES, + TELEGRAM_STICKER_CACHE_NAMESPACE, + type CachedSticker, +} from "./sticker-cache-store.legacy-state.js"; -const CACHE_VERSION = 1; -export const TELEGRAM_STICKER_CACHE_NAMESPACE = "telegram.sticker-cache"; -export const TELEGRAM_STICKER_CACHE_MAX_ENTRIES = 10_000; - -export interface CachedSticker { - fileId: string; - fileUniqueId: string; - emoji?: string; - setName?: string; - description: string; - cachedAt: string; - receivedFrom?: string; -} - -interface StickerCache { - version: number; - stickers: Record; -} +export type { CachedSticker }; type TelegramStickerCacheStore = PluginStateSyncKeyedStore; -function getCacheFile(): string { - return path.join(resolveStateDir(), "telegram", "sticker-cache.json"); -} - function openStickerCacheStore(): TelegramStickerCacheStore { return getTelegramRuntime().state.openSyncKeyedStore({ namespace: TELEGRAM_STICKER_CACHE_NAMESPACE, @@ -38,26 +20,10 @@ function openStickerCacheStore(): TelegramStickerCacheStore { }); } -function loadCache(): StickerCache { - return loadCacheFile(getCacheFile()); -} - function normalizeStickerSearchText(value: unknown): string { return typeof value === "string" ? value.trim().toLowerCase() : ""; } -function normalizeCachedStickerForStore(sticker: CachedSticker): CachedSticker { - return { - fileId: sticker.fileId, - fileUniqueId: sticker.fileUniqueId, - description: sticker.description, - cachedAt: sticker.cachedAt, - ...(sticker.emoji !== undefined ? { emoji: sticker.emoji } : {}), - ...(sticker.setName !== undefined ? { setName: sticker.setName } : {}), - ...(sticker.receivedFrom !== undefined ? { receivedFrom: sticker.receivedFrom } : {}), - }; -} - function readStickerCacheStore( operation: string, read: (store: TelegramStickerCacheStore) => T, @@ -169,27 +135,3 @@ export function getCacheStats(): { count: number; oldestAt?: string; newestAt?: newestAt: sorted[sorted.length - 1]?.cachedAt, }; } - -export function listTelegramLegacyStickerCacheEntries( - params: { - persistedPath?: string; - } = {}, -): Array<{ key: string; value: CachedSticker }> { - const cache = params.persistedPath ? loadCacheFile(params.persistedPath) : loadCache(); - return Object.entries(cache.stickers).map(([key, value]) => ({ - key, - value: normalizeCachedStickerForStore(value), - })); -} - -function loadCacheFile(filePath: string): StickerCache { - const data = loadJsonFile(filePath); - if (!data || typeof data !== "object") { - return { version: CACHE_VERSION, stickers: {} }; - } - const cache = data as StickerCache; - if (cache.version !== CACHE_VERSION) { - return { version: CACHE_VERSION, stickers: {} }; - } - return cache; -} diff --git a/extensions/telegram/src/sticker-cache.test.ts b/extensions/telegram/src/sticker-cache.test.ts index 3745aee8a4e1..39f82fa1155b 100644 --- a/extensions/telegram/src/sticker-cache.test.ts +++ b/extensions/telegram/src/sticker-cache.test.ts @@ -9,6 +9,10 @@ import { setTelegramRuntime } from "./runtime.js"; import { clearTelegramRuntimeForTest } from "./runtime.test-support.js"; import type { TelegramRuntime } from "./runtime.types.js"; import * as stickerCache from "./sticker-cache-store.js"; +import { + TELEGRAM_STICKER_CACHE_MAX_ENTRIES, + TELEGRAM_STICKER_CACHE_NAMESPACE, +} from "./sticker-cache-store.legacy-state.js"; vi.mock("openclaw/plugin-sdk/state-paths", () => ({ resolveStateDir: () => "/tmp/openclaw-test-sticker-cache", @@ -32,8 +36,8 @@ describe("sticker-cache", () => { resetPluginStateStoreForTests({ closeDatabase: false }); installStore( createPluginStateSyncKeyedStoreForTests("telegram", { - namespace: stickerCache.TELEGRAM_STICKER_CACHE_NAMESPACE, - maxEntries: stickerCache.TELEGRAM_STICKER_CACHE_MAX_ENTRIES, + namespace: TELEGRAM_STICKER_CACHE_NAMESPACE, + maxEntries: TELEGRAM_STICKER_CACHE_MAX_ENTRIES, }), ); store.clear(); @@ -89,8 +93,8 @@ describe("sticker-cache", () => { it("treats plugin-state lookup failures as cache misses", () => { installStore({ ...createPluginStateSyncKeyedStoreForTests("telegram", { - namespace: stickerCache.TELEGRAM_STICKER_CACHE_NAMESPACE, - maxEntries: stickerCache.TELEGRAM_STICKER_CACHE_MAX_ENTRIES, + namespace: TELEGRAM_STICKER_CACHE_NAMESPACE, + maxEntries: TELEGRAM_STICKER_CACHE_MAX_ENTRIES, }), lookup() { throw new Error("lookup failed"); @@ -161,8 +165,8 @@ describe("sticker-cache", () => { it("does not throw when plugin-state writes fail", () => { installStore({ ...createPluginStateSyncKeyedStoreForTests("telegram", { - namespace: stickerCache.TELEGRAM_STICKER_CACHE_NAMESPACE, - maxEntries: stickerCache.TELEGRAM_STICKER_CACHE_MAX_ENTRIES, + namespace: TELEGRAM_STICKER_CACHE_NAMESPACE, + maxEntries: TELEGRAM_STICKER_CACHE_MAX_ENTRIES, }), register() { throw new Error("write failed"); @@ -275,8 +279,8 @@ describe("sticker-cache", () => { it("returns no matches when plugin-state search reads fail", () => { installStore({ ...createPluginStateSyncKeyedStoreForTests("telegram", { - namespace: stickerCache.TELEGRAM_STICKER_CACHE_NAMESPACE, - maxEntries: stickerCache.TELEGRAM_STICKER_CACHE_MAX_ENTRIES, + namespace: TELEGRAM_STICKER_CACHE_NAMESPACE, + maxEntries: TELEGRAM_STICKER_CACHE_MAX_ENTRIES, }), entries() { throw new Error("entries failed"); @@ -296,8 +300,8 @@ describe("sticker-cache", () => { it("returns empty array when plugin-state list reads fail", () => { installStore({ ...createPluginStateSyncKeyedStoreForTests("telegram", { - namespace: stickerCache.TELEGRAM_STICKER_CACHE_NAMESPACE, - maxEntries: stickerCache.TELEGRAM_STICKER_CACHE_MAX_ENTRIES, + namespace: TELEGRAM_STICKER_CACHE_NAMESPACE, + maxEntries: TELEGRAM_STICKER_CACHE_MAX_ENTRIES, }), entries() { throw new Error("entries failed"); diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index c97466a1f882..661136d9656b 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -3,7 +3,6 @@ import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-core" import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; -import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, @@ -14,6 +13,7 @@ import { normalizeSecretInputString, resolveSecretInputString, } from "openclaw/plugin-sdk/secret-input"; +import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/secret-provider-alias"; import { resolveDefaultTelegramAccountId } from "./account-selection.js"; type CredentialUnavailableDiagnostic = Extract< diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index 990b814cb005..f6748439368a 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -889,6 +889,9 @@ "openclaw/plugin-sdk/outbound-echo-runtime": [ "../packages/plugin-sdk/dist/src/plugin-sdk/outbound-echo-runtime.d.ts" ], + "openclaw/plugin-sdk/secret-provider-alias": [ + "../packages/plugin-sdk/dist/src/plugin-sdk/secret-provider-alias.d.ts" + ], "openclaw/plugin-sdk/session-store-paths": [ "../packages/plugin-sdk/dist/src/plugin-sdk/session-store-paths.d.ts" ] diff --git a/extensions/xai/tsconfig.json b/extensions/xai/tsconfig.json index 1adb1945b3b1..02ed867339fb 100644 --- a/extensions/xai/tsconfig.json +++ b/extensions/xai/tsconfig.json @@ -876,6 +876,9 @@ "openclaw/plugin-sdk/outbound-echo-runtime": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/outbound-echo-runtime.d.ts" ], + "openclaw/plugin-sdk/secret-provider-alias": [ + "../../packages/plugin-sdk/dist/src/plugin-sdk/secret-provider-alias.d.ts" + ], "openclaw/plugin-sdk/session-store-paths": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/session-store-paths.d.ts" ] diff --git a/package.json b/package.json index 4c885139ab55..d32bac5d561b 100644 --- a/package.json +++ b/package.json @@ -187,6 +187,7 @@ "!dist/plugin-sdk/model-ref-parse.d.ts", "!dist/plugin-sdk/outbound-echo-runtime.d.ts", "!dist/plugin-sdk/plugin-state-store-runtime.d.ts", + "!dist/plugin-sdk/secret-provider-alias.d.ts", "!dist/plugin-sdk/session-store-paths.d.ts", "!dist/plugin-sdk/runtime-doctor.d.ts", "!dist/plugin-sdk/runtime-fetch.d.ts", @@ -416,6 +417,9 @@ "./plugin-sdk/outbound-echo-runtime": { "default": "./dist/plugin-sdk/outbound-echo-runtime.js" }, + "./plugin-sdk/secret-provider-alias": { + "default": "./dist/plugin-sdk/secret-provider-alias.js" + }, "./plugin-sdk/session-store-paths": { "default": "./dist/plugin-sdk/session-store-paths.js" }, diff --git a/scripts/check-built-plugin-control-plane-modules.mts b/scripts/check-built-plugin-control-plane-modules.mts index 2e360a4f091a..3100cdba8356 100644 --- a/scripts/check-built-plugin-control-plane-modules.mts +++ b/scripts/check-built-plugin-control-plane-modules.mts @@ -23,6 +23,11 @@ type BuiltPluginControlPlaneModuleFailure = BuiltPluginControlPlaneModule & { error: string; }; +type BuiltDoctorContractClosureViolation = BuiltPluginControlPlaneModule & { + dependency: string; + importerPath: string; +}; + type ProbeParams = { rootDir?: string; timeoutMs?: number; @@ -37,6 +42,15 @@ const LEGACY_SETUP_PROPERTIES = new Map([ ]); const PROBE_RESULT_MARKER = "__OPENCLAW_PLUGIN_CONTROL_PLANE_PROBE__"; const DEFAULT_TIMEOUT_MS = 120_000; +// Doctor enumeration cold-loads every declaring plugin's contract closure, so a +// doctor artifact must never reach the process-spawn graph. Requiring the artifact +// cannot prove this: plain Node resolves the whole graph fine, and the cost and the +// ESM-only transitive deps (execa -> npm-run-path -> unicorn-magic, which has no +// `require` condition) only surface on source-run hosts whose CJS-flavored resolver +// rejects them. `doctor-contract-closure-guard.test.ts` owns the same invariant over +// sources; bundling can merge runtime code into the artifact behind its back, so the +// built closure is checked here. +const FORBIDDEN_DOCTOR_CONTRACT_DEPENDENCIES = ["execa"]; const REQUIRE_PROBE_SOURCE = String.raw` const { createRequire } = require("node:module"); const path = require("node:path"); @@ -174,6 +188,90 @@ export function probeBuiltPluginControlPlaneModules( ); } +// Built chunks are plain ESM, so static edges are exactly the import/export +// declarations. Dynamic `import()` is excluded by construction: a lazy edge is +// never paid at enumeration time. +function parseStaticModuleSpecifiers(source: string, filePath: string): string[] { + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true); + const specifiers: string[] = []; + for (const statement of sourceFile.statements) { + const moduleSpecifier = + ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement) + ? statement.moduleSpecifier + : undefined; + if (moduleSpecifier && ts.isStringLiteralLike(moduleSpecifier)) { + specifiers.push(moduleSpecifier.text); + } + } + return specifiers; +} + +function resolveBuiltChunkPath(importerPath: string, specifier: string): string | undefined { + const target = path.resolve(path.dirname(importerPath), specifier); + const candidates = [target, `${target}.js`, `${target}.mjs`, path.join(target, "index.js")]; + return candidates.find( + (candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile(), + ); +} + +/** Collects the bare dependencies a built artifact reaches through static imports. */ +function collectBuiltModuleStaticDependencies(entryPath: string): Map { + const dependencies = new Map(); + const visited = new Set(); + const pending: string[] = [entryPath]; + while (pending.length > 0) { + const filePath = pending.pop(); + if (!filePath || visited.has(filePath)) { + continue; + } + visited.add(filePath); + let source: string; + try { + source = fs.readFileSync(filePath, "utf8"); + } catch { + continue; + } + for (const reference of parseStaticModuleSpecifiers(source, filePath)) { + if (reference.startsWith(".") || reference.startsWith("/")) { + const resolved = resolveBuiltChunkPath(filePath, reference); + if (resolved) { + pending.push(resolved); + } + continue; + } + if (!reference.startsWith("node:") && !dependencies.has(reference)) { + dependencies.set(reference, filePath); + } + } + } + return dependencies; +} + +/** Fails when a built doctor artifact statically reaches a forbidden runtime dependency. */ +export function collectBuiltDoctorContractClosureViolations( + modules: BuiltPluginControlPlaneModule[], + params: { rootDir?: string } = {}, +): BuiltDoctorContractClosureViolation[] { + const rootDir = path.resolve(params.rootDir ?? ROOT); + const violations: BuiltDoctorContractClosureViolation[] = []; + for (const module of modules.filter((candidate) => candidate.kind === "doctor-contract")) { + const dependencies = collectBuiltModuleStaticDependencies( + path.join(rootDir, module.relativePath), + ); + for (const dependency of FORBIDDEN_DOCTOR_CONTRACT_DEPENDENCIES) { + const importer = dependencies.get(dependency); + if (importer) { + violations.push({ + ...module, + dependency, + importerPath: path.relative(rootDir, importer).split(path.sep).join("/"), + }); + } + } + } + return violations; +} + /** Fails the build when a generated plugin control-plane module cannot be required natively. */ export function verifyBuiltPluginControlPlaneModules(params: ProbeParams = {}) { const modules = listBuiltPluginControlPlaneModules(params); @@ -185,8 +283,18 @@ export function verifyBuiltPluginControlPlaneModules(params: ProbeParams = {}) { ); throw new Error(`built plugin control-plane module load failures:\n${details.join("\n")}`); } + const closureViolations = collectBuiltDoctorContractClosureViolations(modules, params); + if (closureViolations.length > 0) { + const details = closureViolations.map( + (violation) => + `- ${violation.pluginId} ${violation.relativePath} statically reaches ${violation.dependency} through ${violation.importerPath}`, + ); + throw new Error( + `built doctor contract closures reach forbidden runtime dependencies:\n${details.join("\n")}`, + ); + } console.error( - `[plugin-control-plane-loads] verified ${modules.length} built modules with native require`, + `[plugin-control-plane-loads] verified ${modules.length} built modules with native require and checked doctor closures`, ); } diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index fbb1e36fcae5..2cf37b04d72a 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -8,6 +8,7 @@ "doctor-repair-runtime", "model-ref-parse", "outbound-echo-runtime", + "secret-provider-alias", "session-store-paths", "plugin-state-store-runtime", "runtime-doctor-migrations", diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json index b0681df38d01..8208e3aeaaa1 100644 --- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -143,6 +143,7 @@ "runtime-fetch", "sandbox", "secret-file-runtime", + "secret-provider-alias", "secure-random-runtime", "session-binding-runtime", "session-catalog-runtime", diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index dd5cef9d5e9d..12271bab3574 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -110,7 +110,7 @@ export { } from "../plugins/provider-auth-helpers.js"; export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; export { coerceSecretRef, hasConfiguredSecretInput } from "../config/types.secrets.js"; -export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; +export { resolveDefaultSecretProviderAlias } from "./secret-provider-alias.js"; export { resolveRequiredHomeDir } from "../infra/home-dir.js"; export { normalizeOptionalSecretInput, diff --git a/src/plugin-sdk/secret-provider-alias.ts b/src/plugin-sdk/secret-provider-alias.ts new file mode 100644 index 000000000000..ce4564d7001c --- /dev/null +++ b/src/plugin-sdk/secret-provider-alias.ts @@ -0,0 +1,8 @@ +// Default secret-provider alias resolution. +// +// Split from the `provider-auth` barrel, which also value-loads the auth-profile +// store, provider runtime, and plugin install graph (execa, kysely, commander). +// Doctor closures only need the alias grammar, and doctor enumeration cold-loads +// those closures. + +export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; diff --git a/src/plugins/doctor-contract-closure-guard.test.ts b/src/plugins/doctor-contract-closure-guard.test.ts index 6c854c173559..207216dff874 100644 --- a/src/plugins/doctor-contract-closure-guard.test.ts +++ b/src/plugins/doctor-contract-closure-guard.test.ts @@ -52,6 +52,34 @@ const FORBIDDEN_SPECIFIER_RULES = new Map { ); }); }); + +describe("built doctor contract closures", () => { + it("follows chunk edges to a forbidden runtime dependency", () => { + const rootDir = makeRoot(); + write( + rootDir, + "dist/extensions/demo/doctor-contract-api.js", + 'import { rule } from "../../token-chunk.js";\nexport const rules = [rule];\n', + ); + write(rootDir, "dist/token-chunk.js", 'export { rule } from "./exec-chunk.js";\n'); + write(rootDir, "dist/exec-chunk.js", 'import "execa";\nexport const rule = 1;\n'); + + expect( + collectBuiltDoctorContractClosureViolations(listBuiltPluginControlPlaneModules({ rootDir }), { + rootDir, + }), + ).toEqual([ + { + pluginId: "demo", + kind: "doctor-contract", + relativePath: "dist/extensions/demo/doctor-contract-api.js", + dependency: "execa", + importerPath: "dist/exec-chunk.js", + }, + ]); + }); + + it("ignores lazy edges and non-doctor contract surfaces", () => { + const rootDir = makeRoot(); + // A dynamic import is never paid at enumeration time, and the general contract + // surface may legitimately spawn commands (matrix probes its SDK packages). + write( + rootDir, + "dist/extensions/demo/doctor-contract-api.js", + 'export const load = () => import("execa");\n', + ); + write( + rootDir, + "dist/extensions/demo/contract-api.js", + 'import "execa";\nexport const a = 1;\n', + ); + + expect( + collectBuiltDoctorContractClosureViolations(listBuiltPluginControlPlaneModules({ rootDir }), { + rootDir, + }), + ).toEqual([]); + }); +});