diff --git a/CHANGELOG.md b/CHANGELOG.md index 11cd2bde281..597250b99cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Channels/Telegram: keep Bot API network fallbacks sticky after failed attempts and retry timed-out startup control calls once on the fallback route, so `deleteWebhook` IPv6 stalls no longer trigger slow multi-account retry storms. Fixes #73255. Thanks @ttomiczek and @sktbrd. - Gateway/models: merge explicit `models.providers.*.models` rows into the Gateway model catalog with normalized provider/model dedupe, and use normalized image-capability lookup so custom vision models keep native image attachments even when Pi discovery omits them or model ID casing differs. Fixes #64213 and #65165. Thanks @billonese and @202233a. - Gateway/reload: publish canonical post-write source config to in-process reloaders so simple config saves no longer create phantom plugin diffs or trigger unnecessary Gateway restarts. (#73267) Thanks @szsip239. +- CLI/tasks: ship the task-registry control runtime in npm packages so `openclaw tasks cancel` can load ACP/subagent cancellation helpers from published builds. Fixes #68997. Thanks @1OAKDesign. - Export/session: keep inline export HTML scripts and vendor libraries injected after template formatting so generated session exports open with the app code, markdown renderer, and syntax highlighter present. Fixes #41862 and #49957; carries forward #41861 and #68947. Thanks @briannewman, @martenzi, and @armanddp. - Agents/ACPX: stage the patched Claude ACP adapter as an ACPX runtime dependency and route known Codex/Claude ACP commands through local wrappers, so Gateway runtime no longer depends on live `npx` adapter resolution. Fixes #73202. Thanks @joerod26. - Memory/compaction: let pre-compaction memory flush use an exact `agents.defaults.compaction.memoryFlush.model` override such as `ollama/qwen3:8b` without inheriting the active session fallback chain, so local housekeeping can avoid paid conversation models. Fixes #53772. Thanks @limen96. diff --git a/package.json b/package.json index 67af11c02e5..0431b5d7578 100644 --- a/package.json +++ b/package.json @@ -1273,6 +1273,7 @@ "check:madge-import-cycles": "node --import tsx scripts/check-madge-import-cycles.ts", "check:no-conflict-markers": "node scripts/check-no-conflict-markers.mjs", "check:no-runtime-action-load-config": "node scripts/check-no-runtime-action-load-config.mjs", + "check:runtime-sidecar-loaders": "node --import tsx scripts/check-runtime-sidecar-loaders.mjs", "check:static-import-sccs": "pnpm check:madge-import-cycles", "check:temp-path-guardrails": "node --import tsx scripts/check-temp-path-guardrails.ts", "check:test-types": "pnpm tsgo:test", diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 38f0a7d77c0..07bf9b3170a 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -87,6 +87,7 @@ export function createChangedCheckPlan(result, options = {}) { } if (runAll) { + add("runtime sidecar loader guard", ["check:runtime-sidecar-loaders"]); addTypecheck("typecheck all", ["tsgo:all"]); addLint("lint", ["lint"]); add("runtime import cycles", ["check:import-cycles"]); @@ -130,6 +131,7 @@ export function createChangedCheckPlan(result, options = {}) { } if (lanes.core || lanes.extensions) { + add("runtime sidecar loader guard", ["check:runtime-sidecar-loaders"]); add("runtime import cycles", ["check:import-cycles"]); } if (lanes.core) { diff --git a/scripts/check-runtime-sidecar-loaders.mjs b/scripts/check-runtime-sidecar-loaders.mjs new file mode 100644 index 00000000000..5e691ea236d --- /dev/null +++ b/scripts/check-runtime-sidecar-loaders.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import ts from "typescript"; +import { + collectTypeScriptFilesFromRoots, + resolveRepoRoot, + runAsScript, + toLine, + unwrapExpression, +} from "./lib/ts-guard-utils.mjs"; + +const repoRoot = resolveRepoRoot(import.meta.url); +const defaultSourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")]; +const localRuntimeSpecifierPattern = /^\.{1,2}\/.*\.runtime\.(?:js|ts)$/; + +function toPosixPath(value) { + return value.split(path.sep).join("/"); +} + +function normalizeRelativePath(value) { + return path.posix.normalize(toPosixPath(value).replace(/^\.\//, "")); +} + +function unwrapInitializer(expression) { + let current = unwrapExpression(expression); + while (ts.isSatisfiesExpression(current)) { + current = unwrapExpression(current.expression); + } + return current; +} + +function readStringLiteral(node) { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text; + } + return null; +} + +function readArrayStrings(node) { + const expression = unwrapInitializer(node); + if (!ts.isArrayLiteralExpression(expression)) { + return null; + } + const values = []; + for (const element of expression.elements) { + const value = readStringLiteral(unwrapInitializer(element)); + if (value === null) { + return null; + } + values.push(value); + } + return values; +} + +function isCreateRequireCall(node, createRequireNames) { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + createRequireNames.has(node.expression.text) + ); +} + +function isLocalRuntimeSpecifier(specifier) { + return localRuntimeSpecifierPattern.test(specifier); +} + +function resolveRuntimeSpecifierSource(importerPath, specifier) { + const importerDir = path.posix.dirname(normalizeRelativePath(importerPath)); + const resolved = path.posix.normalize(path.posix.join(importerDir, specifier)); + return resolved.replace(/\.js$/, ".ts"); +} + +function readObjectEntrySources(entry) { + if (!entry || Array.isArray(entry) || typeof entry !== "object") { + return []; + } + return Object.values(entry).filter((value) => typeof value === "string"); +} + +export function collectTsdownEntrySources(config) { + const configs = Array.isArray(config) ? config : [config]; + return new Set( + configs.flatMap((entry) => readObjectEntrySources(entry?.entry)).map(normalizeRelativePath), + ); +} + +export function findRuntimeSidecarLoaderViolations(content, importerPath, explicitEntrySources) { + const sourceFile = ts.createSourceFile(importerPath, content, ts.ScriptTarget.Latest, true); + const createRequireNames = new Set(); + const requireNames = new Set(); + const stringConstants = new Map(); + const stringArrays = new Map(); + const forOfRuntimeValues = []; + const violations = []; + const seen = new Set(); + + const currentForOfValueMap = () => { + const merged = new Map(); + for (const scope of forOfRuntimeValues) { + for (const [name, values] of scope) { + merged.set(name, values); + } + } + return merged; + }; + + const addSpecifier = (specifier, node) => { + if (!isLocalRuntimeSpecifier(specifier)) { + return; + } + const sourcePath = resolveRuntimeSpecifierSource(importerPath, specifier); + if (explicitEntrySources.has(sourcePath)) { + return; + } + const key = `${sourcePath}:${toLine(sourceFile, node)}`; + if (seen.has(key)) { + return; + } + seen.add(key); + violations.push({ + line: toLine(sourceFile, node), + specifier, + sourcePath, + reason: + `hidden local runtime loader "${specifier}" resolves to ${sourcePath}, ` + + "but that source is not an explicit tsdown entry", + }); + }; + + const readRequireArgumentSpecifiers = (node) => { + const arg = node.arguments[0]; + if (!arg) { + return []; + } + const unwrapped = unwrapInitializer(arg); + const literal = readStringLiteral(unwrapped); + if (literal !== null) { + return [literal]; + } + if (ts.isIdentifier(unwrapped)) { + const loopValues = currentForOfValueMap().get(unwrapped.text); + if (loopValues) { + return loopValues; + } + const constant = stringConstants.get(unwrapped.text); + if (constant !== undefined) { + return [constant]; + } + } + return []; + }; + + const visit = (node) => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + if (node.moduleSpecifier.text === "node:module") { + const bindings = node.importClause?.namedBindings; + if (bindings && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) { + if ( + element.propertyName?.text === "createRequire" || + element.name.text === "createRequire" + ) { + createRequireNames.add(element.name.text); + } + } + } + } + } + + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { + const initializer = unwrapInitializer(node.initializer); + const literal = readStringLiteral(initializer); + if (literal !== null) { + stringConstants.set(node.name.text, literal); + } + const arrayValues = readArrayStrings(initializer); + if (arrayValues) { + stringArrays.set(node.name.text, arrayValues); + } + if (isCreateRequireCall(initializer, createRequireNames)) { + requireNames.add(node.name.text); + } + } + + if (ts.isForOfStatement(node)) { + const initializer = node.initializer; + const expression = unwrapInitializer(node.expression); + if ( + ts.isVariableDeclarationList(initializer) && + initializer.declarations.length === 1 && + ts.isIdentifier(initializer.declarations[0].name) && + ts.isIdentifier(expression) + ) { + const values = stringArrays.get(expression.text); + if (values) { + forOfRuntimeValues.push(new Map([[initializer.declarations[0].name.text, values]])); + ts.forEachChild(node.statement, visit); + forOfRuntimeValues.pop(); + return; + } + } + } + + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + requireNames.has(node.expression.text) + ) { + for (const specifier of readRequireArgumentSpecifiers(node)) { + addSpecifier(specifier, node); + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + +export async function collectRuntimeSidecarLoaderViolations(params) { + const files = await collectTypeScriptFilesFromRoots(params.sourceRoots, { + extraTestSuffixes: [".test-support.ts", ".test-helpers.ts"], + }); + const violations = []; + for (const filePath of files) { + if (filePath.endsWith(".d.ts")) { + continue; + } + const relativePath = normalizeRelativePath(path.relative(params.repoRoot, filePath)); + const content = await fs.readFile(filePath, "utf8"); + for (const violation of findRuntimeSidecarLoaderViolations( + content, + relativePath, + params.explicitEntrySources, + )) { + violations.push({ path: relativePath, ...violation }); + } + } + return violations; +} + +async function main() { + const { default: tsdownConfig } = await import("../tsdown.config.ts"); + const violations = await collectRuntimeSidecarLoaderViolations({ + repoRoot, + sourceRoots: defaultSourceRoots, + explicitEntrySources: collectTsdownEntrySources(tsdownConfig), + }); + if (violations.length === 0) { + console.log("runtime-sidecar-loaders: local runtime sidecar loaders look OK."); + return; + } + console.error("runtime-sidecar-loaders: hidden local runtime loaders found:"); + for (const violation of violations) { + console.error( + `- ${violation.path}:${violation.line}: ${violation.reason}. ` + + 'Use cached import("./x.runtime.js") or add the sidecar as a stable tsdown entry.', + ); + } + process.exitCode = 1; +} + +runAsScript(import.meta.url, main); diff --git a/scripts/check.mjs b/scripts/check.mjs index 9c4763e4503..c143e336c63 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -39,6 +39,7 @@ export async function main(argv = process.argv.slice(2)) { name: "plugin-sdk wildcard re-exports", args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"], }, + { name: "runtime sidecar loader guard", args: ["check:runtime-sidecar-loaders"] }, { name: "tool display", args: ["tool-display:check"] }, { name: "host env policy", args: ["check:host-env-policy:swift"] }, ], diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 0aa849d6b71..060358cdf27 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -71,6 +71,7 @@ const requiredPathGroups = [ "scripts/postinstall-bundled-plugins.mjs", "dist/plugin-sdk/compat.js", "dist/plugin-sdk/root-alias.cjs", + "dist/task-registry-control.runtime.js", "dist/build-info.json", "dist/channel-catalog.json", "dist/control-ui/index.html", @@ -474,6 +475,27 @@ function runPackedBundledPluginActivationSmoke(packageRoot: string, tmpRoot: str } } +function runPackedTaskRegistryControlRuntimeSmoke(packageRoot: string): void { + const runtimePath = join(packageRoot, "dist", "task-registry-control.runtime.js"); + if (!existsSync(runtimePath)) { + throw new Error("release-check: packed task-registry control runtime is missing."); + } + const source = ` +const runtime = await import(${JSON.stringify(pathToFileURL(runtimePath).href)}); +if (typeof runtime.getAcpSessionManager !== "function") { + throw new Error("missing getAcpSessionManager export"); +} +if (typeof runtime.killSubagentRunAdmin !== "function") { + throw new Error("missing killSubagentRunAdmin export"); +} +`; + execFileSync(process.execPath, ["--input-type=module", "--eval", source], { + cwd: packageRoot, + stdio: "inherit", + env: createPackedCliSmokeEnv(process.env), + }); +} + function runPackedCliSmoke(params: { prefixDir: string; cwd: string; @@ -536,6 +558,7 @@ function runPackedBundledChannelEntrySmoke(): void { }); runPackedBundledPluginPostinstall(packageRoot); runPackedBundledPluginActivationSmoke(packageRoot, tmpRoot); + runPackedTaskRegistryControlRuntimeSmoke(packageRoot); execFileSync( process.execPath, [ diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 6d20667e8fd..ed579373958 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -67,6 +67,7 @@ describe("tsdown config", () => { "agents/model-catalog.runtime", "agents/models-config.runtime", "subagent-registry.runtime", + "task-registry-control.runtime", "agents/pi-model-discovery-runtime", "link-understanding/apply.runtime", "media-understanding/apply.runtime", diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 1d79f9de443..5fcf706222e 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -539,6 +539,7 @@ describe("collectMissingPackPaths", () => { "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", "scripts/postinstall-bundled-plugins.mjs", + "dist/task-registry-control.runtime.js", bundledDistPluginFile("diffs", "assets/viewer-runtime.js"), bundledDistPluginFile("matrix", "helper-api.js"), bundledDistPluginFile("matrix", "runtime-api.js"), @@ -568,6 +569,7 @@ describe("collectMissingPackPaths", () => { "scripts/preinstall-package-manager-warning.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/plugin-sdk/root-alias.cjs", + "dist/task-registry-control.runtime.js", "dist/build-info.json", "dist/channel-catalog.json", PACKAGE_DIST_INVENTORY_RELATIVE_PATH, diff --git a/test/scripts/check-runtime-sidecar-loaders.test.ts b/test/scripts/check-runtime-sidecar-loaders.test.ts new file mode 100644 index 00000000000..7aebaf095b4 --- /dev/null +++ b/test/scripts/check-runtime-sidecar-loaders.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + collectTsdownEntrySources, + findRuntimeSidecarLoaderViolations, +} from "../../scripts/check-runtime-sidecar-loaders.mjs"; + +describe("check-runtime-sidecar-loaders", () => { + it("flags hidden createRequire runtime sidecars that are not build entries", () => { + const source = ` + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + export function loadRuntime() { + return require("./missing.runtime.js"); + } + `; + + expect( + findRuntimeSidecarLoaderViolations(source, "src/tasks/task-registry.ts", new Set()), + ).toEqual([ + { + line: 5, + specifier: "./missing.runtime.js", + sourcePath: "src/tasks/missing.runtime.ts", + reason: + 'hidden local runtime loader "./missing.runtime.js" resolves to src/tasks/missing.runtime.ts, but that source is not an explicit tsdown entry', + }, + ]); + }); + + it("allows hidden createRequire runtime sidecars when the source is an explicit build entry", () => { + const source = ` + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + export function loadRuntime() { + return require("./task-registry-control.runtime.js"); + } + `; + + expect( + findRuntimeSidecarLoaderViolations( + source, + "src/tasks/task-registry.ts", + new Set(["src/tasks/task-registry-control.runtime.ts"]), + ), + ).toEqual([]); + }); + + it("resolves candidate arrays used by source/build fallback loops", () => { + const source = ` + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const CANDIDATES = ["./control.runtime.js", "./control.runtime.ts"] as const; + export function loadRuntime() { + for (const candidate of CANDIDATES) { + return require(candidate); + } + } + `; + + expect( + findRuntimeSidecarLoaderViolations(source, "src/tasks/task-registry.ts", new Set()), + ).toEqual([ + { + line: 7, + specifier: "./control.runtime.js", + sourcePath: "src/tasks/control.runtime.ts", + reason: + 'hidden local runtime loader "./control.runtime.js" resolves to src/tasks/control.runtime.ts, but that source is not an explicit tsdown entry', + }, + ]); + }); + + it("ignores bundler-visible dynamic imports", () => { + const source = ` + let runtimePromise: Promise | undefined; + export function loadRuntime() { + runtimePromise ??= import("./control.runtime.js"); + return runtimePromise; + } + `; + + expect( + findRuntimeSidecarLoaderViolations(source, "src/tasks/task-registry.ts", new Set()), + ).toEqual([]); + }); + + it("collects explicit tsdown entry sources", () => { + expect( + collectTsdownEntrySources([ + { + entry: { + index: "src/index.ts", + "task-registry-control.runtime": "src/tasks/task-registry-control.runtime.ts", + }, + }, + ]), + ).toEqual(new Set(["src/index.ts", "src/tasks/task-registry-control.runtime.ts"])); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 5b83c633145..dd3f1ffcbe0 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -213,6 +213,7 @@ function buildCoreDistEntries(): Record { "agents/model-catalog.runtime": "src/agents/model-catalog.runtime.ts", "agents/models-config.runtime": "src/agents/models-config.runtime.ts", "subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts", + "task-registry-control.runtime": "src/tasks/task-registry-control.runtime.ts", "agents/pi-model-discovery-runtime": "src/agents/pi-model-discovery-runtime.ts", "link-understanding/apply.runtime": "src/link-understanding/apply.runtime.ts", "media-understanding/apply.runtime": "src/media-understanding/apply.runtime.ts",