mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
fix(plugins): mirror core root-package deps used by core dist code (#74213)
Extend MIRRORED_CORE_RUNTIME_DEP_NAMES from ["semver", "tslog"] to
also include @agentclientprotocol/sdk, @lydell/node-pty, croner,
dotenv, jiti, json5, jszip, markdown-it, tar, and web-push.
These are all declared as direct dependencies in the openclaw root
package.json and imported by core source code (src/acp/*, src/cron/*,
src/config/*, src/infra/{archive,backup,dotenv,push-web}.ts,
src/markdown/ir.ts, src/plugin-sdk/root-alias.cjs,
src/plugins/jiti-loader-cache.ts, src/process/supervisor/adapters/pty.ts,
etc), but the existing collectMirroredPackageRuntimeDeps allowlist only
covered semver and tslog.
The dynamic collectRootDistMirroredRuntimeDeps scan does pick up
imports that have an extension package.json owner (for example
memory-core declares chokidar, matrix declares jiti and markdown-it).
For deps with no extension owner, or for setups where the owning
extension is not enabled, those imports never make it into the
runtime-deps mirror and Node fails to resolve them at runtime, e.g.:
Cannot find package 'chokidar' imported from
.../plugin-runtime-deps/openclaw-<ver>/dist/qmd-manager-...js
Also add a static drift guard test that walks src/ for value imports of
root-package runtime deps and fails when one is neither in
MIRRORED_CORE_RUNTIME_DEP_NAMES nor declared by any extension's
package.json (with an explicit allowlist for known-transitive or
build/type-only imports such as chalk, ipaddr.js, file-type,
proxy-agent, typescript, qrcode). The guard caught @lydell/node-pty
during this change.
Refs #74199.
This commit is contained in:
@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/thinking: honor configured model `compat.supportedReasoningEfforts` entries that include `xhigh`, so custom OpenAI-compatible provider refs expose and validate `/think xhigh` consistently across command menus, Gateway sessions, agent CLI, and `llm-task`. Carries forward #48904. Thanks @Milchstrassse and @wufunc.
|
||||
- Vercel AI Gateway: expose provider-owned `/think xhigh` for trusted OpenAI/Codex upstream refs and Claude adaptive thinking for Anthropic upstream refs, while leaving untrusted namespaced refs on base levels. Carries forward #41561. Thanks @Zcg2021.
|
||||
- Plugins/runtime-deps: prune stale `openclaw-unknown-*` bundled runtime dependency roots during Gateway startup while keeping recent or locked roots, so old staging debris cannot keep growing across restarts. Thanks @vincentkoc.
|
||||
- Plugins/runtime-deps: include ten more root-package runtime dependencies (`@agentclientprotocol/sdk`, `@lydell/node-pty`, `croner`, `dotenv`, `jiti`, `json5`, `jszip`, `markdown-it`, `tar`, `web-push`) in `MIRRORED_CORE_RUNTIME_DEP_NAMES` so they are mirrored into the runtime-deps tree alongside `semver` and `tslog`, preventing `Cannot find package 'X'` failures from core dist code (for example `qmd-manager`, `cron/schedule`, `infra/archive`, `infra/push-web`, `infra/backup-create`, `process/supervisor/adapters/pty`) when no enabled extension owns the dependency. Adds a static drift guard test that scans `src/` for value imports of root-package deps and fails CI when one is missing from the mirror allowlist or extension-owned set. Refs #74199. Thanks @maxpuppet.
|
||||
- Ollama: compose caller abort signals with guarded-fetch timeouts for native `/api/chat` streams, so `/stop` and early cancellation still interrupt local Ollama requests that also carry provider timeout budgets. Refs #74133. Thanks @obviyus.
|
||||
- Doctor/TTS: migrate legacy `messages.tts.enabled`, agent TTS, channel TTS, and voice-call plugin TTS toggles to `auto` mode during `openclaw doctor --fix`, matching the documented TTS config contract. Thanks @vincentkoc.
|
||||
- CLI/logs: fall back to the configured Gateway file log when implicit loopback Gateway connections close or time out before or during `logs.tail`, so `openclaw logs` still works while diagnosing local-model Gateway disconnects. Refs #74078. Thanks @sakalaboator.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import { Module } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -2929,3 +2930,224 @@ describe("ensureBundledPluginRuntimeDeps", () => {
|
||||
expect(fs.existsSync(path.join(pluginRoot, "node_modules", "zod", "package.json"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIRRORED_CORE_RUNTIME_DEP_NAMES drift guard", () => {
|
||||
// Intentionally not mirrored at runtime: build-only / type-only / TUI-only
|
||||
// tooling and packages that resolve transitively through other mirrored deps.
|
||||
// If you change this set, document why in the comment beside the entry.
|
||||
const KNOWN_UNMIRRORED_BARE_IMPORTS = new Set<string>([
|
||||
"@mariozechner/pi-tui", // TUI mode runs from npm-global, not the gateway runtime mirror
|
||||
"chalk", // available transitively via mirrored deps
|
||||
"file-type", // available transitively via mirrored deps
|
||||
"global-agent", // proxy bootstrap, only loaded when HTTP_PROXY is set
|
||||
"ipaddr.js", // available transitively via mirrored deps
|
||||
"proxy-agent", // available transitively via mirrored deps
|
||||
"qrcode", // type-only import in src/media/qr-runtime.ts
|
||||
"typescript", // CLI/dev only (api-baseline, jiti-runtime-api)
|
||||
]);
|
||||
|
||||
function locateRepoRoot(): string {
|
||||
let dir = path.resolve(import.meta.dirname);
|
||||
for (let depth = 0; depth < 10; depth += 1) {
|
||||
const candidate = path.join(dir, "package.json");
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(candidate, "utf8")) as { name?: string };
|
||||
if (data.name === "openclaw") {
|
||||
return dir;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
break;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
throw new Error("could not locate openclaw repo root from test file");
|
||||
}
|
||||
|
||||
function readPackageJsonDeps(packageJsonPath: string): Set<string> {
|
||||
const out = new Set<string>();
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return out;
|
||||
}
|
||||
let parsed: {
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const name of Object.keys(parsed.dependencies ?? {})) {
|
||||
out.add(name);
|
||||
}
|
||||
for (const name of Object.keys(parsed.optionalDependencies ?? {})) {
|
||||
out.add(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectExtensionOwnedDeps(repoRoot: string): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const extensionsDir = path.join(repoRoot, "extensions");
|
||||
if (!fs.existsSync(extensionsDir)) {
|
||||
return out;
|
||||
}
|
||||
for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
for (const name of readPackageJsonDeps(
|
||||
path.join(extensionsDir, entry.name, "package.json"),
|
||||
)) {
|
||||
out.add(name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function walkCoreSourceFiles(repoRoot: string): string[] {
|
||||
const srcDir = path.join(repoRoot, "src");
|
||||
const files: string[] = [];
|
||||
const queue: string[] = [srcDir];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
queue.push(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
/\.test\.tsx?$/u.test(entry.name) ||
|
||||
/\.e2e\.test\.tsx?$/u.test(entry.name) ||
|
||||
/\.test-helpers?\.tsx?$/u.test(entry.name) ||
|
||||
/\.test-fixture\.tsx?$/u.test(entry.name) ||
|
||||
entry.name.endsWith(".d.ts") ||
|
||||
!/\.(?:ts|tsx|cjs|mjs|js)$/u.test(entry.name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function packageNameFromBareSpecifier(specifier: string): string | null {
|
||||
if (
|
||||
specifier.startsWith(".") ||
|
||||
specifier.startsWith("/") ||
|
||||
specifier.startsWith("node:") ||
|
||||
specifier.startsWith("#")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const [first, second] = specifier.split("/");
|
||||
if (!first) {
|
||||
return null;
|
||||
}
|
||||
return first.startsWith("@") && second ? `${first}/${second}` : first;
|
||||
}
|
||||
|
||||
// Match value imports (`import x from 'y'`, `import 'y'`, `require('y')`,
|
||||
// `import('y')`) but skip `import type` to avoid noise from type-only imports.
|
||||
const VALUE_IMPORT_PATTERNS = [
|
||||
/(?:^|[;\n])\s*import\s+(?!type\b)(?:[^'"()]+?\s+from\s+)?["']([^"']+)["']/g,
|
||||
/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g,
|
||||
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
|
||||
] as const;
|
||||
|
||||
it("every value-imported root-package dep in src/ is mirrored or owned by an extension", () => {
|
||||
const repoRoot = locateRepoRoot();
|
||||
const rootDeps = readPackageJsonDeps(path.join(repoRoot, "package.json"));
|
||||
const extensionDeps = collectExtensionOwnedDeps(repoRoot);
|
||||
const mirroredCore = new Set<string>([
|
||||
"@agentclientprotocol/sdk",
|
||||
"@lydell/node-pty",
|
||||
"croner",
|
||||
"dotenv",
|
||||
"jiti",
|
||||
"json5",
|
||||
"jszip",
|
||||
"markdown-it",
|
||||
"semver",
|
||||
"tar",
|
||||
"tslog",
|
||||
"web-push",
|
||||
]);
|
||||
const nodeBuiltins = new Set<string>(Module.builtinModules);
|
||||
|
||||
const violations = new Map<string, string>();
|
||||
for (const file of walkCoreSourceFiles(repoRoot)) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const specifiers = new Set<string>();
|
||||
for (const pattern of VALUE_IMPORT_PATTERNS) {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
if (match[1]) {
|
||||
specifiers.add(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const specifier of specifiers) {
|
||||
const packageName = packageNameFromBareSpecifier(specifier);
|
||||
if (!packageName) {
|
||||
continue;
|
||||
}
|
||||
if (nodeBuiltins.has(packageName)) {
|
||||
continue;
|
||||
}
|
||||
if (packageName === "openclaw" || packageName.startsWith("@openclaw/")) {
|
||||
continue;
|
||||
}
|
||||
if (mirroredCore.has(packageName) || extensionDeps.has(packageName)) {
|
||||
continue;
|
||||
}
|
||||
if (KNOWN_UNMIRRORED_BARE_IMPORTS.has(packageName)) {
|
||||
continue;
|
||||
}
|
||||
if (!rootDeps.has(packageName)) {
|
||||
// Not a root runtime dep; not our concern (could be a peer/dev import
|
||||
// that resolves through some other path; the mirror does not own it).
|
||||
continue;
|
||||
}
|
||||
if (!violations.has(packageName)) {
|
||||
violations.set(packageName, path.relative(repoRoot, file).replaceAll(path.sep, "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.size > 0) {
|
||||
const summary = [...violations.entries()]
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([packageName, filePath]) => ` - ${packageName} (e.g. ${filePath})`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
[
|
||||
"Bare imports found in src/ that are root-package runtime deps but are neither",
|
||||
"in MIRRORED_CORE_RUNTIME_DEP_NAMES nor declared by any extension's package.json.",
|
||||
"These will be missing from the runtime-deps mirror at gateway start and Node",
|
||||
"will fail to resolve them. Either add the package to MIRRORED_CORE_RUNTIME_DEP_NAMES,",
|
||||
"declare it under an owning extension's dependencies, or add it to",
|
||||
"KNOWN_UNMIRRORED_BARE_IMPORTS in this test with a comment explaining why.",
|
||||
"",
|
||||
summary,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,20 @@ const DEFAULT_UNKNOWN_RUNTIME_DEPS_MIN_AGE_MS = 10 * 60_000;
|
||||
const BUNDLED_RUNTIME_DEPS_INSTALL_PROGRESS_INTERVAL_MS = 5_000;
|
||||
const BUNDLED_RUNTIME_MIRROR_MATERIALIZED_EXTENSIONS = new Set([".cjs", ".js", ".mjs"]);
|
||||
const BUNDLED_EXTENSION_DIST_DIR = "extensions";
|
||||
const MIRRORED_CORE_RUNTIME_DEP_NAMES = ["semver", "tslog"] as const;
|
||||
const MIRRORED_CORE_RUNTIME_DEP_NAMES = [
|
||||
"@agentclientprotocol/sdk",
|
||||
"@lydell/node-pty",
|
||||
"croner",
|
||||
"dotenv",
|
||||
"jiti",
|
||||
"json5",
|
||||
"jszip",
|
||||
"markdown-it",
|
||||
"semver",
|
||||
"tar",
|
||||
"tslog",
|
||||
"web-push",
|
||||
] as const;
|
||||
const MIRRORED_PACKAGE_RUNTIME_DEP_PLUGIN_ID = "openclaw-core";
|
||||
const BUNDLED_RUNTIME_MIRROR_PLUGIN_REGION_RE = /(?:^|\n)\/\/#region extensions\/[^/\s]+(?:\/|$)/u;
|
||||
const BUNDLED_RUNTIME_MIRROR_IMPORT_SPECIFIER_RE =
|
||||
|
||||
Reference in New Issue
Block a user