diff --git a/CHANGELOG.md b/CHANGELOG.md index 6239e0d5071..6b151f3c123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 1018dcff54b..3bc0b8ddcb8 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -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([ + "@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 { + const out = new Set(); + if (!fs.existsSync(packageJsonPath)) { + return out; + } + let parsed: { + dependencies?: Record; + optionalDependencies?: Record; + }; + 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 { + const out = new Set(); + 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([ + "@agentclientprotocol/sdk", + "@lydell/node-pty", + "croner", + "dotenv", + "jiti", + "json5", + "jszip", + "markdown-it", + "semver", + "tar", + "tslog", + "web-push", + ]); + const nodeBuiltins = new Set(Module.builtinModules); + + const violations = new Map(); + for (const file of walkCoreSourceFiles(repoRoot)) { + const source = fs.readFileSync(file, "utf8"); + const specifiers = new Set(); + 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"), + ); + } + }); +}); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 3a164a2f908..6a6c68b986e 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -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 =