perf(plugins): make every plugin closure statically kysely-free and guard it transitively (#120876)

* perf(plugins): close the last kysely closure chains and guard reachability transitively

Follow-up to #120698/#120811/#120882: the closure guard's enumerated barrel
bans cannot catch new heavy edges, and two closures still statically reached
kysely on main.

- guard: add a transitive kysely-reachability test that walks static value
  imports from every doctor-contract and legacy-setup closure through plugin,
  plugin-sdk, and relative core graphs, failing with the full import chain;
  type-only and lazy dynamic imports stay allowed
- llm-task/model refs: manifest-model-id-normalization reads snapshots
  through a registration-slot runtime bridge (snapshot modules register at
  eval; require fallback covers cold processes) and
  current-plugin-metadata-state moves its process-scoped facts onto a
  globalThis singleton so dual module instances share published state
- telegram: split thread-bindings-store.ts (pure record shapes + legacy-file
  readers) out of the acp-runtime-heavy manager, delete the consumer-less
  testing export, move the pure bot-user-id token parse to
  token-fingerprint.ts, and lazy-import token.js in the async update-offset
  detector

llm-task enumeration drops to ~0.8s/157 modules cold; every closure is now
statically kysely-free and stays that way by construction.

* fix(telegram): repoint the native-command menu state at the token-fingerprint parser
This commit is contained in:
Peter Steinberger
2026-08-09 00:39:45 -07:00
committed by GitHub
parent 4847655826
commit 499d81cdbd
20 changed files with 482 additions and 233 deletions
@@ -2,8 +2,10 @@
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { getOptionalTelegramRuntime } from "./runtime.js";
import { fingerprintTelegramBotToken } from "./token-fingerprint.js";
import { resolveTelegramBotUserIdFromToken } from "./token.js";
import {
fingerprintTelegramBotToken,
resolveTelegramBotUserIdFromToken,
} from "./token-fingerprint.js";
const TELEGRAM_MENU_LOCALE_LEDGER_VERSION = 1;
const TELEGRAM_MENU_LOCALE_LEDGER_NAMESPACE = "telegram.command-menu-locales";
+1 -1
View File
@@ -28,7 +28,7 @@ import {
import type { TelegramApiCallOpts, TelegramSendOpts } from "./send-message-types.js";
import { prepareTelegramOutbound } from "./send-outbound.js";
import { resolveMarkdownTableMode } from "./send.runtime.js";
import { resolveTelegramBotUserIdFromToken } from "./token.js";
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
type TelegramEditMessageTextParams = Parameters<TelegramApiContext["api"]["editMessageText"]>[3];
type TelegramEditMessageCaptionParams = Parameters<
+1 -1
View File
@@ -14,7 +14,7 @@ import {
} from "./send-context.js";
import type { TelegramLocationSendOpts, TelegramSendResult } from "./send-message-types.js";
import { finalizeTelegramOutbound, prepareTelegramOutbound } from "./send-outbound.js";
import { resolveTelegramBotUserIdFromToken } from "./token.js";
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
type TelegramSendLocationParams = Parameters<TelegramApiContext["api"]["sendLocation"]>[3];
type TelegramSendVenueParams = Parameters<TelegramApiContext["api"]["sendVenue"]>[5];
+1 -1
View File
@@ -52,7 +52,7 @@ import {
} from "./send.runtime.js";
import { recordSentMessage } from "./sent-message-cache.js";
import { parseTelegramTarget } from "./targets.js";
import { resolveTelegramBotUserIdFromToken } from "./token.js";
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
const MAX_TELEGRAM_PHOTO_DIMENSION_SUM = 10_000;
const MAX_TELEGRAM_PHOTO_ASPECT_RATIO = 20;
+1 -1
View File
@@ -19,7 +19,7 @@ import type {
import { finalizeTelegramOutbound, prepareTelegramOutbound } from "./send-outbound.js";
import { normalizePollInput, type PollInput } from "./send.runtime.js";
import { parseTelegramTarget } from "./targets.js";
import { resolveTelegramBotUserIdFromToken } from "./token.js";
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
type TelegramSendPollParams = Parameters<TelegramApiContext["api"]["sendPoll"]>[3];
+9 -7
View File
@@ -36,11 +36,10 @@ import {
} from "./sticker-cache-store.js";
import {
listTelegramLegacyThreadBindingEntries,
resolveTelegramThreadBindingsPath,
TELEGRAM_THREAD_BINDINGS_MAX_ENTRIES,
TELEGRAM_THREAD_BINDINGS_NAMESPACE,
testing as telegramThreadBindingTesting,
} from "./thread-bindings.js";
import { resolveTelegramToken } from "./token.js";
} from "./thread-bindings-store.js";
import {
listTelegramLegacyTopicNameCacheEntries,
resolveTopicNameCacheNamespace,
@@ -259,11 +258,14 @@ function detectTelegramBotInfoCacheLegacyStateMigration(params: {
});
}
function detectTelegramUpdateOffsetLegacyStateMigration(params: {
async function detectTelegramUpdateOffsetLegacyStateMigration(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
stateDir?: string;
}): ChannelLegacyStateMigrationPlan[] {
}): Promise<ChannelLegacyStateMigrationPlan[]> {
// token.js pulls provider-auth's config graph; keep it lazy so setup/doctor
// closure cold-load stays light.
const { resolveTelegramToken } = await import("./token.js");
const stateDir = resolveMigrationStateDir(params);
return listTelegramLegacySidecarAccountIds({
cfg: params.cfg,
@@ -388,7 +390,7 @@ function detectTelegramThreadBindingLegacyStateMigration(params: {
prefix: "thread-bindings-",
suffix: ".json",
}).flatMap((accountId) => {
const persistedPath = telegramThreadBindingTesting.resolveBindingsPath(accountId, params.env);
const persistedPath = resolveTelegramThreadBindingsPath(accountId, params.env);
if (!fileExists(persistedPath)) {
return [];
}
@@ -484,7 +486,7 @@ export async function detectTelegramLegacyStateMigrations(params: {
stateDir?: string;
}): Promise<ChannelLegacyStateMigrationPlan[]> {
const plans: ChannelLegacyStateMigrationPlan[] = [];
plans.push(...detectTelegramUpdateOffsetLegacyStateMigration(params));
plans.push(...(await detectTelegramUpdateOffsetLegacyStateMigration(params)));
plans.push(...detectTelegramBotInfoCacheLegacyStateMigration(params));
plans.push(...detectTelegramStickerCacheLegacyStateMigration(params));
plans.push(...detectTelegramMessageCacheLegacyStateMigration(params));
@@ -0,0 +1,159 @@
// Pure Telegram thread-binding record shapes and legacy-file readers, split
// from thread-bindings.ts so doctor/setup closures (state-migrations) never
// load the acp-runtime/session graph the manager runtime needs.
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
export const TELEGRAM_THREAD_BINDINGS_NAMESPACE = "telegram.thread-bindings";
export const TELEGRAM_THREAD_BINDINGS_MAX_ENTRIES = 5_000;
const TELEGRAM_THREAD_BINDINGS_STORE_VERSION = 1;
export type TelegramBindingTargetKind = "subagent" | "acp";
export type TelegramThreadBindingRecord = {
accountId: string;
conversationId: string;
targetKind: TelegramBindingTargetKind;
targetSessionKey: string;
agentId?: string;
label?: string;
boundBy?: string;
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
metadata?: Record<string, unknown>;
};
type StoredTelegramBindingState = {
version: number;
bindings: TelegramThreadBindingRecord[];
};
export function resolveStoredBindingKey(params: {
accountId: string;
conversationId: string;
}): string {
return createHash("sha256")
.update(`${params.accountId}\0${params.conversationId}`, "utf8")
.digest("hex")
.slice(0, 32);
}
export function resolveTelegramThreadBindingsPath(
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): string {
const stateDir = resolveStateDir(env, os.homedir);
return path.join(stateDir, "telegram", `thread-bindings-${accountId}.json`);
}
function normalizeMetadataForStore(
metadata: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!metadata) {
return undefined;
}
const serialized = JSON.stringify(metadata);
if (!serialized) {
return undefined;
}
const parsed = JSON.parse(serialized) as Record<string, unknown>;
return Object.keys(parsed).length > 0 ? parsed : undefined;
}
export function sanitizeStoredBinding(
accountId: string,
entry: Partial<TelegramThreadBindingRecord> | null | undefined,
): TelegramThreadBindingRecord | null {
const conversationId = normalizeOptionalString(entry?.conversationId);
const targetSessionKey = normalizeOptionalString(entry?.targetSessionKey) ?? "";
const targetKind = entry?.targetKind === "subagent" ? "subagent" : "acp";
if (!conversationId || !targetSessionKey) {
return null;
}
const boundAt =
typeof entry?.boundAt === "number" && Number.isFinite(entry.boundAt)
? Math.floor(entry.boundAt)
: Date.now();
const lastActivityAt =
typeof entry?.lastActivityAt === "number" && Number.isFinite(entry.lastActivityAt)
? Math.floor(entry.lastActivityAt)
: boundAt;
const record: TelegramThreadBindingRecord = {
accountId,
conversationId,
targetSessionKey,
targetKind,
boundAt,
lastActivityAt,
};
if (typeof entry?.idleTimeoutMs === "number" && Number.isFinite(entry.idleTimeoutMs)) {
record.idleTimeoutMs = Math.max(0, Math.floor(entry.idleTimeoutMs));
}
if (typeof entry?.maxAgeMs === "number" && Number.isFinite(entry.maxAgeMs)) {
record.maxAgeMs = Math.max(0, Math.floor(entry.maxAgeMs));
}
if (typeof entry?.agentId === "string" && entry.agentId.trim()) {
record.agentId = entry.agentId.trim();
}
if (typeof entry?.label === "string" && entry.label.trim()) {
record.label = entry.label.trim();
}
if (typeof entry?.boundBy === "string" && entry.boundBy.trim()) {
record.boundBy = entry.boundBy.trim();
}
const metadata = normalizeMetadataForStore(
entry?.metadata && typeof entry.metadata === "object" ? { ...entry.metadata } : undefined,
);
if (metadata) {
record.metadata = metadata;
}
return record;
}
function readLegacyBindingsFile(
filePath: string,
accountId: string,
): TelegramThreadBindingRecord[] {
try {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as StoredTelegramBindingState;
if (
parsed?.version !== TELEGRAM_THREAD_BINDINGS_STORE_VERSION ||
!Array.isArray(parsed.bindings)
) {
return [];
}
const bindings: TelegramThreadBindingRecord[] = [];
for (const entry of parsed.bindings) {
const record = sanitizeStoredBinding(accountId, entry);
if (record) {
bindings.push(record);
}
}
return bindings;
} catch (err) {
const code = (err as { code?: string }).code;
if (code !== "ENOENT") {
logVerbose(`telegram thread bindings load failed (${accountId}): ${String(err)}`);
}
return [];
}
}
export function listTelegramLegacyThreadBindingEntries(params: {
accountId: string;
persistedPath?: string;
}): Array<{ key: string; value: TelegramThreadBindingRecord }> {
const bindings = readLegacyBindingsFile(
params.persistedPath ?? resolveTelegramThreadBindingsPath(params.accountId),
params.accountId,
);
return bindings.map((value) => ({ key: resolveStoredBindingKey(value), value }));
}
+15 -13
View File
@@ -29,8 +29,10 @@ vi.mock("openclaw/plugin-sdk/acp-runtime", async () => {
import {
TELEGRAM_THREAD_BINDINGS_MAX_ENTRIES,
TELEGRAM_THREAD_BINDINGS_NAMESPACE,
testing,
} from "./thread-bindings-store.js";
import {
createTelegramThreadBindingManager as createTelegramThreadBindingManagerImpl,
resetTelegramThreadBindingsForTests,
setTelegramThreadBindingIdleTimeoutBySessionKey,
setTelegramThreadBindingMaxAgeBySessionKey,
} from "./thread-bindings.js";
@@ -111,12 +113,12 @@ describe("telegram thread bindings", () => {
"openclaw/plugin-sdk/acp-runtime",
);
readAcpSessionEntryMock.mockImplementation(acpRuntime.readAcpSessionEntry);
await testing.resetTelegramThreadBindingsForTests();
await resetTelegramThreadBindingsForTests();
});
afterEach(async () => {
vi.useRealTimers();
await testing.resetTelegramThreadBindingsForTests();
await resetTelegramThreadBindingsForTests();
clearTelegramRuntimeForTest();
resetPluginStateStoreForTests();
await openClawState.cleanup();
@@ -211,7 +213,7 @@ describe("telegram thread bindings", () => {
import.meta.url,
"./thread-bindings.js?scope=shared-b",
);
await bindingsA.testing.resetTelegramThreadBindingsForTests();
await bindingsA.resetTelegramThreadBindingsForTests();
try {
const managerA = bindingsA.createTelegramThreadBindingManager({
@@ -246,7 +248,7 @@ describe("telegram thread bindings", () => {
?.getByConversationId("-100200300:topic:44")?.targetSessionKey,
).toBe("agent:main:subagent:child-shared");
} finally {
await bindingsA.testing.resetTelegramThreadBindingsForTests();
await bindingsA.resetTelegramThreadBindingsForTests();
}
});
@@ -354,7 +356,7 @@ describe("telegram thread bindings", () => {
reason: "test-detach",
});
await testing.resetTelegramThreadBindingsForTests();
await resetTelegramThreadBindingsForTests();
const reloaded = createTelegramThreadBindingManager({
accountId: "default",
@@ -439,7 +441,7 @@ describe("telegram thread bindings", () => {
},
});
await testing.resetTelegramThreadBindingsForTests();
await resetTelegramThreadBindingsForTests();
const reloaded = createTelegramThreadBindingManager({
accountId: "metadata",
@@ -491,7 +493,7 @@ describe("telegram thread bindings", () => {
},
});
await testing.resetTelegramThreadBindingsForTests();
await resetTelegramThreadBindingsForTests();
readAcpSessionEntryMock.mockReturnValue({
cfg: {} as never,
storePath: "/tmp/acp-store.json",
@@ -509,7 +511,7 @@ describe("telegram thread bindings", () => {
});
expect(reloaded.getByConversationId("cleanup-me")).toBeUndefined();
await testing.resetTelegramThreadBindingsForTests();
await resetTelegramThreadBindingsForTests();
expect(storedBindings().map((binding) => binding.conversationId)).not.toContain("cleanup-me");
});
@@ -530,7 +532,7 @@ describe("telegram thread bindings", () => {
},
});
await testing.resetTelegramThreadBindingsForTests();
await resetTelegramThreadBindingsForTests();
const reloaded = createTelegramThreadBindingManager({
accountId: "default",
@@ -561,7 +563,7 @@ describe("telegram thread bindings", () => {
},
});
await testing.resetTelegramThreadBindingsForTests();
await resetTelegramThreadBindingsForTests();
readAcpSessionEntryMock.mockReturnValue({
cfg: {} as never,
storePath: "/tmp/acp-store.json",
@@ -609,7 +611,7 @@ describe("telegram thread bindings", () => {
idleTimeoutMs: 90_000,
});
await testing.resetTelegramThreadBindingsForTests();
await resetTelegramThreadBindingsForTests();
expect(
storedBindings().find((binding) => binding.accountId === "persist-reset")?.idleTimeoutMs,
@@ -648,7 +650,7 @@ describe("telegram thread bindings", () => {
});
manager.touchConversation("-100200300:topic:100");
await testing.resetTelegramThreadBindingsForTests();
await resetTelegramThreadBindingsForTests();
await flushMicrotasks();
expect(unhandled).toStrictEqual([]);
} finally {
+8 -150
View File
@@ -1,8 +1,4 @@
// Telegram plugin module implements thread bindings behavior.
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readAcpSessionEntry } from "openclaw/plugin-sdk/acp-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
@@ -20,41 +16,22 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { normalizeAccountId, isAcpSessionKey } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getTelegramRuntime } from "./runtime.js";
import { loadTelegramSendModule } from "./send-runtime.js";
import {
resolveStoredBindingKey,
sanitizeStoredBinding,
TELEGRAM_THREAD_BINDINGS_MAX_ENTRIES,
TELEGRAM_THREAD_BINDINGS_NAMESPACE,
type TelegramBindingTargetKind,
type TelegramThreadBindingRecord,
} from "./thread-bindings-store.js";
import { resolveTelegramToken } from "./token.js";
const DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000;
const DEFAULT_THREAD_BINDING_MAX_AGE_MS = 0;
const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 60_000;
const STORE_VERSION = 1;
export const TELEGRAM_THREAD_BINDINGS_NAMESPACE = "telegram.thread-bindings";
export const TELEGRAM_THREAD_BINDINGS_MAX_ENTRIES = 5_000;
type TelegramBindingTargetKind = "subagent" | "acp";
type TelegramThreadBindingRecord = {
accountId: string;
conversationId: string;
targetKind: TelegramBindingTargetKind;
targetSessionKey: string;
agentId?: string;
label?: string;
boundBy?: string;
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
metadata?: Record<string, unknown>;
};
type StoredTelegramBindingState = {
version: number;
bindings: TelegramThreadBindingRecord[];
};
type TelegramThreadBindingStore = PluginStateSyncKeyedStore<TelegramThreadBindingRecord>;
type TelegramThreadBindingManager = {
@@ -118,13 +95,6 @@ function resolveBindingKey(params: { accountId: string; conversationId: string }
return `${params.accountId}:${params.conversationId}`;
}
function resolveStoredBindingKey(params: { accountId: string; conversationId: string }): string {
return createHash("sha256")
.update(`${params.accountId}\0${params.conversationId}`, "utf8")
.digest("hex")
.slice(0, 32);
}
function openThreadBindingStore(): TelegramThreadBindingStore {
return getTelegramRuntime().state.openSyncKeyedStore<TelegramThreadBindingRecord>({
namespace: TELEGRAM_THREAD_BINDINGS_NAMESPACE,
@@ -239,25 +209,6 @@ function fromSessionBindingInput(params: {
return record;
}
function resolveBindingsPath(accountId: string, env: NodeJS.ProcessEnv = process.env): string {
const stateDir = resolveStateDir(env, os.homedir);
return path.join(stateDir, "telegram", `thread-bindings-${accountId}.json`);
}
function normalizeMetadataForStore(
metadata: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!metadata) {
return undefined;
}
const serialized = JSON.stringify(metadata);
if (!serialized) {
return undefined;
}
const parsed = JSON.parse(serialized) as Record<string, unknown>;
return Object.keys(parsed).length > 0 ? parsed : undefined;
}
function summarizeLifecycleForLog(
record: TelegramThreadBindingRecord,
defaults: {
@@ -273,83 +224,6 @@ function summarizeLifecycleForLog(
return `idle=${idleLabel} maxAge=${maxAgeLabel}`;
}
function sanitizeStoredBinding(
accountId: string,
entry: Partial<TelegramThreadBindingRecord> | null | undefined,
): TelegramThreadBindingRecord | null {
const conversationId = normalizeOptionalString(entry?.conversationId);
const targetSessionKey = normalizeOptionalString(entry?.targetSessionKey) ?? "";
const targetKind = entry?.targetKind === "subagent" ? "subagent" : "acp";
if (!conversationId || !targetSessionKey) {
return null;
}
const boundAt =
typeof entry?.boundAt === "number" && Number.isFinite(entry.boundAt)
? Math.floor(entry.boundAt)
: Date.now();
const lastActivityAt =
typeof entry?.lastActivityAt === "number" && Number.isFinite(entry.lastActivityAt)
? Math.floor(entry.lastActivityAt)
: boundAt;
const record: TelegramThreadBindingRecord = {
accountId,
conversationId,
targetSessionKey,
targetKind,
boundAt,
lastActivityAt,
};
if (typeof entry?.idleTimeoutMs === "number" && Number.isFinite(entry.idleTimeoutMs)) {
record.idleTimeoutMs = Math.max(0, Math.floor(entry.idleTimeoutMs));
}
if (typeof entry?.maxAgeMs === "number" && Number.isFinite(entry.maxAgeMs)) {
record.maxAgeMs = Math.max(0, Math.floor(entry.maxAgeMs));
}
if (typeof entry?.agentId === "string" && entry.agentId.trim()) {
record.agentId = entry.agentId.trim();
}
if (typeof entry?.label === "string" && entry.label.trim()) {
record.label = entry.label.trim();
}
if (typeof entry?.boundBy === "string" && entry.boundBy.trim()) {
record.boundBy = entry.boundBy.trim();
}
const metadata = normalizeMetadataForStore(
entry?.metadata && typeof entry.metadata === "object" ? { ...entry.metadata } : undefined,
);
if (metadata) {
record.metadata = metadata;
}
return record;
}
function readLegacyBindingsFile(
filePath: string,
accountId: string,
): TelegramThreadBindingRecord[] {
try {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as StoredTelegramBindingState;
if (parsed?.version !== STORE_VERSION || !Array.isArray(parsed.bindings)) {
return [];
}
const bindings: TelegramThreadBindingRecord[] = [];
for (const entry of parsed.bindings) {
const record = sanitizeStoredBinding(accountId, entry);
if (record) {
bindings.push(record);
}
}
return bindings;
} catch (err) {
const code = (err as { code?: string }).code;
if (code !== "ENOENT") {
logVerbose(`telegram thread bindings load failed (${accountId}): ${String(err)}`);
}
return [];
}
}
function loadBindingsFromStore(accountId: string): TelegramThreadBindingRecord[] {
let store: TelegramThreadBindingStore;
try {
@@ -918,20 +792,4 @@ export function resetTelegramThreadBindingsForTests(): Promise<void> {
return Promise.resolve();
}
export function listTelegramLegacyThreadBindingEntries(params: {
accountId: string;
persistedPath?: string;
}): Array<{ key: string; value: TelegramThreadBindingRecord }> {
const bindings = readLegacyBindingsFile(
params.persistedPath ?? resolveBindingsPath(params.accountId),
params.accountId,
);
return bindings.map((value) => ({ key: resolveStoredBindingKey(value), value }));
}
export const testing = {
resetTelegramThreadBindingsForTests,
resolveBindingsPath,
resolveStoredBindingKey,
};
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
@@ -1,5 +1,6 @@
// Telegram plugin module implements token fingerprint behavior.
import { createHash } from "node:crypto";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
/**
* Derive a short, non-reversible fingerprint of a Telegram bot token suitable
@@ -11,3 +12,12 @@ import { createHash } from "node:crypto";
export function fingerprintTelegramBotToken(token: string): string {
return createHash("sha256").update(token).digest("hex").slice(0, 16);
}
/** Parse the numeric bot user id prefix from a Telegram bot token. */
export function resolveTelegramBotUserIdFromToken(token?: string): number | undefined {
const rawBotId = token?.trim().split(":", 1)[0];
if (!rawBotId || !/^\d+$/.test(rawBotId)) {
return undefined;
}
return parseStrictPositiveInteger(rawBotId);
}
+2 -1
View File
@@ -4,7 +4,8 @@ import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveTelegramBotUserIdFromToken, resolveTelegramToken } from "./token.js";
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
import { resolveTelegramToken } from "./token.js";
describe("resolveTelegramBotUserIdFromToken", () => {
it.each([
-9
View File
@@ -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 { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
import {
DEFAULT_ACCOUNT_ID,
@@ -29,14 +28,6 @@ export type TelegramTokenResolution = BaseTokenResolution & {
credentialDiagnostics?: CredentialUnavailableDiagnostic[];
};
export function resolveTelegramBotUserIdFromToken(token?: string): number | undefined {
const rawBotId = token?.trim().split(":", 1)[0];
if (!rawBotId || !/^\d+$/.test(rawBotId)) {
return undefined;
}
return parseStrictPositiveInteger(rawBotId);
}
type RuntimeTokenValueResolution =
| { status: "available"; value: string }
| { status: "configured_unavailable" }
@@ -4,7 +4,7 @@ import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-run
import { getTelegramRuntime } from "./runtime.js";
import { normalizeTelegramStateAccountId } from "./state-account-id.js";
import { fingerprintTelegramBotToken } from "./token-fingerprint.js";
import { resolveTelegramBotUserIdFromToken } from "./token.js";
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
const STORE_VERSION = 3;
export const TELEGRAM_UPDATE_OFFSET_NAMESPACE = "telegram.update-offsets";
@@ -13,6 +13,7 @@ import {
resolvePluginControlPlaneFingerprint,
type ResolvePluginControlPlaneContextParams,
} from "./plugin-control-plane-context.js";
import { registerPluginMetadataSnapshotReaders } from "./plugin-metadata-snapshot.runtime.js";
import type {
PluginMetadataSnapshot,
PluginMetadataSnapshotPluginIdScope,
@@ -416,3 +417,8 @@ export function getCurrentPluginMetadataSnapshot(
params,
);
}
// Light bridges (plugin-metadata-snapshot.runtime.ts) serve reads through this
// instance whenever the metadata system is loaded; the require fallback only
// covers cold processes.
registerPluginMetadataSnapshotReaders({ getCurrentPluginMetadataSnapshot });
+55 -42
View File
@@ -4,37 +4,54 @@ import {
type ManifestModelIdNormalizationRecord,
} from "@openclaw/model-catalog-core/provider-model-id-normalization";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
let currentPluginMetadataSnapshot: unknown;
let currentPluginMetadataSnapshotConfigFingerprint: string | undefined;
let currentPluginMetadataSnapshotCompatiblePolicyHashes: readonly string[] | undefined;
let currentPluginMetadataSnapshotCompatibleConfigFingerprints: readonly string[] | undefined;
let currentManifestModelIdNormalizationRecords:
| readonly ManifestModelIdNormalizationRecord[]
| undefined;
// Temporary snapshot owners compare this publication token before restoring;
// lifecycle clears and newer publications must always win.
let currentPluginMetadataSnapshotRevision = Symbol("plugin-metadata-snapshot");
let currentPluginMetadataConfigIdentities = new WeakSet<OpenClawConfig>();
export type CurrentPluginMetadataSnapshotRevision = symbol;
export type CurrentPluginMetadataSnapshotRevision = typeof currentPluginMetadataSnapshotRevision;
type CurrentPluginMetadataMutableState = {
snapshot: unknown;
configFingerprint: string | undefined;
compatiblePolicyHashes: readonly string[] | undefined;
compatibleConfigFingerprints: readonly string[] | undefined;
manifestModelIdNormalizationRecords: readonly ManifestModelIdNormalizationRecord[] | undefined;
// Temporary snapshot owners compare this publication token before restoring;
// lifecycle clears and newer publications must always win.
revision: CurrentPluginMetadataSnapshotRevision;
configIdentities: WeakSet<OpenClawConfig>;
};
// Process-scoped facts must survive dual module instances (ESM plus the lazy
// require bridge in plugin-metadata-snapshot.runtime.ts), so the state lives
// on a globalThis singleton like the scoped snapshot ALS.
const state = resolveGlobalSingleton<CurrentPluginMetadataMutableState>(
Symbol.for("openclaw.currentPluginMetadataState"),
() => ({
snapshot: undefined,
configFingerprint: undefined,
compatiblePolicyHashes: undefined,
compatibleConfigFingerprints: undefined,
manifestModelIdNormalizationRecords: undefined,
revision: Symbol("plugin-metadata-snapshot"),
configIdentities: new WeakSet<OpenClawConfig>(),
}),
);
/** Owns config identity reuse for the current immutable metadata snapshot. */
export const currentPluginMetadataConfigIdentityCache = {
add(config: OpenClawConfig): void {
currentPluginMetadataConfigIdentities.add(config);
state.configIdentities.add(config);
},
capture(): WeakSet<OpenClawConfig> {
return currentPluginMetadataConfigIdentities;
return state.configIdentities;
},
clear(): void {
currentPluginMetadataConfigIdentities = new WeakSet();
state.configIdentities = new WeakSet();
},
has(config: OpenClawConfig): boolean {
return currentPluginMetadataConfigIdentities.has(config);
return state.configIdentities.has(config);
},
restore(identities: WeakSet<OpenClawConfig>): void {
currentPluginMetadataConfigIdentities = identities;
state.configIdentities = identities;
},
};
@@ -46,32 +63,28 @@ export function setCurrentPluginMetadataSnapshotState(
compatibleConfigFingerprints?: readonly string[],
manifestModelIdNormalizationRecords?: readonly ManifestModelIdNormalizationRecord[],
): CurrentPluginMetadataSnapshotRevision {
currentPluginMetadataSnapshot = snapshot;
currentPluginMetadataSnapshotConfigFingerprint = snapshot ? configFingerprint : undefined;
currentPluginMetadataSnapshotCompatiblePolicyHashes = snapshot
? compatiblePolicyHashes
: undefined;
currentPluginMetadataSnapshotCompatibleConfigFingerprints = snapshot
? compatibleConfigFingerprints
: undefined;
currentManifestModelIdNormalizationRecords = snapshot
state.snapshot = snapshot;
state.configFingerprint = snapshot ? configFingerprint : undefined;
state.compatiblePolicyHashes = snapshot ? compatiblePolicyHashes : undefined;
state.compatibleConfigFingerprints = snapshot ? compatibleConfigFingerprints : undefined;
state.manifestModelIdNormalizationRecords = snapshot
? manifestModelIdNormalizationRecords
: undefined;
setCurrentManifestModelIdNormalizationRecords(currentManifestModelIdNormalizationRecords);
currentPluginMetadataSnapshotRevision = Symbol("plugin-metadata-snapshot");
return currentPluginMetadataSnapshotRevision;
setCurrentManifestModelIdNormalizationRecords(state.manifestModelIdNormalizationRecords);
state.revision = Symbol("plugin-metadata-snapshot");
return state.revision;
}
/** Clears the process-current plugin metadata snapshot. */
function clearCurrentPluginMetadataSnapshotState(): CurrentPluginMetadataSnapshotRevision {
currentPluginMetadataSnapshot = undefined;
currentPluginMetadataSnapshotConfigFingerprint = undefined;
currentPluginMetadataSnapshotCompatiblePolicyHashes = undefined;
currentPluginMetadataSnapshotCompatibleConfigFingerprints = undefined;
currentManifestModelIdNormalizationRecords = undefined;
state.snapshot = undefined;
state.configFingerprint = undefined;
state.compatiblePolicyHashes = undefined;
state.compatibleConfigFingerprints = undefined;
state.manifestModelIdNormalizationRecords = undefined;
setCurrentManifestModelIdNormalizationRecords(undefined);
currentPluginMetadataSnapshotRevision = Symbol("plugin-metadata-snapshot");
return currentPluginMetadataSnapshotRevision;
state.revision = Symbol("plugin-metadata-snapshot");
return state.revision;
}
/** Clears the snapshot, its identity cache, and process-wide model normalization. */
@@ -90,11 +103,11 @@ export function getCurrentPluginMetadataSnapshotState(): {
revision: CurrentPluginMetadataSnapshotRevision;
} {
return {
snapshot: currentPluginMetadataSnapshot,
configFingerprint: currentPluginMetadataSnapshotConfigFingerprint,
compatiblePolicyHashes: currentPluginMetadataSnapshotCompatiblePolicyHashes,
compatibleConfigFingerprints: currentPluginMetadataSnapshotCompatibleConfigFingerprints,
manifestModelIdNormalizationRecords: currentManifestModelIdNormalizationRecords,
revision: currentPluginMetadataSnapshotRevision,
snapshot: state.snapshot,
configFingerprint: state.configFingerprint,
compatiblePolicyHashes: state.compatiblePolicyHashes,
compatibleConfigFingerprints: state.compatibleConfigFingerprints,
manifestModelIdNormalizationRecords: state.manifestModelIdNormalizationRecords,
revision: state.revision,
};
}
@@ -363,6 +363,83 @@ function collectHeavyRuntimeDoctorMigrationImports(): string[] {
return violations;
}
const PLUGIN_SDK_SPECIFIER_PREFIX = "openclaw/plugin-sdk/";
function isKyselySpecifier(specifier: string): boolean {
return specifier === "kysely" || specifier.startsWith("kysely/");
}
// The transitive walk follows relative imports (plugin-local, core src, and the
// deep-relative package bridges) plus openclaw/plugin-sdk/* subpaths. Other bare
// specifiers are node builtins or npm/workspace packages; only the repo root
// depends on kysely, so every kysely edge is reachable through this resolution.
function collectTraversalValueReferences(filePath: string, source: string): ModuleReference[] {
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
const staticValueReferenceKeys = collectStaticValueReferenceKeys(sourceFile);
return collectModuleReferencesFromSource(source, {
fileName: filePath,
acceptSpecifier: (specifier) =>
isKyselySpecifier(specifier) ||
specifier.startsWith(".") ||
specifier.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX),
}).filter((reference) =>
staticValueReferenceKeys.has(`${reference.kind}\0${reference.line}\0${reference.specifier}`),
);
}
function resolveTraversalModule(filePath: string, specifier: string): string | null {
if (specifier.startsWith(".")) {
return resolveRelativeSourceModule(filePath, specifier);
}
if (specifier.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX)) {
const subpath = specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length);
return resolveRelativeSourceModule(
path.join(REPO_ROOT, "src/plugin-sdk", "entrypoint-anchor.ts"),
`./${subpath}`,
);
}
return null;
}
// Cached per file: null = kysely unreachable; otherwise the first found import
// chain from this file to a kysely value import (repo-relative, entry first).
const kyselyReachabilityByFile = new Map<string, string[] | null>();
function findKyselyChain(filePath: string, inProgress: Set<string>): string[] | null {
const cached = kyselyReachabilityByFile.get(filePath);
if (cached !== undefined) {
return cached;
}
if (inProgress.has(filePath)) {
// Cycle back-edge: the ancestor still explores its remaining children, so
// skipping here cannot hide a kysely edge.
return null;
}
inProgress.add(filePath);
let chain: string[] | null = null;
const source = fs.readFileSync(filePath, "utf8");
for (const reference of collectTraversalValueReferences(filePath, source)) {
if (isKyselySpecifier(reference.specifier)) {
chain = [`${formatRepoPath(filePath)}:${reference.line} imports ${reference.specifier}`];
break;
}
const resolvedPath = resolveTraversalModule(filePath, reference.specifier);
if (!resolvedPath || !isInsideRoot(REPO_ROOT, resolvedPath)) {
continue;
}
const childChain = findKyselyChain(resolvedPath, inProgress);
if (childChain) {
chain = [`${formatRepoPath(filePath)}:${reference.line} -> ${reference.specifier}`].concat(
childChain,
);
break;
}
}
inProgress.delete(filePath);
kyselyReachabilityByFile.set(filePath, chain);
return chain;
}
describe("doctor contract import closures", () => {
it("classifies only static value module edges", () => {
const source = [
@@ -390,4 +467,19 @@ describe("doctor contract import closures", () => {
it("keeps the runtime doctor migration helper off state DB and plugin-state graphs", () => {
expect(collectHeavyRuntimeDoctorMigrationImports()).toStrictEqual([]);
});
// The exhaustive backstop: no doctor-contract or legacy-setup closure may reach
// the kysely package through any static value import chain, regardless of which
// barrel introduces the edge. Type-only and dynamic imports stay allowed.
it("keeps kysely statically unreachable from every plugin closure", () => {
const violations = collectClosureEntries()
.flatMap((entry) => {
const chain = findKyselyChain(entry.entryPath, new Set());
return chain
? [`${entry.pluginId}: closure reaches kysely via\n ${chain.join("\n ")}`]
: [];
})
.toSorted();
expect(violations).toStrictEqual([]);
});
});
@@ -7,6 +7,10 @@ import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/e
import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js";
import { listOpenClawPluginManifestMetadata } from "./manifest-metadata-scan.js";
import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js";
// Registers the snapshot resolver in the runtime bridge slot. Production and
// jiti load it via the bridge's require fallback; vitest workers lack a CJS TS
// hook, so the no-snapshot fallback path needs the ESM registration.
import "./plugin-metadata-snapshot.js";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
import { resetPluginRuntimeStateForTest } from "./runtime.js";
+12 -4
View File
@@ -4,10 +4,15 @@ import {
normalizeProviderModelIdWithPolicies,
} from "@openclaw/model-catalog-core/provider-model-id-normalization";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import type { PluginManifestModelIdNormalizationProvider } from "./manifest.js";
import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
// Snapshot reads go through the registration-slot bridge so this module stays
// off the control-plane/kysely graph; doctor closures cold-load it via
// parseModelRef consumers.
import {
getCurrentPluginMetadataSnapshotRuntime,
resolvePluginMetadataSnapshotRuntime,
} from "./plugin-metadata-snapshot.runtime.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-workspace-state.js";
type ManifestModelIdNormalizationLookupParams = {
@@ -34,7 +39,7 @@ function resolveMetadataSnapshotForPolicies(
const env = params.env ?? process.env;
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
if (params.config === undefined) {
const currentSnapshot = getCurrentPluginMetadataSnapshot({
const currentSnapshot = getCurrentPluginMetadataSnapshotRuntime({
env,
workspaceDir,
allowWorkspaceScopedSnapshot: true,
@@ -48,12 +53,15 @@ function resolveMetadataSnapshotForPolicies(
};
}
}
const snapshot = resolvePluginMetadataSnapshot({
const snapshot = resolvePluginMetadataSnapshotRuntime({
config: params.config ?? {},
env,
workspaceDir,
allowWorkspaceScopedCurrent: true,
});
if (!snapshot) {
return { plugins: [], cacheable: false };
}
return {
plugins: snapshot.plugins,
configFingerprint: snapshot.configFingerprint,
@@ -0,0 +1,95 @@
/**
* Lazy bridge for plugin metadata snapshot reads. The snapshot modules pull
* the control-plane context (installed-plugin index/kysely), which light
* shared modules and doctor closures must not cold-load at import time.
*
* The snapshot module registers its reader here at eval time, so any process
* that published or scoped a snapshot serves reads through the registered
* instance. The require fallback only covers cold processes that never loaded
* the metadata system (built code loads .js; source/jiti paths resolve .ts).
*/
import { createRequire } from "node:module";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
type CurrentSnapshotModule = Pick<
typeof import("./current-plugin-metadata-snapshot.js"),
"getCurrentPluginMetadataSnapshot"
>;
type SnapshotLoaderModule = Pick<
typeof import("./plugin-metadata-snapshot.js"),
"resolvePluginMetadataSnapshot"
>;
type SnapshotReaderSlot = {
getCurrentPluginMetadataSnapshot?: CurrentSnapshotModule["getCurrentPluginMetadataSnapshot"];
resolvePluginMetadataSnapshot?: SnapshotLoaderModule["resolvePluginMetadataSnapshot"];
};
// globalThis-keyed so a require-loaded second module instance shares the slot.
const snapshotReaderSlot = resolveGlobalSingleton<SnapshotReaderSlot>(
Symbol.for("openclaw.pluginMetadataSnapshotReaders"),
() => ({}),
);
/** Called by the snapshot modules at eval time; last registration wins. */
export function registerPluginMetadataSnapshotReaders(readers: SnapshotReaderSlot): void {
Object.assign(snapshotReaderSlot, readers);
}
const require = createRequire(import.meta.url);
function createModuleLoader(candidates: readonly string[]): () => unknown {
let loaded: unknown;
let attempted = false;
return () => {
if (loaded) {
return loaded;
}
if (attempted) {
return null;
}
attempted = true;
for (const candidate of candidates) {
try {
loaded = require(candidate);
return loaded;
} catch {
// Try source/runtime candidates in order.
}
}
return null;
};
}
const loadCurrentSnapshotModule = createModuleLoader([
"./current-plugin-metadata-snapshot.js",
"./current-plugin-metadata-snapshot.ts",
]) as () => CurrentSnapshotModule | null;
const loadSnapshotLoaderModule = createModuleLoader([
"./plugin-metadata-snapshot.js",
"./plugin-metadata-snapshot.ts",
]) as () => SnapshotLoaderModule | null;
/** Reads the current plugin metadata snapshot, loading the snapshot graph lazily. */
export function getCurrentPluginMetadataSnapshotRuntime(
params: Parameters<CurrentSnapshotModule["getCurrentPluginMetadataSnapshot"]>[0],
): ReturnType<CurrentSnapshotModule["getCurrentPluginMetadataSnapshot"]> {
const reader =
snapshotReaderSlot.getCurrentPluginMetadataSnapshot ??
loadCurrentSnapshotModule()?.getCurrentPluginMetadataSnapshot;
return reader?.(params) ?? undefined;
}
/**
* Resolves a plugin metadata snapshot, or undefined when the metadata system
* is unavailable (cold test workers without a CJS TS hook); callers treat that
* as "no manifest policies exist".
*/
export function resolvePluginMetadataSnapshotRuntime(
params: Parameters<SnapshotLoaderModule["resolvePluginMetadataSnapshot"]>[0],
): ReturnType<SnapshotLoaderModule["resolvePluginMetadataSnapshot"]> | undefined {
const resolver =
snapshotReaderSlot.resolvePluginMetadataSnapshot ??
loadSnapshotLoaderModule()?.resolvePluginMetadataSnapshot;
return resolver?.(params);
}
+6
View File
@@ -16,6 +16,7 @@ import {
import type { PluginManifestRecord } from "./manifest-registry.js";
import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js";
import { buildPluginMetadataProviderFacts } from "./plugin-metadata-provider-facts.js";
import { registerPluginMetadataSnapshotReaders } from "./plugin-metadata-snapshot.runtime.js";
import type {
LoadPluginMetadataSnapshotParams,
PluginMetadataSnapshot,
@@ -425,3 +426,8 @@ function loadPluginMetadataSnapshotImpl(
discovery: registryResult.discovery,
};
}
// Light bridges (plugin-metadata-snapshot.runtime.ts) serve loads through this
// instance whenever the metadata system is loaded; the require fallback only
// covers cold processes.
registerPluginMetadataSnapshotReaders({ resolvePluginMetadataSnapshot });