mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
081a565cba
* 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.
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
#!/usr/bin/env node
|
|
|
|
// Verifies built plugin control-plane artifacts through Node's native require(esm) path.
|
|
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import ts from "typescript";
|
|
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
|
|
|
// The live-updater fixture copies this script without workspace packages.
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
type BuiltPluginControlPlaneModule = {
|
|
pluginId: string;
|
|
kind: string;
|
|
relativePath: string;
|
|
};
|
|
|
|
type BuiltPluginControlPlaneModuleFailure = BuiltPluginControlPlaneModule & {
|
|
error: string;
|
|
};
|
|
|
|
type BuiltDoctorContractClosureViolation = BuiltPluginControlPlaneModule & {
|
|
dependency: string;
|
|
importerPath: string;
|
|
};
|
|
|
|
type ProbeParams = {
|
|
rootDir?: string;
|
|
timeoutMs?: number;
|
|
};
|
|
|
|
const ROOT = resolveRepoRoot(import.meta.url);
|
|
const DIRECT_CONTRACT_FILES = ["contract-api.js", "doctor-contract-api.js"];
|
|
const LEGACY_SETUP_PROPERTIES = new Map<string, string>([
|
|
["legacyStateMigrations", "channel-legacy-state-migrations"],
|
|
["legacySessionSurface", "channel-legacy-session-surface"],
|
|
["legacySessionSurfaces", "channel-legacy-session-surface"],
|
|
]);
|
|
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");
|
|
const targets = JSON.parse(Buffer.from(process.argv[1], "base64url").toString("utf8"));
|
|
const requireFromRoot = createRequire(path.join(process.cwd(), "package.json"));
|
|
const failures = [];
|
|
for (const target of targets) {
|
|
try {
|
|
requireFromRoot(path.resolve(process.cwd(), target.relativePath));
|
|
} catch (error) {
|
|
failures.push({
|
|
...target,
|
|
error: error instanceof Error ? (error.stack || error.message) : String(error),
|
|
});
|
|
}
|
|
}
|
|
process.stdout.write("\n${PROBE_RESULT_MARKER}" + JSON.stringify({ failures }));
|
|
`;
|
|
|
|
function propertyNameText(name: ts.PropertyName) {
|
|
return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : "";
|
|
}
|
|
|
|
function listLegacySetupModuleSpecifiers(setupEntryPath: string) {
|
|
const source = fs.readFileSync(setupEntryPath, "utf8");
|
|
const sourceFile = ts.createSourceFile(setupEntryPath, source, ts.ScriptTarget.Latest, true);
|
|
const specifiers: Array<{ kind: string; specifier: string }> = [];
|
|
const visit = (node: ts.Node): void => {
|
|
if (ts.isPropertyAssignment(node) && ts.isObjectLiteralExpression(node.initializer)) {
|
|
const kind = LEGACY_SETUP_PROPERTIES.get(propertyNameText(node.name));
|
|
if (kind) {
|
|
const specifierProperty = node.initializer.properties.find(
|
|
(property) =>
|
|
ts.isPropertyAssignment(property) && propertyNameText(property.name) === "specifier",
|
|
);
|
|
if (
|
|
specifierProperty &&
|
|
ts.isPropertyAssignment(specifierProperty) &&
|
|
ts.isStringLiteralLike(specifierProperty.initializer)
|
|
) {
|
|
specifiers.push({ kind, specifier: specifierProperty.initializer.text });
|
|
}
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sourceFile);
|
|
return specifiers;
|
|
}
|
|
|
|
/** Lists exact built doctor, contract, and channel legacy migration artifacts. */
|
|
export function listBuiltPluginControlPlaneModules(params: { rootDir?: string } = {}) {
|
|
const rootDir = path.resolve(params.rootDir ?? ROOT);
|
|
const extensionsDir = path.join(rootDir, "dist", "extensions");
|
|
if (!fs.existsSync(extensionsDir)) {
|
|
return [];
|
|
}
|
|
const modules = new Map<string, BuiltPluginControlPlaneModule>();
|
|
for (const entry of fs
|
|
.readdirSync(extensionsDir, { withFileTypes: true })
|
|
.filter((candidate) => candidate.isDirectory())
|
|
.toSorted((left, right) => left.name.localeCompare(right.name))) {
|
|
const pluginId = entry.name;
|
|
const pluginDir = path.join(extensionsDir, pluginId);
|
|
for (const fileName of DIRECT_CONTRACT_FILES) {
|
|
const modulePath = path.join(pluginDir, fileName);
|
|
if (fs.existsSync(modulePath)) {
|
|
const relativePath = path.relative(rootDir, modulePath).split(path.sep).join("/");
|
|
modules.set(relativePath, {
|
|
pluginId,
|
|
kind: fileName === "doctor-contract-api.js" ? "doctor-contract" : "contract",
|
|
relativePath,
|
|
});
|
|
}
|
|
}
|
|
const setupEntryPath = path.join(pluginDir, "setup-entry.js");
|
|
if (!fs.existsSync(setupEntryPath)) {
|
|
continue;
|
|
}
|
|
for (const { kind, specifier } of listLegacySetupModuleSpecifiers(setupEntryPath)) {
|
|
const modulePath = path.resolve(pluginDir, specifier);
|
|
const pluginRelativePath = path.relative(pluginDir, modulePath);
|
|
if (pluginRelativePath.startsWith(`..${path.sep}`) || path.isAbsolute(pluginRelativePath)) {
|
|
throw new Error(`${pluginId} setup entry module escapes the plugin root: ${specifier}`);
|
|
}
|
|
const relativePath = path.relative(rootDir, modulePath).split(path.sep).join("/");
|
|
modules.set(relativePath, { pluginId, kind, relativePath });
|
|
}
|
|
}
|
|
return [...modules.values()].toSorted((left, right) =>
|
|
left.relativePath.localeCompare(right.relativePath),
|
|
);
|
|
}
|
|
|
|
/** Loads every selected artifact in one timeout-bounded native-require child. */
|
|
export function probeBuiltPluginControlPlaneModules(
|
|
modules: BuiltPluginControlPlaneModule[],
|
|
params: ProbeParams = {},
|
|
) {
|
|
if (modules.length === 0) {
|
|
return [];
|
|
}
|
|
const rootDir = path.resolve(params.rootDir ?? ROOT);
|
|
const encodedTargets = Buffer.from(JSON.stringify(modules), "utf8").toString("base64url");
|
|
const result = spawnSync(process.execPath, ["-e", REQUIRE_PROBE_SOURCE, encodedTargets], {
|
|
cwd: rootDir,
|
|
encoding: "utf8",
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
timeout: params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
});
|
|
if (result.error) {
|
|
throw new Error(
|
|
`built plugin control-plane native-require probe failed: ${result.error.message}`,
|
|
);
|
|
}
|
|
const markerIndex = result.stdout.lastIndexOf(PROBE_RESULT_MARKER);
|
|
if (markerIndex < 0) {
|
|
throw new Error(
|
|
`built plugin control-plane native-require probe exited ${String(result.status)} without a result`,
|
|
);
|
|
}
|
|
const payload: unknown = JSON.parse(
|
|
result.stdout.slice(markerIndex + PROBE_RESULT_MARKER.length),
|
|
);
|
|
if (!isRecord(payload) || !Array.isArray(payload.failures)) {
|
|
return [];
|
|
}
|
|
return payload.failures.filter(
|
|
(failure): failure is BuiltPluginControlPlaneModuleFailure =>
|
|
isRecord(failure) &&
|
|
typeof failure.pluginId === "string" &&
|
|
typeof failure.kind === "string" &&
|
|
typeof failure.relativePath === "string" &&
|
|
typeof failure.error === "string",
|
|
);
|
|
}
|
|
|
|
// 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);
|
|
const failures = probeBuiltPluginControlPlaneModules(modules, params);
|
|
if (failures.length > 0) {
|
|
const details = failures.map(
|
|
(failure) =>
|
|
`- ${failure.pluginId} (${failure.kind}) ${failure.relativePath}: ${failure.error}`,
|
|
);
|
|
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 and checked doctor closures`,
|
|
);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
verifyBuiltPluginControlPlaneModules();
|
|
}
|