mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-10 17:16:55 +00:00
perf(doctor): restore telegram doctor repairs dropped on source-run hosts (#120954)
* perf(doctor): keep telegram doctor enumeration off the runtime graph Telegram's built doctor artifact reached execa through dist chunking, so a source-run host (pnpm dev, tsx CLI, vitest) could not require it and silently dropped all 9 telegram legacy config rules plus its state migration. The artifact also pulled telegram's runtime stores, making it a 674-chunk outlier that dominated doctor enumeration. Root cause: `src/token.ts` took the broad `plugin-sdk/provider-auth` barrel for `resolveDefaultSecretProviderAlias`, dragging the auth-profile store, provider runtime, and plugin install graph (execa, kysely, commander) into the closure. The alias now has a narrow `plugin-sdk/secret-provider-alias` leaf, and provider-auth re-exports it so its runtime surface is unchanged. Thread-binding, sent-message, and sticker-cache row shapes, keys, and legacy sidecar readers move to `*.legacy-state.ts` leaves. The doctor closure keeps the rows and drops the ACP, session-binding, send, logger, and plugin-runtime graphs the stores also load. The postbuild control-plane verifier only required each artifact in a plain Node child, the one host where these graphs resolve fine, so it proved nothing about the invariant that broke. It now also walks each built doctor artifact's static import closure and fails when it reaches the process-spawn graph, which is the dist-level analogue of the source closure guard. Guard rules added for provider-auth, acp-runtime, and conversation-runtime; the telegram boundary test became a real closure assertion instead of a string grep. * fix(doctor): drop dead export surface from the telegram legacy-state split Knip and oxlint caught leftovers from the split: the leaves exported helpers only they use, the store modules re-exported constants nobody imports from them anymore, and thread-bindings kept a `testing` barrel whose last production caller was the migration path that now reads the leaf directly. Tests import the constants from the leaf that owns them, and the reset helper directly. The closure gate's failure message still interpolated a `host` field left over from a probe-host approach that was reverted before commit; the existing verifier test caught it. The gate now has its own coverage: a transitive chunk edge to a forbidden dependency is reported, while dynamic imports and non-doctor contract surfaces are not. * fix(doctor): adopt the upstream telegram thread-binding store split `main` landed an equivalent thread-binding leaf as `thread-bindings-store.ts` while this branch was open, so the branch-local `thread-bindings.legacy-state.ts` is dropped rather than kept as a second path for the same rows. `state-migrations.ts` now reaches token.js through the lazy import `main` added, so `token.ts` is no longer in the doctor closure at all. The narrow `secret-provider-alias` leaf still matters: telegram's contract-api closure reaches `provider-auth` through `token.ts` on current `main`, which is the same execa/kysely/commander graph, so the barrel is repaired at its source instead of being deferred a second time. * fix(scripts): type the built doctor closure gate for the TypeScript migration The gate was authored against the `.mjs` script and landed in the `.mts` file `main` migrated to, so its parameters were implicitly `any` and `check:test-types` failed. Adds the explicit signatures plus the violation type. Regenerates the plugin-sdk API baseline: `provider-auth` re-exports the default secret-provider alias from the new leaf, so its module hash moves while its runtime export surface stays identical.
This commit is contained in:
committed by
GitHub
parent
0303af17f3
commit
081a565cba
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
112
extensions/telegram/src/sent-message-cache.legacy-state.ts
Normal file
112
extensions/telegram/src/sent-message-cache.legacy-state.ts
Normal file
@@ -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<OpenClawConfig, "agents" | "session">;
|
||||
|
||||
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<string, Map<string, number>> {
|
||||
const store = new Map<string, Map<string, number>>();
|
||||
let parsed: Record<string, Record<string, number>>;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<
|
||||
string,
|
||||
Record<string, number>
|
||||
>;
|
||||
} catch {
|
||||
return store;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const [chatId, entry] of Object.entries(parsed)) {
|
||||
const messages = new Map<string, number>();
|
||||
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<string, Map<string, number>>();
|
||||
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,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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<string, Map<string, number>>;
|
||||
type SentMessagePersistentStore = PluginStateSyncKeyedStore<PersistedSentMessage>;
|
||||
|
||||
@@ -34,8 +28,6 @@ type SentMessageState = {
|
||||
bucketsByScope: Map<string, SentMessageBucket>;
|
||||
};
|
||||
|
||||
type SentMessageConfig = Pick<OpenClawConfig, "agents" | "session">;
|
||||
|
||||
function getSentMessageState(): SentMessageState {
|
||||
const globalStore = globalThis as Record<PropertyKey, unknown>;
|
||||
const existing = globalStore[TELEGRAM_SENT_MESSAGES_STATE_KEY] as SentMessageState | undefined;
|
||||
@@ -53,37 +45,6 @@ function createSentMessageStore(): SentMessageStore {
|
||||
return new Map<string, Map<string, number>>();
|
||||
}
|
||||
|
||||
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<PersistedSentMessage>({
|
||||
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<string, Record<string, number>>;
|
||||
const now = Date.now();
|
||||
const store = createSentMessageStore();
|
||||
for (const [chatId, entry] of Object.entries(parsed)) {
|
||||
const messages = new Map<string, number>();
|
||||
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,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
59
extensions/telegram/src/sticker-cache-store.legacy-state.ts
Normal file
59
extensions/telegram/src/sticker-cache-store.legacy-state.ts
Normal file
@@ -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<string, CachedSticker>;
|
||||
}
|
||||
|
||||
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),
|
||||
}));
|
||||
}
|
||||
@@ -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<string, CachedSticker>;
|
||||
}
|
||||
export type { CachedSticker };
|
||||
|
||||
type TelegramStickerCacheStore = PluginStateSyncKeyedStore<CachedSticker>;
|
||||
|
||||
function getCacheFile(): string {
|
||||
return path.join(resolveStateDir(), "telegram", "sticker-cache.json");
|
||||
}
|
||||
|
||||
function openStickerCacheStore(): TelegramStickerCacheStore {
|
||||
return getTelegramRuntime().state.openSyncKeyedStore<CachedSticker>({
|
||||
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<T>(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<string, string>([
|
||||
]);
|
||||
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<string, string> {
|
||||
const dependencies = new Map<string, string>();
|
||||
const visited = new Set<string>();
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"runtime-fetch",
|
||||
"sandbox",
|
||||
"secret-file-runtime",
|
||||
"secret-provider-alias",
|
||||
"secure-random-runtime",
|
||||
"session-binding-runtime",
|
||||
"session-catalog-runtime",
|
||||
|
||||
@@ -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,
|
||||
|
||||
8
src/plugin-sdk/secret-provider-alias.ts
Normal file
8
src/plugin-sdk/secret-provider-alias.ts
Normal file
@@ -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";
|
||||
@@ -52,6 +52,34 @@ const FORBIDDEN_SPECIFIER_RULES = new Map<string, { reason: string; kinds: Set<C
|
||||
kinds: new Set(["doctor-contract"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"openclaw/plugin-sdk/acp-runtime",
|
||||
{
|
||||
reason:
|
||||
"the ACP runtime barrel cold-loads the ACP control-plane manager and backend registry; " +
|
||||
"keep legacy-state row shapes in a plugin-local leaf module",
|
||||
kinds: new Set(["doctor-contract"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"openclaw/plugin-sdk/conversation-runtime",
|
||||
{
|
||||
reason:
|
||||
"the deprecated conversation barrel cold-loads binding-routing and the session-binding registry; " +
|
||||
"keep legacy-state row shapes in a plugin-local leaf module",
|
||||
kinds: new Set(["doctor-contract"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"openclaw/plugin-sdk/provider-auth",
|
||||
{
|
||||
reason:
|
||||
"the provider-auth barrel cold-loads the auth-profile store, provider runtime, and plugin " +
|
||||
"install graph (execa, kysely, commander); use openclaw/plugin-sdk/secret-provider-alias " +
|
||||
"for the default secret provider alias",
|
||||
kinds: new Set(["doctor-contract"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"openclaw/plugin-sdk/channel-secret-basic-runtime",
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectBuiltDoctorContractClosureViolations,
|
||||
listBuiltPluginControlPlaneModules,
|
||||
probeBuiltPluginControlPlaneModules,
|
||||
verifyBuiltPluginControlPlaneModules,
|
||||
@@ -109,3 +110,52 @@ describe("built plugin control-plane module loads", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user