From 2bfa3ebc98b854fa1d59d09fe8d5f45b721cbc7b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 12 Sep 2026 07:46:15 -0700 Subject: [PATCH] fix(update): complete plugin runtime artifacts on first Git update (#145664) * fix(update): prevent stale runtime modules after Git updates Promote root and nested dist-runtime outputs through the existing candidate runtime transaction so activation and rollback keep every runtime generation aligned. Extend real-Git execution probes for runtime overlays and consolidate repeated fixture writes. * fix(update): complete source runtime artifacts on first hop Complete canonical plugin overlays and SDK aliases before target-owned plugin convergence when an older published updater omitted those outputs. Preserve exact online retries and legacy target contracts, and require current offline authority for changed physical runtime publication and rollback. Share artifact ownership with build/postbuild writers, join smoke cancellation, and keep each directory swap synchronous after its authority check. * fix(daemon): preserve proven systemd service absence Recognize the native systemd missing-unit-file response during strict non-loading inspection. Carry affirmative user-unit absence to the Linux absence owner, which separately excludes system-scope ownership before reporting an offline service. Keep failed manager inspection and other platforms conservative. This allows first-hop Git runtime artifact completion on CLI-only Linux installations without weakening publication authority. * fix(update): preserve canonical artifacts in smoke consumers Keep the built singleton smoke on canonical runtime preparation and verify its cleanup leaves a no-op. Release the QA fixture port reservation before packaged maintenance so offline publication can inspect the configured port. Align update routing and lifecycle fixtures with their real target identity and lease contracts while retaining consent, cleanup, and restart assertions. * test: rebalance plugin state type checks --- docs/cli/update/how-updates-run.md | 5 +- extensions/qa-lab/src/gateway-child-setup.ts | 61 +- extensions/qa-lab/src/gateway-child.test.ts | 18 +- scripts/build-all.mts | 5 +- scripts/lib/dist-artifact-ownership.mts | 5 +- scripts/lib/runtime-artifact-contract.ts | 12 + scripts/lib/static-extension-assets.mts | 20 +- scripts/run-node.mts | 22 +- scripts/runtime-postbuild.mts | 11 +- scripts/stage-bundled-plugin-runtime.mts | 280 +++++- scripts/test-built-plugin-singleton.mts | 832 ++++++++++-------- src/cli/update-cli.test.ts | 35 +- .../update-cli/update-command-convergence.ts | 9 +- src/cli/update-cli/update-command-finalize.ts | 6 + .../update-command-lease.test-support.ts | 19 + .../update-cli/update-command-lease.test.ts | 214 +++++ .../update-command-lifecycle.test.ts | 14 + .../update-command-plugin-caller.test.ts | 6 + .../update-command-post-update.test.ts | 47 +- src/cli/update-cli/update-command-resume.ts | 4 +- src/cli/update-cli/update-command-runtime.ts | 116 +++ .../update-command-service-maintenance.ts | 32 +- ...update-command-service-publication.test.ts | 469 ++++++++++ .../update-command-service-publication.ts | 315 +++++++ src/daemon/service-absence.test.ts | 155 ++++ src/daemon/service.ts | 77 +- src/daemon/systemd-command-query.ts | 3 +- src/daemon/systemd-peer-native.ts | 3 +- src/daemon/systemd-scope.ts | 15 +- src/daemon/systemd.test.ts | 16 +- src/infra/update-runner-git-candidate.test.ts | 30 +- src/infra/update-runner-git-runtime.ts | 4 +- test/package-scripts.test.ts | 2 +- test/scripts/build-all.test.ts | 26 +- test/scripts/dist-artifact-ownership.test.ts | 160 ++++ .../stage-bundled-plugin-runtime.test.ts | 283 +++++- .../tsconfig/tsconfig.core.test.services.json | 4 +- .../tsconfig.core.test.state-logging.json | 2 + 38 files changed, 2749 insertions(+), 588 deletions(-) create mode 100644 scripts/lib/runtime-artifact-contract.ts create mode 100644 src/cli/update-cli/update-command-runtime.ts create mode 100644 src/cli/update-cli/update-command-service-publication.test.ts create mode 100644 src/cli/update-cli/update-command-service-publication.ts create mode 100644 src/daemon/service-absence.test.ts diff --git a/docs/cli/update/how-updates-run.md b/docs/cli/update/how-updates-run.md index c9849565c198..bfde87fd1e18 100644 --- a/docs/cli/update/how-updates-run.md +++ b/docs/cli/update/how-updates-run.md @@ -447,7 +447,7 @@ the sentinel. Stable, beta, and dev updates install dependencies and build in a temporary worktree while the old Gateway serves. Dev rebases the candidate first so local commits are preserved and the build validates the exact source that will be activated. On POSIX, staging uses a private directory in the checkout's existing ignored `.artifacts` area. By default, the full workspace stays on the checkout filesystem, not a potentially small system temporary filesystem. An existing `.artifacts` redirect is honored as an operator storage choice, just like the build cache. Existing checkout, parent, and artifact directory permissions are not changed. Windows keeps its short system-drive staging path. Only dev updates walk back through earlier commits; stable and beta updates validate their selected target. - The updater prepares the built runtime on the destination filesystem and removes the temporary Git worktree registration before changing the live checkout. Cleanup failures remain visible in the update result. If an interruption leaves staging behind, artifact-area staging does not dirty the checkout or block the next update's clean check. + The updater prepares the built runtime (`dist`, `dist-runtime`, and dependencies, including nested workspace outputs) on the destination filesystem and removes the temporary Git worktree registration before changing the live checkout. Cleanup failures remain visible in the update result. If an interruption leaves staging behind, artifact-area staging does not dirty the checkout or block the next update's clean check. Dev can walk back up to 10 commits to find the newest buildable candidate. Confirmed ENOSPC storage failures stop immediately with `preflight-insufficient-space`; free space on the preflight staging and package-manager store filesystems before retrying. Shared package-manager stores are not deleted. Update builds skip TypeScript declaration generation by default. Set `OPENCLAW_RUN_NODE_SKIP_DTS_BUILD=0` to explicitly request declarations. Set `OPENCLAW_UPDATE_PREFLIGHT_LINT=1` to also run source lint during this preflight; lint runs in constrained serial mode because user update hosts are often smaller than CI runners. @@ -467,6 +467,9 @@ the sentinel. Against the installed target, syncs plugins to the active channel before restarting the managed service. Dev uses bundled plugins; stable and beta use npm or ClawHub while preserving recorded source choices. A changed plugin snapshot runs fresh Doctor migrations; unchanged plugins do not run another full Doctor pass. The updater then revalidates the service owner, starts the Gateway, and verifies the final snapshot. + + Source targets that support runtime completion also check their generated plugin runtime overlay and SDK aliases before loading plugin configuration. This completes artifacts omitted by an older updater on the first update to such a target; `update repair` performs the same check before Doctor. Exact artifacts remain untouched, including while a Gateway is running. Replacing missing or stale artifacts requires proof that the affected runtime is offline. A Gateway serving a physically separate runtime does not block completion. If service ownership or offline status cannot be verified, completion fails with recovery guidance instead of reporting a successful update. Older targets retain their existing generation behavior. Clean-source and candidate-build validation still apply. + diff --git a/extensions/qa-lab/src/gateway-child-setup.ts b/extensions/qa-lab/src/gateway-child-setup.ts index 44a7199f75b1..8d52bc296a64 100644 --- a/extensions/qa-lab/src/gateway-child-setup.ts +++ b/extensions/qa-lab/src/gateway-child-setup.ts @@ -435,45 +435,44 @@ export async function prepareQaGatewayChild( } packagedMockAuthStaged = true; } - if (usesPackagedCandidate && gatewayCommand) { - // Live auth staging opens parent-owned agent stores. Release this - // fixture's leases before the child Doctor takes maintenance ownership. - await closeQaRuntimeStores(tempRoot); - const command = { - lifetime, - executablePath: gatewayCommand.executablePath, - argsPrefix: gatewayCommand.argsPrefix ?? [], - cwd: gatewayCwd, - env, - }; - // The separate onboarding smoke cannot prepare this child's state. - // Converge every freshly written config; a new-port retry can otherwise - // restore plugin entries the candidate removed before verify-only startup. - // Published candidates such as 2026.7.1-2 predate capability consent. - const help = await runQaPackagedBootstrap( - "installed package plugin setup failed (update repair --help)", - () => runQaGatewayCliCommand({ ...command, args: ["update", "repair", "--help"] }), - ); - const consentArgs = help.includes("--accept-capabilities") - ? ["--accept-capabilities"] - : []; - await runQaPackagedBootstrap( - "installed package plugin setup failed (update repair)", - () => - runQaGatewayCliCommand({ - ...command, - args: ["update", "repair", ...consentArgs, "--yes", "--no-restart", "--json"], - }), - ); - } } if (!env) { throw new Error("qa gateway runtime env not initialized"); } + // Packaged repair must inspect the configured port without our placeholder listener. await lifetime.portReservation?.release(); lifetime.portReservation = null; lifetime.assertOpen(); + + if (!reuseStartupLaunchState && usesPackagedCandidate && gatewayCommand) { + // Live auth staging opens parent-owned agent stores. Release this + // fixture's leases before the child Doctor takes maintenance ownership. + await closeQaRuntimeStores(tempRoot); + const command = { + lifetime, + executablePath: gatewayCommand.executablePath, + argsPrefix: gatewayCommand.argsPrefix ?? [], + cwd: gatewayCwd, + env, + }; + // The separate onboarding smoke cannot prepare this child's state. + // Converge every freshly written config; a new-port retry can otherwise + // restore plugin entries the candidate removed before verify-only startup. + // Published candidates such as 2026.7.1-2 predate capability consent. + const help = await runQaPackagedBootstrap( + "installed package plugin setup failed (update repair --help)", + () => runQaGatewayCliCommand({ ...command, args: ["update", "repair", "--help"] }), + ); + const consentArgs = help.includes("--accept-capabilities") ? ["--accept-capabilities"] : []; + await runQaPackagedBootstrap("installed package plugin setup failed (update repair)", () => + runQaGatewayCliCommand({ + ...command, + args: ["update", "repair", ...consentArgs, "--yes", "--no-restart", "--json"], + }), + ); + } + lifetime.assertOpen(); return { cfg, env, gatewayPort, baseUrl, wsUrl }; }, }; diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 3fdf42c6a0dc..e309f79d2715 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -167,6 +167,7 @@ async function writePackagedGatewayFixture(root: string): Promise { await writeFile( fixturePath, `import fs from "node:fs"; +import net from "node:net"; import path from "node:path"; const args = process.argv.slice(2); @@ -243,8 +244,16 @@ if (args[0] === "update") { process.stderr.write("unknown option --accept-capabilities"); process.exit(2); } - record({ kind: "plugins", args, authDbPath, configPath, stateDir }); const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + const portProbe = net.createServer(); + await new Promise((resolve, reject) => { + portProbe.once("error", reject); + portProbe.listen(config.gateway.port, "127.0.0.1", resolve); + }); + await new Promise((resolve, reject) => { + portProbe.close((error) => error ? reject(error) : resolve()); + }); + record({ kind: "plugins", args, authDbPath, configPath, stateDir, configPort: config.gateway.port }); delete config.plugins.entries["qa-lab"]; config.plugins.allow = config.plugins.allow.filter((id) => id !== "qa-lab"); fs.writeFileSync(configPath, JSON.stringify(config)); @@ -1819,6 +1828,7 @@ describe("buildQaRuntimeEnv", () => { ], configPath: records.at(-1)?.configPath, stateDir: records.at(-1)?.stateDir, + configPort: records.at(-1)?.configPort, }); expect(new Set(records.map((record) => record.authDbPath)).size).toBe(1); }, @@ -1856,7 +1866,11 @@ describe("buildQaRuntimeEnv", () => { const gateways = records.filter((record) => record.kind === "gateway"); expect(gateways).toHaveLength(2); expect(gateways.map((record) => record.sourcePluginConfigured)).toEqual([false, false]); - expect(records.filter((record) => record.kind === "plugins")).toHaveLength(configBuilds); + const repairs = records.filter((record) => record.kind === "plugins"); + expect(repairs).toHaveLength(configBuilds); + expect(repairs.map((record) => record.configPort)).toEqual( + retry === "bind" ? gateways.map((record) => record.configPort) : [gateways[0]?.configPort], + ); expect(mutateConfig).toHaveBeenCalledTimes(configBuilds); expect(records.filter((record) => record.kind === "auth")).toHaveLength(2); expect(records.map((record) => record.kind)).toEqual([ diff --git a/scripts/build-all.mts b/scripts/build-all.mts index 2d0dcfbfca40..f6eb6d07fee2 100644 --- a/scripts/build-all.mts +++ b/scripts/build-all.mts @@ -133,7 +133,7 @@ export const BUILD_ALL_STEPS: BuildAllStep[] = [ kind: "pnpm", pnpmArgs: ["plugins:assets:copy"], }, - nodeStep("runtime-postbuild", ["scripts/runtime-postbuild.mjs"]), + tsxStep("runtime-postbuild", "scripts/runtime-postbuild.mts"), tsxStep("build-stamp", "scripts/build-stamp.mts"), tsxStep("runtime-postbuild-stamp", "scripts/runtime-postbuild-stamp.mts"), { @@ -549,7 +549,8 @@ export async function runBuildAllSteps( args: script === "scripts/tsdown-build.mts" || script === "scripts/write-unified-entry-dts.ts" || - script === "scripts/write-plugin-sdk-entry-dts.ts" + script === "scripts/write-plugin-sdk-entry-dts.ts" || + script === "scripts/runtime-postbuild.mts" ? distArtifactEntryArgs(script, invocation.args.slice(3)) : invocation.args, ...invocation.options, diff --git a/scripts/lib/dist-artifact-ownership.mts b/scripts/lib/dist-artifact-ownership.mts index d0816b219994..da7388cf50f5 100644 --- a/scripts/lib/dist-artifact-ownership.mts +++ b/scripts/lib/dist-artifact-ownership.mts @@ -7,6 +7,7 @@ import { root as openLockRoot } from "@openclaw/fs-safe/root"; import { isDirectRunUrl } from "./direct-run.mjs"; import { hasUnjoinedWork } from "./managed-child-process.mts"; import { findRepoRoot } from "./repo-root.mjs"; +import type { WithDistArtifactOwnership } from "./runtime-artifact-contract.js"; const DIST_ARTIFACT_LOCK_PATH = ".artifacts/dist-artifacts.lock"; const LOCK_POLL_MS = 500; @@ -44,7 +45,7 @@ async function runOwnedDistArtifactEntry(script: string, args: string[]) { } /** The callback must join every writer/reader before returning, including on failure. */ -export async function withDistArtifactOwnership(rootDir: string, run: () => Promise) { +export const withDistArtifactOwnership: WithDistArtifactOwnership = async (rootDir, run) => { const directory = resolveDistArtifactLockPath(fs.realpathSync(rootDir)); // Only the private child entry can inherit its parent's checkout ownership; // the same standalone CLI flow runs without reacquiring that parent's lock. @@ -112,7 +113,7 @@ export async function withDistArtifactOwnership(rootDir: string, run: () => P await lock.release(); } } -} +}; /** * An owning orchestrator calls the same implementation in a separately sized Node diff --git a/scripts/lib/runtime-artifact-contract.ts b/scripts/lib/runtime-artifact-contract.ts new file mode 100644 index 000000000000..40d5a7be503c --- /dev/null +++ b/scripts/lib/runtime-artifact-contract.ts @@ -0,0 +1,12 @@ +// Shared callable contract for compiled callers loading source-checkout writers. +type PreparedBundledPluginRuntime = { + changed: boolean; + publish(assertCurrent: () => void | Promise): Promise; + cleanup(): Promise; +}; + +export type PrepareBundledPluginRuntime = (params: { + repoRoot: string; +}) => PreparedBundledPluginRuntime; + +export type WithDistArtifactOwnership = (rootDir: string, run: () => Promise) => Promise; diff --git a/scripts/lib/static-extension-assets.mts b/scripts/lib/static-extension-assets.mts index 9efdf47e14fa..39b5f1a53b0e 100644 --- a/scripts/lib/static-extension-assets.mts +++ b/scripts/lib/static-extension-assets.mts @@ -20,6 +20,12 @@ type StaticExtensionAssetParams = { warn?: (message: string) => void; }; +export function shouldCopyStaticExtensionAssets( + params: Pick = {}, +) { + return (params.env ?? process.env).OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS !== "0"; +} + function toPosixPath(value: unknown) { return (typeof value === "string" ? value : "").replaceAll("\\", "/"); } @@ -329,11 +335,14 @@ export function copyStaticExtensionAssets(params: StaticExtensionAssetParams = { /** * Copies static assets into the dist-runtime overlay from source or root dist. */ -export function copyStaticExtensionAssetsToRuntimeOverlay(params: StaticExtensionAssetParams = {}) { +export function copyStaticExtensionAssetsToRuntimeOverlay( + params: StaticExtensionAssetParams & { runtimeRoot?: string } = {}, +) { const rootDir = params.rootDir ?? process.cwd(); const fsImpl = params.fs ?? fs; const assets = discoverStaticExtensionRuntimeOverlayAssets({ ...params, rootDir, fs: fsImpl }); - const runtimeExtensionsRoot = path.join(rootDir, "dist-runtime", "extensions"); + const runtimeRoot = params.runtimeRoot ?? path.join(rootDir, "dist-runtime"); + const runtimeExtensionsRoot = path.join(runtimeRoot, "extensions"); if (!fsImpl.existsSync(runtimeExtensionsRoot)) { return; } @@ -346,9 +355,14 @@ export function copyStaticExtensionAssetsToRuntimeOverlay(params: StaticExtensio const srcPath = path.join(rootDir, src); const distPath = path.join(rootDir, dest); const copySourcePath = fsImpl.existsSync(srcPath) ? srcPath : distPath; - const destPath = path.join(rootDir, "dist-runtime", normalizedDest.slice("dist/".length)); + const destPath = path.join(runtimeRoot, normalizedDest.slice("dist/".length)); if (fsImpl.existsSync(copySourcePath)) { fsImpl.mkdirSync(path.dirname(destPath), { recursive: true }); + // Staging links target the final location, so replace the link instead of + // following it into the live output while materializing a static asset. + if (fsImpl.lstatSync(destPath, { throwIfNoEntry: false })?.isSymbolicLink()) { + fsImpl.unlinkSync(destPath); + } fsImpl.copyFileSync(copySourcePath, destPath); } else { warn(`[runtime-postbuild] static asset not found, skipping: ${src}`); diff --git a/scripts/run-node.mts b/scripts/run-node.mts index db8544ad4058..6c81b61e13c9 100644 --- a/scripts/run-node.mts +++ b/scripts/run-node.mts @@ -16,6 +16,7 @@ import { BUNDLED_PLUGIN_PATH_PREFIX, BUNDLED_PLUGIN_ROOT_DIR, } from "./lib/bundled-plugin-paths.mjs"; +import { withDistArtifactOwnership } from "./lib/dist-artifact-ownership.mts"; import { BUILD_STAMP_FILE, RUNTIME_POSTBUILD_STAMP_FILE, @@ -26,6 +27,7 @@ import { sleep } from "./lib/sleep.mjs"; import { discoverStaticExtensionAssets, listStaticExtensionAssetSources, + shouldCopyStaticExtensionAssets, } from "./lib/static-extension-assets.mts"; import { extensionRestartMetadataFiles, @@ -586,7 +588,7 @@ const listRequiredOpenClawExtensionAliasOutputs = (deps: RunNodeRequirementDeps) }; const listRequiredStaticExtensionAssetOutputs = (deps: RunNodeRequirementDeps) => { - if (deps.env.OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS === "0") { + if (!shouldCopyStaticExtensionAssets({ env: deps.env })) { return []; } const distRoot = deps.distRoot; @@ -1461,13 +1463,17 @@ const writeRuntimePostBuildStamp = (deps: RunNodeDeps) => { } }; -const syncRuntimeArtifactsAndStamp = async (deps: RunNodeDeps) => { - const synced = await syncRuntimeArtifacts(deps); - if (synced) { - writeRuntimePostBuildStamp(deps); - } - return synced; -}; +const syncRuntimeArtifactsAndStamp = async (deps: RunNodeDeps) => + withDistArtifactOwnership(deps.cwd, async () => { + if (!resolveRuntimePostBuildRequirement(deps).shouldSync) { + return true; + } + const synced = await syncRuntimeArtifacts(deps); + if (synced) { + writeRuntimePostBuildStamp(deps); + } + return synced; + }); const shouldSkipWatchRuntimeSync = (deps: RunNodeDeps, requirement: RuntimePostBuildRequirement) => deps.env.OPENCLAW_WATCH_MODE === "1" && diff --git a/scripts/runtime-postbuild.mts b/scripts/runtime-postbuild.mts index eb00428f8b69..1626a20f4a41 100644 --- a/scripts/runtime-postbuild.mts +++ b/scripts/runtime-postbuild.mts @@ -10,6 +10,7 @@ import { buildSync } from "esbuild"; import { verifyBuiltPluginControlPlaneModules } from "./check-built-plugin-control-plane-modules.mts"; import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mts"; import { copyHookMetadata, listHookMetadataOutputs } from "./copy-hook-metadata.ts"; +import { withDistArtifactOwnership } from "./lib/dist-artifact-ownership.mts"; import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { escapeRegExp } from "./lib/regexp.mjs"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; @@ -22,6 +23,7 @@ import { copyStaticExtensionAssets, copyStaticExtensionAssetsToRuntimeOverlay, discoverStaticExtensionAssets, + shouldCopyStaticExtensionAssets, } from "./lib/static-extension-assets.mts"; import { isUpdateCompatibilityChunk, @@ -731,11 +733,6 @@ export function writeLegacyCliExitCompatChunks( } } -function shouldCopyStaticExtensionAssets(params: RuntimePostBuildParams) { - const env = params.env ?? process.env; - return env.OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS !== "0"; -} - /** * Runs every runtime postbuild phase after the main dist build. */ @@ -818,5 +815,7 @@ export function runRuntimePostBuild(params: RuntimePostBuildParams = {}) { } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - runRuntimePostBuild(); + await withDistArtifactOwnership(process.cwd(), async () => { + runRuntimePostBuild(); + }); } diff --git a/scripts/stage-bundled-plugin-runtime.mts b/scripts/stage-bundled-plugin-runtime.mts index f3bfb65de376..97b804e29364 100644 --- a/scripts/stage-bundled-plugin-runtime.mts +++ b/scripts/stage-bundled-plugin-runtime.mts @@ -3,8 +3,14 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { withDistArtifactOwnership } from "./lib/dist-artifact-ownership.mts"; import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { isRecord } from "./lib/record-shared.mjs"; +import type { PrepareBundledPluginRuntime } from "./lib/runtime-artifact-contract.js"; +import { + copyStaticExtensionAssetsToRuntimeOverlay, + shouldCopyStaticExtensionAssets, +} from "./lib/static-extension-assets.mts"; import { removePathIfExists } from "./runtime-postbuild-shared.mjs"; type SymlinkType = Parameters[2]; @@ -77,8 +83,13 @@ function ensureSymlink( } } -function symlinkPath(sourcePath: string, targetPath: string, type?: SymlinkType) { - ensureSymlink(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type, sourcePath); +function symlinkPath( + sourcePath: string, + targetPath: string, + finalPath = targetPath, + type?: SymlinkType, +) { + ensureSymlink(relativeSymlinkTarget(sourcePath, finalPath), targetPath, type, sourcePath); } function writeJsonFile(targetPath: string, value: unknown) { @@ -181,7 +192,11 @@ function buildRuntimePluginSdkPackageExports( ); } -function ensureOpenClawExtensionAlias(params: { distExtensionsRoot: string; repoRoot: string }) { +function ensureOpenClawExtensionAlias(params: { + distExtensionsRoot: string; + repoRoot: string; + aliasDir?: string; +}) { const pluginSdkDir = path.join(params.repoRoot, "dist", "plugin-sdk"); if (!fs.existsSync(pluginSdkDir)) { return; @@ -191,7 +206,8 @@ function ensureOpenClawExtensionAlias(params: { distExtensionsRoot: string; repo repoRoot: params.repoRoot, pluginSdkDir, }); - const aliasDir = path.join(params.distExtensionsRoot, "node_modules", "openclaw"); + const finalAliasDir = path.join(params.distExtensionsRoot, "node_modules", "openclaw"); + const aliasDir = params.aliasDir ?? finalAliasDir; const pluginSdkAliasPath = path.join(aliasDir, "plugin-sdk"); fs.mkdirSync(aliasDir, { recursive: true }); writeJsonFile(path.join(aliasDir, "package.json"), { @@ -211,6 +227,7 @@ function ensureOpenClawExtensionAlias(params: { distExtensionsRoot: string; repo writeRuntimeModuleWrapper( path.join(pluginSdkDir, dirent.name), path.join(pluginSdkAliasPath, dirent.name), + path.join(finalAliasDir, "plugin-sdk", dirent.name), ); } } @@ -248,8 +265,8 @@ function hasDefaultExport(sourcePath: string) { return /\bexport\s+default\b/u.test(text) || /\bas\s+default\b/u.test(text); } -function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string) { - const specifier = relativeSymlinkTarget(sourcePath, targetPath).replace(/\\/g, "/"); +function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string, finalPath = targetPath) { + const specifier = relativeSymlinkTarget(sourcePath, finalPath).replace(/\\/g, "/"); const normalizedSpecifier = specifier.startsWith(".") ? specifier : `./${specifier}`; const defaultForwarder = hasDefaultExport(sourcePath) ? [ @@ -278,7 +295,12 @@ function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string) { ); } -function stagePluginRuntimeOverlay(sourceDir: string, targetDir: string, relativeDir = ""): void { +function stagePluginRuntimeOverlay( + sourceDir: string, + targetDir: string, + finalDir = targetDir, + relativeDir = "", +): void { fs.mkdirSync(targetDir, { recursive: true }); for (const dirent of fs.readdirSync(sourceDir, { withFileTypes: true })) { @@ -288,6 +310,7 @@ function stagePluginRuntimeOverlay(sourceDir: string, targetDir: string, relativ const sourcePath = path.join(sourceDir, dirent.name); const targetPath = path.join(targetDir, dirent.name); + const finalPath = path.join(finalDir, dirent.name); const relativePath = path.join(relativeDir, dirent.name).replace(/\\/g, "/"); if (dirent.isDirectory()) { @@ -297,7 +320,7 @@ function stagePluginRuntimeOverlay(sourceDir: string, targetDir: string, relativ copyPathFallback(sourcePath, targetPath); continue; } - stagePluginRuntimeOverlay(sourcePath, targetPath, relativePath); + stagePluginRuntimeOverlay(sourcePath, targetPath, finalPath, relativePath); continue; } @@ -315,7 +338,7 @@ function stagePluginRuntimeOverlay(sourceDir: string, targetDir: string, relativ } if (shouldWrapRuntimeJsFile(sourcePath)) { - writeRuntimeModuleWrapper(sourcePath, targetPath); + writeRuntimeModuleWrapper(sourcePath, targetPath, finalPath); continue; } @@ -324,30 +347,18 @@ function stagePluginRuntimeOverlay(sourceDir: string, targetDir: string, relativ continue; } - symlinkPath(sourcePath, targetPath); + symlinkPath(sourcePath, targetPath, finalPath); } } -/** - * Stages runtime plugin entries and aliases used by packaged bundled plugins. - */ -export function stageBundledPluginRuntime(params: { cwd?: string; repoRoot?: string } = {}) { - const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); - const distRoot = path.join(repoRoot, "dist"); - const runtimeRoot = path.join(repoRoot, "dist-runtime"); - assertRealOutputRoot(distRoot); - assertRealOutputRoot(runtimeRoot); - const distExtensionsRoot = path.join(distRoot, "extensions"); +function generateBundledPluginRuntime(repoRoot: string, runtimeRoot: string, aliasDir?: string) { + const distExtensionsRoot = path.join(repoRoot, "dist", "extensions"); const runtimeExtensionsRoot = path.join(runtimeRoot, "extensions"); - if (!fs.existsSync(distExtensionsRoot)) { - removePathIfExists(runtimeRoot); return; } - - removePathIfExists(runtimeRoot); fs.mkdirSync(runtimeExtensionsRoot, { recursive: true }); - ensureOpenClawExtensionAlias({ repoRoot, distExtensionsRoot }); + ensureOpenClawExtensionAlias({ repoRoot, distExtensionsRoot, aliasDir }); for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) { if (!dirent.isDirectory() || dirent.name === "node_modules") { @@ -356,10 +367,223 @@ export function stageBundledPluginRuntime(params: { cwd?: string; repoRoot?: str const distPluginDir = path.join(distExtensionsRoot, dirent.name); const runtimePluginDir = path.join(runtimeExtensionsRoot, dirent.name); - stagePluginRuntimeOverlay(distPluginDir, runtimePluginDir); + stagePluginRuntimeOverlay( + distPluginDir, + runtimePluginDir, + path.join(repoRoot, "dist-runtime", "extensions", dirent.name), + ); } } -if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - stageBundledPluginRuntime(); +/** Stages runtime plugin entries and aliases used by packaged bundled plugins. */ +export function stageBundledPluginRuntime(params: { cwd?: string; repoRoot?: string } = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const runtimeRoot = path.join(repoRoot, "dist-runtime"); + assertRealOutputRoot(path.join(repoRoot, "dist")); + assertRealOutputRoot(runtimeRoot); + removePathIfExists(runtimeRoot); + generateBundledPluginRuntime(repoRoot, runtimeRoot); +} + +function runtimeTreesEqual(expected: string, actual: string, finalPath = actual): boolean { + const expectedStat = fs.lstatSync(expected, { throwIfNoEntry: false }); + const actualStat = fs.lstatSync(actual, { throwIfNoEntry: false }); + if (!expectedStat || !actualStat) { + return expectedStat === actualStat; + } + if (expectedStat.isSymbolicLink()) { + const target = fs.readlinkSync(expected); + if (actualStat.isSymbolicLink()) { + return ( + (expectedStat.mode & 0o7777) === (actualStat.mode & 0o7777) && + target === fs.readlinkSync(actual) + ); + } + // Windows may have materialized this exact canonical link as a copy. + return ( + process.platform === "win32" && + runtimeTreesEqual(fs.realpathSync(path.resolve(path.dirname(finalPath), target)), actual) + ); + } + if ((expectedStat.mode & 0o7777) !== (actualStat.mode & 0o7777)) { + return false; + } + if (expectedStat.isFile()) { + return ( + actualStat.isFile() && + expectedStat.size === actualStat.size && + fs.readFileSync(expected).equals(fs.readFileSync(actual)) + ); + } + if (!expectedStat.isDirectory() || !actualStat.isDirectory()) { + return false; + } + const expectedNames = fs.readdirSync(expected).toSorted(); + const actualNames = fs.readdirSync(actual).toSorted(); + return ( + expectedNames.length === actualNames.length && + expectedNames.every( + (name, index) => + name === actualNames[index] && + runtimeTreesEqual( + path.join(expected, name), + path.join(actual, name), + path.join(finalPath, name), + ), + ) + ); +} + +type PreparedRuntimeRoot = { + destination: string; + temporary: string; + candidate: string; + previous: string; + changed: boolean; + savedOriginal: boolean; + published: boolean; +}; + +/** Prepare canonical outputs without touching live artifacts. The caller holds + * checkout artifact ownership through preparation, publication, and cleanup. */ +export const prepareBundledPluginRuntime: PrepareBundledPluginRuntime = (params) => { + const repoRoot = fs.realpathSync(params.repoRoot); + const distRoot = path.join(repoRoot, "dist"); + const runtimeRoot = path.join(repoRoot, "dist-runtime"); + const aliasRoot = path.join(distRoot, "extensions", "node_modules", "openclaw"); + for (const root of [distRoot, runtimeRoot, aliasRoot]) { + assertRealOutputRoot(root); + } + const roots: PreparedRuntimeRoot[] = []; + let phase: "prepared" | "publishing" | "published" | "failed" | "cleaned" = "prepared"; + const stageRoot = (destination: string, parent: string) => { + const temporary = fs.mkdtempSync(path.join(fs.realpathSync(parent), ".openclaw-runtime-")); + const entry: PreparedRuntimeRoot = { + destination, + temporary, + candidate: path.join(temporary, "candidate"), + previous: path.join(temporary, "previous"), + changed: false, + savedOriginal: false, + published: false, + }; + roots.push(entry); + return entry; + }; + const cleanupStaging = () => { + const failures: unknown[] = []; + for (const entry of roots) { + if (phase === "failed" && (entry.savedOriginal || entry.published)) { + continue; + } + try { + fs.rmSync(entry.temporary, { recursive: true, force: true }); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, "Runtime staging cleanup failed."); + } + }; + try { + const runtime = stageRoot(runtimeRoot, repoRoot); + const hasAliasInput = + fs.existsSync(path.join(distRoot, "extensions")) && + fs.existsSync(path.join(distRoot, "plugin-sdk")); + const alias = hasAliasInput + ? stageRoot( + aliasRoot, + fs.existsSync(path.dirname(aliasRoot)) ? path.dirname(aliasRoot) : distRoot, + ) + : undefined; + generateBundledPluginRuntime(repoRoot, runtime.candidate, alias?.candidate); + if (shouldCopyStaticExtensionAssets()) { + copyStaticExtensionAssetsToRuntimeOverlay({ + rootDir: repoRoot, + runtimeRoot: runtime.candidate, + }); + } + for (const entry of roots) { + entry.changed = !runtimeTreesEqual(entry.candidate, entry.destination); + } + } catch (error) { + try { + cleanupStaging(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Runtime preparation and cleanup failed.", { + cause: cleanupError, + }); + } + throw error; + } + return { + changed: roots.some((entry) => entry.changed), + async publish(assertCurrent) { + if (phase !== "prepared") { + throw new Error("Prepared runtime publication is no longer available."); + } + phase = "publishing"; + try { + for (const entry of roots.filter((root) => root.changed)) { + await assertCurrent(); + assertRealOutputRoot(entry.destination); + // A root swap stays synchronous so cancellation cannot strand its + // original between saving it and publishing the replacement. + if (fs.existsSync(entry.destination)) { + fs.renameSync(entry.destination, entry.previous); + entry.savedOriginal = true; + } + if (fs.existsSync(entry.candidate)) { + fs.mkdirSync(path.dirname(entry.destination), { recursive: true }); + fs.renameSync(entry.candidate, entry.destination); + entry.published = true; + } + } + phase = "published"; + } catch (error) { + phase = "failed"; + const failures: unknown[] = [error]; + for (const entry of roots.toReversed()) { + if (!entry.savedOriginal && !entry.published) { + continue; + } + try { + await assertCurrent(); + if (entry.published) { + fs.rmSync(entry.destination, { recursive: true, force: true }); + entry.published = false; + } + if (entry.savedOriginal) { + fs.renameSync(entry.previous, entry.destination); + entry.savedOriginal = false; + } + } catch (restoreError) { + failures.push( + new Error(`Runtime original retained at ${entry.previous}`, { cause: restoreError }), + ); + } + } + if (failures.length > 1) { + throw new AggregateError(failures, "Runtime publication and restoration failed.", { + cause: error, + }); + } + throw error; + } + }, + async cleanup() { + if (phase === "publishing") { + throw new Error("Cannot clean runtime staging during publication."); + } + cleanupStaging(); + if (phase !== "failed") { + phase = "cleaned"; + } + }, + }; +}; + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await withDistArtifactOwnership(process.cwd(), async () => stageBundledPluginRuntime()); } diff --git a/scripts/test-built-plugin-singleton.mts b/scripts/test-built-plugin-singleton.mts index 6698fed1c22d..b5a3ede1a6ea 100644 --- a/scripts/test-built-plugin-singleton.mts +++ b/scripts/test-built-plugin-singleton.mts @@ -4,195 +4,218 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { withDistArtifactOwnership } from "./lib/dist-artifact-ownership.mts"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { installProcessWarningFilter } from "./process-warning-filter.mts"; -import { stageBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mts"; +import { prepareBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mts"; installProcessWarningFilter(); const repoRoot = resolveRepoRoot(import.meta.url); -const smokeEntryPath = path.join(repoRoot, "dist", "plugins", "build-smoke-entry.js"); -assert.ok(fs.existsSync(smokeEntryPath), `missing build output: ${smokeEntryPath}`); +async function runBuiltPluginSingletonSmoke(signal: AbortSignal) { + signal.throwIfAborted(); + const smokeEntryPath = path.join(repoRoot, "dist", "plugins", "build-smoke-entry.js"); + assert.ok(fs.existsSync(smokeEntryPath), `missing build output: ${smokeEntryPath}`); -const { - buildPluginRuntimeLoadOptions, - clearPluginCommands, - getPluginCommandSpecs, - getPluginModuleLoaderStats, - loadOpenClawPlugins, - matchPluginCommand, - resolvePluginRuntimeLoadContext, -} = await import(pathToFileURL(smokeEntryPath).href); + const { + buildPluginRuntimeLoadOptions, + clearPluginCommands, + getPluginCommandSpecs, + getPluginModuleLoaderStats, + loadOpenClawPlugins, + matchPluginCommand, + resolvePluginRuntimeLoadContext, + } = await import(pathToFileURL(smokeEntryPath).href); + signal.throwIfAborted(); -assert.equal(typeof loadOpenClawPlugins, "function", "built loader export missing"); -assert.equal(typeof clearPluginCommands, "function", "clearPluginCommands missing"); -assert.equal(typeof getPluginCommandSpecs, "function", "getPluginCommandSpecs missing"); -assert.equal(typeof getPluginModuleLoaderStats, "function", "plugin loader stats missing"); -assert.equal(typeof matchPluginCommand, "function", "matchPluginCommand missing"); + assert.equal(typeof loadOpenClawPlugins, "function", "built loader export missing"); + assert.equal(typeof clearPluginCommands, "function", "clearPluginCommands missing"); + assert.equal(typeof getPluginCommandSpecs, "function", "getPluginCommandSpecs missing"); + assert.equal(typeof getPluginModuleLoaderStats, "function", "plugin loader stats missing"); + assert.equal(typeof matchPluginCommand, "function", "matchPluginCommand missing"); -const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-smoke-")); -const pluginId = "build-smoke-plugin"; -const distPluginDir = path.join(repoRoot, "dist", "extensions", pluginId); -const runtimePluginDir = path.join(repoRoot, "dist-runtime", "extensions", pluginId); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-smoke-")); + const pluginId = "build-smoke-plugin"; + const distPluginDir = path.join(repoRoot, "dist", "extensions", pluginId); + const runtimePluginDir = path.join(repoRoot, "dist-runtime", "extensions", pluginId); -function cleanup() { - clearPluginCommands(); - fs.rmSync(distPluginDir, { recursive: true, force: true }); - fs.rmSync(runtimePluginDir, { recursive: true, force: true }); - fs.rmSync(tempRoot, { recursive: true, force: true }); -} + function cleanup() { + clearPluginCommands(); + fs.rmSync(distPluginDir, { recursive: true, force: true }); + fs.rmSync(runtimePluginDir, { recursive: true, force: true }); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } -process.on("exit", cleanup); -process.on("SIGINT", () => { - cleanup(); - process.exit(130); -}); -process.on("SIGTERM", () => { - cleanup(); - process.exit(143); -}); + process.on("exit", cleanup); -fs.mkdirSync(distPluginDir, { recursive: true }); -fs.writeFileSync( - path.join(distPluginDir, "package.json"), - JSON.stringify( - { - name: "@openclaw/build-smoke-plugin", - type: "module", - openclaw: { - extensions: ["./index.js"], - }, - }, - null, - 2, - ), - "utf8", -); -fs.writeFileSync( - path.join(distPluginDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: pluginId, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }, - null, - 2, - ), - "utf8", -); -fs.writeFileSync( - path.join(distPluginDir, "index.js"), - [ - "import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk/plugin-entry';", - "", - "export default {", - ` id: ${JSON.stringify(pluginId)},`, - " configSchema: emptyPluginConfigSchema(),", - " register(api) {", - " api.registerCommand({", - " name: 'pair',", - " description: 'Pair a device',", - " acceptsArgs: true,", - " nativeNames: { telegram: 'pair', discord: 'pair' },", - " async handler({ args }) {", - " return { text: `paired:${args ?? ''}` };", - " },", - " });", - " },", - "};", - "", - ].join("\n"), - "utf8", -); - -stageBundledPluginRuntime({ repoRoot }); - -const runtimeEntryPath = path.join(runtimePluginDir, "index.js"); -assert.ok(fs.existsSync(runtimeEntryPath), "runtime overlay entry missing"); -const smsRuntimeEntryPath = path.join(repoRoot, "dist-runtime", "extensions", "sms", "index.js"); -assert.ok(fs.existsSync(smsRuntimeEntryPath), "compiled SMS runtime entry missing"); -assert.ok( - fs.existsSync(path.join(repoRoot, "dist-runtime", "extensions", "mxc", "mxc-spawn-launcher.mjs")), - "compiled MXC runtime asset missing", -); -assert.equal( - fs.existsSync(path.join(repoRoot, "dist-runtime", "plugins", "commands.js")), - false, - "dist-runtime must not stage a duplicate commands module", -); - -clearPluginCommands(); - -const smsStatsBefore = getPluginModuleLoaderStats(); -// Prepared runtimes carry this context into late, plugin-scoped loads. Prove that the load-options -// projection retains the built-artifact choice instead of reopening source transformation. -const smsRegistry = loadOpenClawPlugins( - buildPluginRuntimeLoadOptions( - resolvePluginRuntimeLoadContext({ - config: { - plugins: { - enabled: true, - allow: ["sms"], - entries: { sms: { enabled: true } }, + try { + fs.mkdirSync(distPluginDir, { recursive: true }); + fs.writeFileSync( + path.join(distPluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/build-smoke-plugin", + type: "module", + openclaw: { + extensions: ["./index.js"], + }, }, - }, - env: { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "extensions"), - }, - workspaceDir: tempRoot, - }), - { cache: false, onlyPluginIds: ["sms"] }, - ), -); -const smsRecord = smsRegistry.plugins.find((entry: { id: string }) => entry.id === "sms"); -assert.ok(smsRecord, "SMS plugin missing from registry"); -assert.equal(smsRecord.status, "loaded", smsRecord.error ?? "SMS plugin failed to load"); -const smsStatsAfter = getPluginModuleLoaderStats(); -assert.ok( - smsStatsAfter.nativeHits > smsStatsBefore.nativeHits, - "compiled SMS runtime did not use native loading", -); -for (const counter of [ - "nativeMisses", - "sourceTransformForced", - "sourceTransformFallbacks", -] as const) { - assert.equal( - smsStatsAfter[counter], - smsStatsBefore[counter], - `compiled SMS runtime changed ${counter}`, - ); -} -assert.equal( - smsStatsAfter.topSourceTransformTargets.some(({ target }: { target: string }) => - target.replaceAll("\\", "/").includes("/extensions/sms/"), - ), - false, - "compiled SMS runtime reached the source transformer", -); + null, + 2, + ), + "utf8", + ); + fs.writeFileSync( + path.join(distPluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: pluginId, + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync( + path.join(distPluginDir, "index.js"), + [ + "import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk/plugin-entry';", + "", + "export default {", + ` id: ${JSON.stringify(pluginId)},`, + " configSchema: emptyPluginConfigSchema(),", + " register(api) {", + " api.registerCommand({", + " name: 'pair',", + " description: 'Pair a device',", + " acceptsArgs: true,", + " nativeNames: { telegram: 'pair', discord: 'pair' },", + " async handler({ args }) {", + " return { text: `paired:${args ?? ''}` };", + " },", + " });", + " },", + "};", + "", + ].join("\n"), + "utf8", + ); -// Exercise the real built load owner with omitted preferences, as provider and -// tool callers do. Source-owned metadata must not force source execution. -const { resolvePluginDiscoveryProvidersRuntime } = await import( - pathToFileURL(path.join(repoRoot, "dist", "plugins", "provider-discovery.runtime.js")).href -); -const { createPluginMetadataSnapshotFixture } = - await import("../src/plugins/plugin-metadata.test-support.js"); -const { withPluginRuntimeGenerationScope } = - await import("../src/plugins/runtime/generation-scope.js"); -const { setPluginRuntimeLoadContext } = await import("../src/plugins/runtime/load-context.js"); -const artifactPluginId = "build-artifact-selection"; -const artifactSourceRoot = path.join(tempRoot, "extensions", artifactPluginId); -const artifactBuiltRoot = path.join(tempRoot, "dist", "extensions", artifactPluginId); -fs.mkdirSync(artifactSourceRoot, { recursive: true }); -fs.mkdirSync(artifactBuiltRoot, { recursive: true }); -fs.mkdirSync(path.join(artifactSourceRoot, "dist")); -const artifactEntry = (label: string) => `export default { + const runtime = prepareBundledPluginRuntime({ repoRoot }); + try { + await runtime.publish(() => signal.throwIfAborted()); + } catch (error) { + try { + await runtime.cleanup(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Smoke runtime setup and cleanup failed.", { + cause: cleanupError, + }); + } + throw error; + } + await runtime.cleanup(); + signal.throwIfAborted(); + + const runtimeEntryPath = path.join(runtimePluginDir, "index.js"); + assert.ok(fs.existsSync(runtimeEntryPath), "runtime overlay entry missing"); + const smsRuntimeEntryPath = path.join( + repoRoot, + "dist-runtime", + "extensions", + "sms", + "index.js", + ); + assert.ok(fs.existsSync(smsRuntimeEntryPath), "compiled SMS runtime entry missing"); + assert.ok( + fs.existsSync( + path.join(repoRoot, "dist-runtime", "extensions", "mxc", "mxc-spawn-launcher.mjs"), + ), + "compiled MXC runtime asset missing", + ); + assert.equal( + fs.existsSync(path.join(repoRoot, "dist-runtime", "plugins", "commands.js")), + false, + "dist-runtime must not stage a duplicate commands module", + ); + + clearPluginCommands(); + + const smsStatsBefore = getPluginModuleLoaderStats(); + // Prepared runtimes carry this context into late, plugin-scoped loads. Prove that the load-options + // projection retains the built-artifact choice instead of reopening source transformation. + const smsRegistry = loadOpenClawPlugins( + buildPluginRuntimeLoadOptions( + resolvePluginRuntimeLoadContext({ + config: { + plugins: { + enabled: true, + allow: ["sms"], + entries: { sms: { enabled: true } }, + }, + }, + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "extensions"), + }, + workspaceDir: tempRoot, + }), + { cache: false, onlyPluginIds: ["sms"] }, + ), + ); + const smsRecord = smsRegistry.plugins.find((entry: { id: string }) => entry.id === "sms"); + assert.ok(smsRecord, "SMS plugin missing from registry"); + assert.equal(smsRecord.status, "loaded", smsRecord.error ?? "SMS plugin failed to load"); + const smsStatsAfter = getPluginModuleLoaderStats(); + assert.ok( + smsStatsAfter.nativeHits > smsStatsBefore.nativeHits, + "compiled SMS runtime did not use native loading", + ); + for (const counter of [ + "nativeMisses", + "sourceTransformForced", + "sourceTransformFallbacks", + ] as const) { + assert.equal( + smsStatsAfter[counter], + smsStatsBefore[counter], + `compiled SMS runtime changed ${counter}`, + ); + } + assert.equal( + smsStatsAfter.topSourceTransformTargets.some(({ target }: { target: string }) => + target.replaceAll("\\", "/").includes("/extensions/sms/"), + ), + false, + "compiled SMS runtime reached the source transformer", + ); + + // Exercise the real built load owner with omitted preferences, as provider and + // tool callers do. Source-owned metadata must not force source execution. + const { resolvePluginDiscoveryProvidersRuntime } = await import( + pathToFileURL(path.join(repoRoot, "dist", "plugins", "provider-discovery.runtime.js")).href + ); + signal.throwIfAborted(); + const { createPluginMetadataSnapshotFixture } = + await import("../src/plugins/plugin-metadata.test-support.js"); + signal.throwIfAborted(); + const { withPluginRuntimeGenerationScope } = + await import("../src/plugins/runtime/generation-scope.js"); + signal.throwIfAborted(); + const { setPluginRuntimeLoadContext } = await import("../src/plugins/runtime/load-context.js"); + signal.throwIfAborted(); + const artifactPluginId = "build-artifact-selection"; + const artifactSourceRoot = path.join(tempRoot, "extensions", artifactPluginId); + const artifactBuiltRoot = path.join(tempRoot, "dist", "extensions", artifactPluginId); + fs.mkdirSync(artifactSourceRoot, { recursive: true }); + fs.mkdirSync(artifactBuiltRoot, { recursive: true }); + fs.mkdirSync(path.join(artifactSourceRoot, "dist")); + const artifactEntry = (label: string) => `export default { id: ${JSON.stringify(artifactPluginId)}, register(api) { api.registerProvider({ id: ${JSON.stringify(artifactPluginId)}, label: ${JSON.stringify(label)}, auth: [] }); @@ -202,216 +225,267 @@ const artifactEntry = (label: string) => `export default { }); } };\n`; -const artifactSource = path.join(artifactSourceRoot, "index.ts"); -fs.writeFileSync(artifactSource, artifactEntry("source")); -fs.writeFileSync(path.join(artifactBuiltRoot, "index.js"), artifactEntry("compiled")); -fs.writeFileSync(path.join(artifactSourceRoot, "dist", "index.js"), artifactEntry("package-local")); -fs.writeFileSync(path.join(artifactSourceRoot, "package.json"), JSON.stringify({ type: "module" })); -for (const [rootDir, extension, label] of [ - [artifactSourceRoot, ".ts", "source"], - [artifactBuiltRoot, ".js", "compiled"], -] as const) { - fs.writeFileSync( - path.join(rootDir, `provider-discovery${extension}`), - `export default { + const artifactSource = path.join(artifactSourceRoot, "index.ts"); + fs.writeFileSync(artifactSource, artifactEntry("source")); + fs.writeFileSync(path.join(artifactBuiltRoot, "index.js"), artifactEntry("compiled")); + fs.writeFileSync( + path.join(artifactSourceRoot, "dist", "index.js"), + artifactEntry("package-local"), + ); + fs.writeFileSync( + path.join(artifactSourceRoot, "package.json"), + JSON.stringify({ type: "module" }), + ); + for (const [rootDir, extension, label] of [ + [artifactSourceRoot, ".ts", "source"], + [artifactBuiltRoot, ".js", "compiled"], + ] as const) { + fs.writeFileSync( + path.join(rootDir, `provider-discovery${extension}`), + `export default { id: ${JSON.stringify(artifactPluginId)}, label: ${JSON.stringify(label)}, auth: [], catalog: { run: async () => ({ providers: {} }) } };\n`, - ); -} -fs.writeFileSync( - path.join(artifactBuiltRoot, "package.json"), - JSON.stringify({ - type: "module", - openclaw: { extensions: ["./index.js"] }, - }), -); -const artifactConfig = { - plugins: { allow: [artifactPluginId], entries: { [artifactPluginId]: { enabled: true } } }, -}; -const artifactManifest = { - id: artifactPluginId, - rootDir: artifactSourceRoot, - source: artifactSource, - origin: "bundled" as const, - channels: [], - providers: [artifactPluginId], - cliBackends: [], - skills: [], - hooks: [], - contracts: { tools: ["artifact_probe"] }, - manifestPath: path.join(artifactSourceRoot, "openclaw.plugin.json"), - providerDiscoverySource: path.join(artifactSourceRoot, "provider-discovery.ts"), - configSchema: { type: "object", properties: {}, additionalProperties: false }, - packageManifest: { extensions: ["./index.ts"], build: { bundledDist: false } }, -}; -const artifactManifestRegistry = { - plugins: [artifactManifest], - diagnostics: [], -}; -const artifactMetadataSnapshot = createPluginMetadataSnapshotFixture(artifactManifestRegistry); -for (const toolDiscovery of [false, true]) { - for (const preferBuiltPluginArtifacts of [undefined, false]) { - const values = { - config: artifactConfig, - env: { ...process.env, OPENCLAW_STATE_DIR: path.join(tempRoot, "artifact-state") }, - manifestRegistry: artifactManifestRegistry, - installRecords: {}, - preferBuiltPluginArtifacts, - workspaceDir: tempRoot, - }; - const options = toolDiscovery - ? buildPluginRuntimeLoadOptions(resolvePluginRuntimeLoadContext(values)) - : values; - const selected = loadOpenClawPlugins({ - ...options, - cache: false, - activate: false, - toolDiscovery, - onlyPluginIds: [artifactPluginId], - }); - const label = preferBuiltPluginArtifacts === false ? "source" : "compiled"; - assert.equal(selected.plugins[0]?.status, "loaded", selected.plugins[0]?.error); - assert.equal(selected.providers[0]?.provider.label, label); - const tool = selected.tools[0]?.factory({ config: artifactConfig }); - assert.equal(tool?.description, label); - if (!toolDiscovery) { - setPluginRuntimeLoadContext(selected, resolvePluginRuntimeLoadContext(values)); - const providers = withPluginRuntimeGenerationScope( - { metadataSnapshot: artifactMetadataSnapshot, pluginRegistry: selected }, - () => - resolvePluginDiscoveryProvidersRuntime({ - config: artifactConfig, - pluginMetadataSnapshot: artifactMetadataSnapshot, - onlyPluginIds: [artifactPluginId], - discoveryEntriesOnly: true, - }), - ); - assert.equal( - providers[0]?.label, - label, - "provider discovery chose a different artifact policy", ); } - } -} - -// Implicit built-host policy leaves installed source alone; explicit true can -// use its package-local output. Neither cache call order may contaminate the other. -for (const [orderIndex, preferences] of [ - [undefined, true], - [true, undefined], -].entries()) { - const options = { - config: artifactConfig, - env: { ...process.env, OPENCLAW_STATE_DIR: path.join(tempRoot, "artifact-state") }, - workspaceDir: path.join(tempRoot, `cache-order-${orderIndex}`), - manifestRegistry: { - ...artifactManifestRegistry, - plugins: [{ ...artifactManifest, origin: "global" }], - }, - installRecords: {}, - onlyPluginIds: [artifactPluginId], - cache: true, - activate: false, - }; - const registries = preferences.map((preferBuiltPluginArtifacts) => { - const selected = loadOpenClawPlugins({ ...options, preferBuiltPluginArtifacts }); - const label = preferBuiltPluginArtifacts ? "package-local" : "source"; - assert.equal(selected.plugins[0]?.status, "loaded", selected.plugins[0]?.error); - assert.equal(selected.providers[0]?.provider.label, label, `cache call order ${orderIndex}`); - assert.equal(selected.tools[0]?.factory({ config: artifactConfig })?.description, label); - return selected; - }); - for (const [index, preferBuiltPluginArtifacts] of preferences.entries()) { - assert.equal( - loadOpenClawPlugins({ ...options, preferBuiltPluginArtifacts }), - registries[index], - "repeated selection did not reuse its own cached registry", + fs.writeFileSync( + path.join(artifactBuiltRoot, "package.json"), + JSON.stringify({ + type: "module", + openclaw: { extensions: ["./index.js"] }, + }), ); + const artifactConfig = { + plugins: { allow: [artifactPluginId], entries: { [artifactPluginId]: { enabled: true } } }, + }; + const artifactManifest = { + id: artifactPluginId, + rootDir: artifactSourceRoot, + source: artifactSource, + origin: "bundled" as const, + channels: [], + providers: [artifactPluginId], + cliBackends: [], + skills: [], + hooks: [], + contracts: { tools: ["artifact_probe"] }, + manifestPath: path.join(artifactSourceRoot, "openclaw.plugin.json"), + providerDiscoverySource: path.join(artifactSourceRoot, "provider-discovery.ts"), + configSchema: { type: "object", properties: {}, additionalProperties: false }, + packageManifest: { extensions: ["./index.ts"], build: { bundledDist: false } }, + }; + const artifactManifestRegistry = { + plugins: [artifactManifest], + diagnostics: [], + }; + const artifactMetadataSnapshot = createPluginMetadataSnapshotFixture(artifactManifestRegistry); + for (const toolDiscovery of [false, true]) { + for (const preferBuiltPluginArtifacts of [undefined, false]) { + const values = { + config: artifactConfig, + env: { ...process.env, OPENCLAW_STATE_DIR: path.join(tempRoot, "artifact-state") }, + manifestRegistry: artifactManifestRegistry, + installRecords: {}, + preferBuiltPluginArtifacts, + workspaceDir: tempRoot, + }; + const options = toolDiscovery + ? buildPluginRuntimeLoadOptions(resolvePluginRuntimeLoadContext(values)) + : values; + const selected = loadOpenClawPlugins({ + ...options, + cache: false, + activate: false, + toolDiscovery, + onlyPluginIds: [artifactPluginId], + }); + const label = preferBuiltPluginArtifacts === false ? "source" : "compiled"; + assert.equal(selected.plugins[0]?.status, "loaded", selected.plugins[0]?.error); + assert.equal(selected.providers[0]?.provider.label, label); + const tool = selected.tools[0]?.factory({ config: artifactConfig }); + assert.equal(tool?.description, label); + if (!toolDiscovery) { + setPluginRuntimeLoadContext(selected, resolvePluginRuntimeLoadContext(values)); + const providers = withPluginRuntimeGenerationScope( + { metadataSnapshot: artifactMetadataSnapshot, pluginRegistry: selected }, + () => + resolvePluginDiscoveryProvidersRuntime({ + config: artifactConfig, + pluginMetadataSnapshot: artifactMetadataSnapshot, + onlyPluginIds: [artifactPluginId], + discoveryEntriesOnly: true, + }), + ); + assert.equal( + providers[0]?.label, + label, + "provider discovery chose a different artifact policy", + ); + } + } + } + + // Implicit built-host policy leaves installed source alone; explicit true can + // use its package-local output. Neither cache call order may contaminate the other. + for (const [orderIndex, preferences] of [ + [undefined, true], + [true, undefined], + ].entries()) { + const options = { + config: artifactConfig, + env: { ...process.env, OPENCLAW_STATE_DIR: path.join(tempRoot, "artifact-state") }, + workspaceDir: path.join(tempRoot, `cache-order-${orderIndex}`), + manifestRegistry: { + ...artifactManifestRegistry, + plugins: [{ ...artifactManifest, origin: "global" }], + }, + installRecords: {}, + onlyPluginIds: [artifactPluginId], + cache: true, + activate: false, + }; + const registries = preferences.map((preferBuiltPluginArtifacts) => { + const selected = loadOpenClawPlugins({ ...options, preferBuiltPluginArtifacts }); + const label = preferBuiltPluginArtifacts ? "package-local" : "source"; + assert.equal(selected.plugins[0]?.status, "loaded", selected.plugins[0]?.error); + assert.equal( + selected.providers[0]?.provider.label, + label, + `cache call order ${orderIndex}`, + ); + assert.equal(selected.tools[0]?.factory({ config: artifactConfig })?.description, label); + return selected; + }); + for (const [index, preferBuiltPluginArtifacts] of preferences.entries()) { + assert.equal( + loadOpenClawPlugins({ ...options, preferBuiltPluginArtifacts }), + registries[index], + "repeated selection did not reuse its own cached registry", + ); + } + } + + clearPluginCommands(); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: tempRoot, + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "dist-runtime", "extensions"), + }, + config: { + plugins: { + enabled: true, + allow: [pluginId], + entries: { + [pluginId]: { enabled: true }, + }, + }, + }, + }); + + const record = registry.plugins.find((entry: { id: string }) => entry.id === pluginId); + assert.ok(record, "smoke plugin missing from registry"); + assert.equal(record.status, "loaded", record.error ?? "smoke plugin failed to load"); + + assert.deepEqual( + getPluginCommandSpecs().filter((command: { name: string }) => command.name === "pair"), + [{ name: "pair", description: "Pair a device", acceptsArgs: true }], + ); + + const match = matchPluginCommand("/pair now"); + assert.ok(match, "canonical built command registry did not receive the command"); + assert.equal(match.args, "now"); + const result = await match.command.handler({ args: match.args }); + signal.throwIfAborted(); + assert.deepEqual(result, { text: "paired:now" }); + + // Keep these imports after the cold native checks so they cannot prewarm the loader. + const { buildBundleMcpToolsFromCatalog } = await import( + pathToFileURL(path.join(repoRoot, "dist", "agents", "agent-bundle-mcp-materialize.js")).href + ); + signal.throwIfAborted(); + const { getPluginToolMeta } = await import( + pathToFileURL(path.join(repoRoot, "dist", "plugins", "tool-metadata.js")).href + ); + signal.throwIfAborted(); + const { getPluginToolMeta: getSdkPluginToolMeta } = await import( + pathToFileURL(path.join(repoRoot, "dist", "plugin-sdk", "agent-harness-runtime.js")).href + ); + signal.throwIfAborted(); + const [mcpTool] = buildBundleMcpToolsFromCatalog({ + catalog: { + version: 1, + generatedAt: 0, + servers: { + "build-smoke-mcp": { + serverName: "build-smoke-mcp", + safeServerName: "build-smoke-mcp", + launchSummary: "build smoke inventory fixture", + toolCount: 1, + }, + }, + tools: [ + { + serverName: "build-smoke-mcp", + safeServerName: "build-smoke-mcp", + toolName: "lookup", + inputSchema: { type: "object", properties: {} }, + fallbackDescription: "Look up a build smoke inventory item", + }, + ], + }, + }); + assert.ok(mcpTool, "compiled MCP materializer did not produce a tool"); + const mcpMetadata = getPluginToolMeta(mcpTool); + assert.ok(mcpMetadata, "canonical built metadata owner did not receive MCP tool metadata"); + assert.equal(mcpMetadata.pluginId, "bundle-mcp"); + assert.equal(mcpMetadata.mcp?.serverName, "build-smoke-mcp"); + assert.equal(mcpMetadata.mcp?.safeServerName, "build-smoke-mcp"); + assert.equal(mcpMetadata.mcp?.toolName, "lookup"); + assert.equal(mcpMetadata.mcp?.operation, "tool"); + assert.strictEqual( + getSdkPluginToolMeta(mcpTool), + mcpMetadata, + "public agent-harness-runtime SDK did not read the canonical MCP metadata record", + ); + } finally { + process.off("exit", cleanup); + cleanup(); } + signal.throwIfAborted(); + const remaining = prepareBundledPluginRuntime({ repoRoot }); + const changed = remaining.changed; + await remaining.cleanup(); + assert.equal(changed, false, "singleton smoke left noncanonical runtime artifacts"); + process.stdout.write("[build-smoke] built plugin singleton smoke passed\n"); } -clearPluginCommands(); - -const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir: tempRoot, - env: { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "dist-runtime", "extensions"), - }, - config: { - plugins: { - enabled: true, - allow: [pluginId], - entries: { - [pluginId]: { enabled: true }, - }, - }, - }, -}); - -const record = registry.plugins.find((entry: { id: string }) => entry.id === pluginId); -assert.ok(record, "smoke plugin missing from registry"); -assert.equal(record.status, "loaded", record.error ?? "smoke plugin failed to load"); - -assert.deepEqual( - getPluginCommandSpecs().filter((command: { name: string }) => command.name === "pair"), - [{ name: "pair", description: "Pair a device", acceptsArgs: true }], -); - -const match = matchPluginCommand("/pair now"); -assert.ok(match, "canonical built command registry did not receive the command"); -assert.equal(match.args, "now"); -const result = await match.command.handler({ args: match.args }); -assert.deepEqual(result, { text: "paired:now" }); - -// Keep these imports after the cold native checks so they cannot prewarm the loader. -const { buildBundleMcpToolsFromCatalog } = await import( - pathToFileURL(path.join(repoRoot, "dist", "agents", "agent-bundle-mcp-materialize.js")).href -); -const { getPluginToolMeta } = await import( - pathToFileURL(path.join(repoRoot, "dist", "plugins", "tool-metadata.js")).href -); -const { getPluginToolMeta: getSdkPluginToolMeta } = await import( - pathToFileURL(path.join(repoRoot, "dist", "plugin-sdk", "agent-harness-runtime.js")).href -); -const [mcpTool] = buildBundleMcpToolsFromCatalog({ - catalog: { - version: 1, - generatedAt: 0, - servers: { - "build-smoke-mcp": { - serverName: "build-smoke-mcp", - safeServerName: "build-smoke-mcp", - launchSummary: "build smoke inventory fixture", - toolCount: 1, - }, - }, - tools: [ - { - serverName: "build-smoke-mcp", - safeServerName: "build-smoke-mcp", - toolName: "lookup", - inputSchema: { type: "object", properties: {} }, - fallbackDescription: "Look up a build smoke inventory item", - }, - ], - }, -}); -assert.ok(mcpTool, "compiled MCP materializer did not produce a tool"); -const mcpMetadata = getPluginToolMeta(mcpTool); -assert.ok(mcpMetadata, "canonical built metadata owner did not receive MCP tool metadata"); -assert.equal(mcpMetadata.pluginId, "bundle-mcp"); -assert.equal(mcpMetadata.mcp?.serverName, "build-smoke-mcp"); -assert.equal(mcpMetadata.mcp?.safeServerName, "build-smoke-mcp"); -assert.equal(mcpMetadata.mcp?.toolName, "lookup"); -assert.equal(mcpMetadata.mcp?.operation, "tool"); -assert.strictEqual( - getSdkPluginToolMeta(mcpTool), - mcpMetadata, - "public agent-harness-runtime SDK did not read the canonical MCP metadata record", -); - -process.stdout.write("[build-smoke] built plugin singleton smoke passed\n"); +const controller = new AbortController(); +let exitCode = 0; +const cancel = (code: number) => { + if (!controller.signal.aborted) { + exitCode = code; + controller.abort(new Error("Built plugin singleton smoke canceled.")); + } +}; +const onSigint = () => cancel(130); +const onSigterm = () => cancel(143); +try { + await withDistArtifactOwnership(repoRoot, async () => { + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + // Imports cannot be interrupted; keep ownership until each pending read joins. + await runBuiltPluginSingletonSmoke(controller.signal); + }); + controller.signal.throwIfAborted(); +} catch (error) { + if (!controller.signal.aborted || error !== controller.signal.reason) { + throw error; + } + process.exitCode = exitCode; +} finally { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); +} diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index e53606ff329f..af3f9732a512 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -717,7 +717,8 @@ const { openOpenClawStateDatabase, closeOpenClawStateDatabaseForTest } = await import("../state/openclaw-state-db.js"); // Real recovery dependencies need the initialized runtime and child-process mocks. const { runUpdateFailureTriage } = await import("../infra/update-triage.js"); -const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); +const { resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } = + await import("../infra/openclaw-root.js"); const { resolveGatewayInstallEntrypoint } = await import("../daemon/gateway-entrypoint.js"); const { mutateConfigFileWithRetry, @@ -4085,6 +4086,9 @@ describe("update-cli", () => { "keeps downgrade consent separate from --yes (explicit=%s)", async (acceptCapabilities) => { const downgradedRoot = createCaseDir("openclaw-downgraded-consent-root"); + vi.mocked(resolveUpdateInstallKind).mockImplementation(async (root) => + root === downgradedRoot ? "package" : "git", + ); setupUpdatedRootRefresh({ targetVersion: "2026.4.10", gatewayUpdateImpl: async () => @@ -4124,6 +4128,9 @@ describe("update-cli", () => { it("pins the compatibility host version to the downgraded target during current-process post-core plugin convergence (#87914)", async () => { const downgradedRoot = createCaseDir("openclaw-downgraded-compat-root"); + vi.mocked(resolveUpdateInstallKind).mockImplementation(async (root) => + root === downgradedRoot ? "package" : "git", + ); setupUpdatedRootRefresh({ targetVersion: "2026.4.10", gatewayUpdateImpl: async () => @@ -6903,6 +6910,22 @@ describe("update-cli", () => { ] as const)( "$name", async ({ installKind, options, storedChannel, expectedChannel, expectedPersistedChannel }) => { + let gitRoutingRoot: string | undefined; + if (installKind === "git" && expectedChannel !== undefined) { + const root = createCaseDir("openclaw-routing-legacy-git"); + await writeOpenClawPackageFixture(root, "1.0.0", { + git: true, + entrySource: "export {};\n", + }); + mockFileBackedPathExists(); + gitRoutingRoot = await fs.realpath(root); + vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(gitRoutingRoot); + vi.mocked(resolveOpenClawPackageRootSync).mockReturnValue(gitRoutingRoot); + vi.mocked(resolveUpdateInstallKind).mockImplementation(async (target) => { + expect(target).toBe(gitRoutingRoot); + return "git"; + }); + } if (installKind === "package" && expectedChannel === undefined) { await mockPackageInstallAtCaseDir(); } @@ -6912,7 +6935,9 @@ describe("update-cli", () => { vi.mocked(resolveUpdateInstallIdentity).mockResolvedValue({ installKind: "git" }); } if (installKind === "git" || expectedChannel !== undefined) { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode: "git" })); + vi.mocked(runGatewayUpdate).mockResolvedValue( + makeOkUpdateResult({ mode: "git", root: gitRoutingRoot }), + ); } if (storedChannel) { vi.mocked(readConfigFileSnapshot).mockResolvedValue({ @@ -6935,6 +6960,12 @@ describe("update-cli", () => { entrySource: "export {};\n", }); const canonicalGitRoot = await fs.realpath(gitRoot); + vi.mocked(resolveUpdateInstallKind).mockImplementation(async (root) => + root === canonicalGitRoot ? "git" : "package", + ); + vi.mocked(resolveUpdateInstallIdentity).mockImplementation(async ({ root }) => ({ + installKind: root === canonicalGitRoot ? "git" : "package", + })); mockFileBackedPathExists(); mockNpmGlobalCommands(nodeModules, undefined, canonicalGitRoot); mockGitUpdateAfterMutation( diff --git a/src/cli/update-cli/update-command-convergence.ts b/src/cli/update-cli/update-command-convergence.ts index 44e558a6148d..6b3778d388bf 100644 --- a/src/cli/update-cli/update-command-convergence.ts +++ b/src/cli/update-cli/update-command-convergence.ts @@ -22,6 +22,7 @@ import { continuePostCoreUpdateInFreshProcess, shouldResumePostCoreUpdateInFreshProcess, } from "./update-command-post-core.js"; +import { completeSourceUpdateRuntime } from "./update-command-runtime.js"; import { withOwnedManagedUpdateEnv } from "./update-command-service-env.js"; export async function convergeUpdatePlugins(params: { @@ -167,7 +168,13 @@ export async function convergeUpdatePlugins(params: { } if (!pluginsUpdatedInFreshProcess) { - postCorePluginUpdate = await withPluginLifecycleLease({}, async () => { + postCorePluginUpdate = await withPluginLifecycleLease({}, async (lease) => { + await completeSourceUpdateRuntime({ + root: postUpdateRoot, + timeoutMs: params.updateStepTimeoutMs, + lease, + beforePersistentEffect: params.beforePersistentEffect, + }); const preparedConfig = await preparePostCorePluginConfig({ requestedChannel: params.requestedChannel, preUpdateConfig, diff --git a/src/cli/update-cli/update-command-finalize.ts b/src/cli/update-cli/update-command-finalize.ts index e44266a08218..e064d5151efd 100644 --- a/src/cli/update-cli/update-command-finalize.ts +++ b/src/cli/update-cli/update-command-finalize.ts @@ -53,6 +53,7 @@ import { type PostCorePluginUpdateResult, } from "./update-command-plugins.js"; import { UpdateCommandFailure } from "./update-command-result.js"; +import { completeSourceUpdateRuntime } from "./update-command-runtime.js"; import { resolveServiceRefreshEnv, withUpdateInProgressEnv } from "./update-command-service-env.js"; import { reportPreMutationUpdateResult } from "./update-command-terminal.js"; import { withUpdateFailureTriage } from "./update-command-triage.js"; @@ -203,6 +204,11 @@ async function updateFinalizeCommandInternal( lifecycle.recordWarnings(doctorWarnings); }; + if ((await resolveUpdateInstallKind(root)) === "git") { + await withPluginLifecycleLease({}, async (lease) => { + await completeSourceUpdateRuntime({ root, timeoutMs: lifecycle.budget("plugins"), lease }); + }); + } const initialPluginUpdate = await withPrePluginUpdateDoctorEnv(async () => { await lifecycle.run("configSnapshot", createUpdateConfigSnapshot); await lifecycle.run("doctor", () => diff --git a/src/cli/update-cli/update-command-lease.test-support.ts b/src/cli/update-cli/update-command-lease.test-support.ts index d3beaf23cb08..e1418164fef2 100644 --- a/src/cli/update-cli/update-command-lease.test-support.ts +++ b/src/cli/update-cli/update-command-lease.test-support.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { once } from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../config/types.plugins.js"; import type { PostCorePluginUpdateResult } from "./update-command-plugins.js"; @@ -16,6 +17,7 @@ export type LeaseScenario = { hostVersion?: string; writerConfig?: OpenClawConfig; writerRecords?: Record; + runtimeRoot?: string; }; // A narrow child substitutes for the CLI, not for its cross-process lease. @@ -42,6 +44,23 @@ export async function runUpdateLeaseChild(): Promise { await record("writer-committed"); }; const command = process.argv[2]; + if (scenario.runtimeRoot && (command === "doctor" || command === "runtime-proof")) { + const runtimeRoot = path.join(scenario.runtimeRoot, "dist-runtime", "extensions", "demo"); + const runtime = await import(pathToFileURL(path.join(runtimeRoot, "index.js")).href); + const metadata = JSON.parse(await fs.readFile(path.join(runtimeRoot, "package.json"), "utf8")); + const sdk = await import( + pathToFileURL( + path.join(scenario.runtimeRoot, "dist/extensions/node_modules/openclaw/plugin-sdk/demo.js"), + ).href + ); + assert.equal(runtime.generation, "candidate"); + assert.equal(metadata.generation, "candidate"); + assert.equal(sdk.generation, "candidate"); + await record(`runtime-proof:${command}`); + if (command === "runtime-proof") { + return; + } + } if (command === "config") { assert.deepEqual(process.argv.slice(2), ["config", "validate", "--json"]); assert.equal(process.env.OPENCLAW_UPDATE_IN_PROGRESS, "0"); diff --git a/src/cli/update-cli/update-command-lease.test.ts b/src/cli/update-cli/update-command-lease.test.ts index b70aeff33b91..dfa3ec2a27bb 100644 --- a/src/cli/update-cli/update-command-lease.test.ts +++ b/src/cli/update-cli/update-command-lease.test.ts @@ -1,7 +1,10 @@ import { spawn } from "node:child_process"; +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { stageBundledPluginRuntime } from "../../../scripts/stage-bundled-plugin-runtime.mts"; import { createDeferred } from "../../../test/helpers/promise.js"; import { readConfigFileSnapshot } from "../../config/config.js"; import { resolveFutureConfigActionBlock } from "../../config/future-version-guard.js"; @@ -35,6 +38,15 @@ const mocks = vi.hoisted(() => ({ plugins: vi.fn(), restart: vi.fn(async () => "ok"), print: vi.fn(), + publication: vi.fn( + async ( + params: { assertCurrent: () => void }, + publish: (assertCurrent: () => Promise) => Promise, + ) => { + params.assertCurrent(); + return await publish(async () => params.assertCurrent()); + }, + ), })); vi.mock("../../daemon/gateway-entrypoint.js", () => ({ @@ -52,12 +64,19 @@ vi.mock("./update-command-service.js", async (importOriginal) => ({ maybeRestartService: mocks.restart, tryInstallShellCompletion: vi.fn(), })); +// Native service custody has separate boundary tests; this fixture retains the +// real plugin lease, artifact ownership, installed adapter, and canonical writer. +vi.mock("./update-command-service-maintenance.js", async (importOriginal) => ({ + ...(await importOriginal()), + withGatewayRuntimeArtifactPublication: mocks.publication, +})); // The fixture CLI owns lease probes and Doctor phases; triage has its own owner tests. vi.mock("../../infra/update-triage.js", () => ({ prepareUpdateFailureTriage: async () => async () => ({ status: "completed", hint: "" }), })); +import { convergeUpdatePlugins } from "./update-command-convergence.js"; import { updateFinalizeCommand } from "./update-command-finalize.js"; import type { LeaseScenario } from "./update-command-lease.test-support.js"; import type { ProducedPluginUpdateResult } from "./update-command-plugins-internals.js"; @@ -78,6 +97,10 @@ let entrypoint: string; beforeEach(async () => { vi.clearAllMocks(); + mocks.publication.mockReset().mockImplementation(async (params, publish) => { + params.assertCurrent(); + return await publish(async () => params.assertCurrent()); + }); state = await createOpenClawTestState({ label: "update-lease", env: { @@ -260,7 +283,198 @@ function expectRecoveredRun(run: UpdateRunRecord | undefined): void { }); } +async function prepareIncompleteSourceRuntime() { + await runExec("git", ["init", "--quiet", state.root], { timeoutMs: 15_000 }); + await fs.symlink( + fileURLToPath(new URL("../../../scripts", import.meta.url)), + state.path("scripts"), + process.platform === "win32" ? "junction" : "dir", + ); + const files = { + "tsconfig.json": JSON.stringify({ + extends: fileURLToPath(new URL("../../../tsconfig.json", import.meta.url)), + }), + "package.json": JSON.stringify({ + name: "openclaw", + version: VERSION, + type: "module", + exports: { "./plugin-sdk/demo": "./dist/plugin-sdk/demo.js" }, + }), + "dist/plugin-sdk/demo.js": "export const generation = 'candidate';\n", + "dist/extensions/demo/index.js": "export const generation = 'candidate';\n", + "dist/extensions/demo/package.json": JSON.stringify({ + name: "demo", + type: "module", + generation: "candidate", + }), + }; + for (const [relative, content] of Object.entries(files)) { + const target = state.path(relative); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, content); + } + stageBundledPluginRuntime({ repoRoot: state.root }); + const aliasRoot = state.path("dist/extensions/node_modules/openclaw"); + const runtimeEntry = state.path("dist-runtime/extensions/demo/index.js"); + const runtimeMetadata = state.path("dist-runtime/extensions/demo/package.json"); + await fs.unlink(runtimeEntry); + await fs.writeFile(runtimeMetadata, '{"name":"demo","generation":"stale"}\n'); + return { aliasRoot, runtimeEntry, runtimeMetadata, aliasBefore: await fs.stat(aliasRoot) }; +} + describe("update orchestration lifecycle ownership", () => { + it.each([ + { shape: "legacy-mjs", version: "2026.4.27", source: undefined, failure: undefined }, + { + shape: "legacy-mts", + version: VERSION, + source: + "export function stageBundledPluginRuntime() { throw new Error('legacy stager ran'); }", + failure: undefined, + }, + { + shape: "malformed-prepare", + version: VERSION, + source: "export const prepareBundledPluginRuntime = 1;", + failure: /cannot complete its runtime artifacts/, + }, + { + shape: "missing-dependency", + version: VERSION, + source: "import './missing-dependency.mjs'; export function prepareBundledPluginRuntime() {}", + failure: /missing-dependency/, + }, + { + shape: "missing-ownership", + version: VERSION, + source: + "export function prepareBundledPluginRuntime() { throw new Error('stager ran without ownership'); }", + failure: /dist-artifact-ownership/, + }, + ])( + "converges target-owned source completion contracts: $shape", + async ({ version, source, failure }) => { + await writeScenario("current-process"); + const { runtimeEntry } = await prepareIncompleteSourceRuntime(); + stageBundledPluginRuntime({ repoRoot: state.root }); + const original = await fs.stat(runtimeEntry); + const packageJson = JSON.parse(await fs.readFile(state.path("package.json"), "utf8")); + await fs.writeFile(state.path("package.json"), JSON.stringify({ ...packageJson, version })); + await fs.unlink(state.path("scripts")); + await fs.mkdir(state.path("scripts")); + await fs.writeFile( + state.path("scripts/stage-bundled-plugin-runtime.mjs"), + "throw new Error('legacy CLI shim imported');", + ); + if (source) { + await fs.writeFile(state.path("scripts/stage-bundled-plugin-runtime.mts"), source); + } + mocks.plugins.mockResolvedValue({ ...pluginResult, changed: false }); + const current = version === VERSION; + const result = convergeUpdatePlugins({ + coreAlreadyCurrent: current, + result: { + status: current ? "skipped" : "ok", + mode: "git", + root: state.root, + before: { version: VERSION }, + after: { version }, + steps: [], + durationMs: 1, + }, + root: state.root, + installKindChanged: false, + configSnapshot: await readConfigFileSnapshot({ skipPluginValidation: true }), + requestedChannel: null, + storedChannel: "stable", + channel: "stable", + downgradeRisk: !current, + opts: { json: true, yes: true }, + preUpdatePluginInstallRecords: {}, + startedAt: Date.now(), + updateStepTimeoutMs: 15_000, + }); + if (failure) { + await expect(result).rejects.toThrow(failure); + expect(mocks.plugins).not.toHaveBeenCalled(); + } else { + expect((await result).resultWithPostUpdate.status).toBe(current ? "skipped" : "ok"); + expect(mocks.plugins).toHaveBeenCalledOnce(); + } + expect(mocks.publication).not.toHaveBeenCalled(); + expect(await fs.stat(runtimeEntry)).toMatchObject({ + ino: original.ino, + mtimeMs: original.mtimeMs, + }); + }, + ); + + it.each(["resume", "repair"] as const)( + "%s completes missing source artifacts before consumers and leaves an exact online retry untouched", + async (lane) => { + await writeScenario(lane, { runtimeRoot: state.root }); + const { aliasRoot, runtimeEntry, runtimeMetadata, aliasBefore } = + await prepareIncompleteSourceRuntime(); + mocks.plugins.mockImplementation(async () => { + await runExec(process.execPath, [entrypoint, "runtime-proof"], { timeoutMs: 15_000 }); + return pluginResult; + }); + + await invoke(lane); + expectSuccess(lane, lane === "repair"); + expect(mocks.publication).toHaveBeenCalledOnce(); + expect((await fs.stat(aliasRoot)).ino).toBe(aliasBefore.ino); + expect(JSON.parse(await fs.readFile(runtimeMetadata, "utf8")).generation).toBe("candidate"); + const firstEvents = await events(); + expect(firstEvents).toContain("runtime-proof:runtime-proof"); + if (lane === "repair") { + expect(firstEvents.indexOf("runtime-proof:doctor")).toBeLessThan( + firstEvents.indexOf("pre-attempt"), + ); + } + + const beforeRetry = await fs.stat(runtimeEntry); + mocks.publication.mockImplementationOnce(async () => { + throw new Error("A running Gateway cannot publish changed artifacts."); + }); + await invoke(lane); + expectSuccess(lane, lane === "repair"); + expect(mocks.publication).toHaveBeenCalledOnce(); + expect(await fs.stat(runtimeEntry)).toMatchObject({ + ino: beforeRetry.ino, + mtimeMs: beforeRetry.mtimeMs, + }); + expect((await fs.stat(aliasRoot)).ino).toBe(aliasBefore.ino); + }, + ); + + it("resume preserves publication failure details when staging cleanup also fails", async () => { + await writeScenario("resume", { runtimeRoot: state.root }); + await prepareIncompleteSourceRuntime(); + const resultPath = state.path("post-core-result.json"); + vi.stubEnv("OPENCLAW_UPDATE_POST_CORE_RESULT_PATH", resultPath); + mocks.publication.mockRejectedValueOnce(new Error("publication authority revoked")); + const remove = fsSync.rmSync.bind(fsSync); + const cleanup = vi.spyOn(fsSync, "rmSync").mockImplementation((target, options) => { + if (String(target).startsWith(state.root) && String(target).includes(".openclaw-runtime-")) { + throw new Error("staging cleanup unavailable"); + } + return remove(target, options); + }); + try { + await expect(invoke("resume")).rejects.toThrow( + "Runtime completion and staging cleanup failed", + ); + const result = JSON.parse(await fs.readFile(resultPath, "utf8")); + expect(result.status).toBe("failed"); + expect(result.error).toContain("publication authority revoked"); + expect(result.error).toContain("staging cleanup unavailable"); + expect(mocks.plugins).not.toHaveBeenCalled(); + } finally { + cleanup.mockRestore(); + } + }); + it.each(["fresh-process", "current-process", "repair"] as const)( "%s releases plugin ownership for fresh doctor without delegating Gateway activation", async (lane) => { diff --git a/src/cli/update-cli/update-command-lifecycle.test.ts b/src/cli/update-cli/update-command-lifecycle.test.ts index 7216f15b5099..54a694f67c8b 100644 --- a/src/cli/update-cli/update-command-lifecycle.test.ts +++ b/src/cli/update-cli/update-command-lifecycle.test.ts @@ -161,6 +161,14 @@ vi.mock("./update-command-plugins.js", () => ({ }), })); +// Process fixtures cover runtime generation with real lifecycle ownership. +vi.mock("./update-command-runtime.js", () => ({ + completeSourceUpdateRuntime: vi.fn(async () => { + record("runtime-completion"); + return { changed: false }; + }), +})); + vi.mock("./update-command-post-core.js", async (importOriginal) => ({ ...(await importOriginal()), continuePostCoreUpdateInFreshProcess: vi.fn(), @@ -410,6 +418,12 @@ describe("update plugin lifecycle lease boundaries", () => { }); expectLifecycleBoundary("handoff-records"); + expect(mocks.events.indexOf("runtime-completion:true")).toBeGreaterThan( + mocks.events.indexOf("lease-enter:false"), + ); + expect(mocks.events.indexOf("runtime-completion:true")).toBeLessThan( + mocks.events.indexOf("prepare-config:true"), + ); expect(mocks.events).not.toContain("fresh-doctor:false"); expect(mocks.events).not.toContain("fresh-doctor:true"); expect(mocks.events).not.toContain("config-snapshot:false"); diff --git a/src/cli/update-cli/update-command-plugin-caller.test.ts b/src/cli/update-cli/update-command-plugin-caller.test.ts index ffec4f458db1..239ccfea4555 100644 --- a/src/cli/update-cli/update-command-plugin-caller.test.ts +++ b/src/cli/update-cli/update-command-plugin-caller.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as convergence from "../../commands/doctor/shared/post-core-plugin-convergence.js"; import { readConfigFileSnapshot } from "../../config/config.js"; import * as temporaryState from "../../infra/tmp-openclaw-dir.js"; +import * as updateCheck from "../../infra/update-check.js"; import { CONTROL_PLANE_UPDATE_SENTINEL_META_ENV } from "../../infra/update-control-plane-sentinel.js"; import { createUpdateRun, getUpdateRun } from "../../infra/update-run-ledger.js"; import { writePersistedInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; @@ -55,6 +56,11 @@ describe("connected in-process plugin finalization authority", () => { state.path("package.json"), JSON.stringify({ name: "openclaw", version: VERSION }), ); + const resolveInstallKind = updateCheck.resolveUpdateInstallKind; + vi.spyOn(updateCheck, "resolveUpdateInstallKind").mockImplementation( + async (root, options) => + root === state.root ? "package" : resolveInstallKind(root, options), + ); vi.spyOn(temporaryState, "resolvePreferredOpenClawTmpDir").mockReturnValue(control); const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => undefined); const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); diff --git a/src/cli/update-cli/update-command-post-update.test.ts b/src/cli/update-cli/update-command-post-update.test.ts index 3e58a6a79793..ddb0c5caa97a 100644 --- a/src/cli/update-cli/update-command-post-update.test.ts +++ b/src/cli/update-cli/update-command-post-update.test.ts @@ -35,9 +35,7 @@ const mocks = vi.hoisted(() => ({ readConfig: vi.fn(), createServiceConfigIO: vi.fn(), readServiceState: vi.fn(), - restartService: vi.fn( - async () => "ok", - ), + restartService: vi.fn(), stopService: vi.fn< typeof import("./update-command-service.js").maybeStopManagedServiceBeforeMutableUpdate @@ -70,16 +68,20 @@ vi.mock("../../commands/doctor-completion.js", async (importOriginal) => ({ checkShellCompletionStatus: mocks.checkCompletionStatus, ensureCompletionCacheExists: mocks.ensureCompletionCache, })); -vi.mock("../../plugins/plugin-lifecycle-lease.js", () => ({ - withPluginLifecycleLease: async (_params: unknown, callback: () => unknown) => { - mocks.leaseActive = true; - try { - return await callback(); - } finally { - mocks.leaseActive = false; - } - }, -})); +vi.mock("../../plugins/plugin-lifecycle-lease.js", async (importOriginal) => { + const actual = await importOriginal(); + const withPluginLifecycleLease: typeof actual.withPluginLifecycleLease = (params, callback) => + actual.withPluginLifecycleLease(params, async (lease) => { + const leaseWasActive = mocks.leaseActive; + mocks.leaseActive = true; + try { + return await callback(lease); + } finally { + mocks.leaseActive = leaseWasActive; + } + }); + return { ...actual, withPluginLifecycleLease }; +}); vi.mock("../../plugins/installed-plugin-index-records.js", () => ({ loadInstalledPluginIndexInstallRecords: mocks.loadPluginRecords, })); @@ -278,13 +280,18 @@ describe("successful update finalization ordering", () => { windowsTaskAutoStartRecovery: recovery, }); try { - await entered.promise; - expect.soft(mocks.restartService).not.toHaveBeenCalled(); - expect.soft(recovery.restore).not.toHaveBeenCalled(); - } finally { - release.resolve(); - } - try { + try { + await Promise.race([ + entered.promise, + finishing.then(() => { + throw new Error("Update completed before plugin convergence entered."); + }), + ]); + expect.soft(mocks.restartService).not.toHaveBeenCalled(); + expect.soft(recovery.restore).not.toHaveBeenCalled(); + } finally { + release.resolve(); + } await finishing; } finally { identity.restore(); diff --git a/src/cli/update-cli/update-command-resume.ts b/src/cli/update-cli/update-command-resume.ts index 4f8c255fd369..c97f889f351f 100644 --- a/src/cli/update-cli/update-command-resume.ts +++ b/src/cli/update-cli/update-command-resume.ts @@ -28,6 +28,7 @@ import { writePostCorePluginUpdateResultFile, writePostCoreUpdateFailureFile, } from "./update-command-post-core.js"; +import { completeSourceUpdateRuntime } from "./update-command-runtime.js"; type ResumePostCoreUpdateParams = { root: string; @@ -91,7 +92,8 @@ async function resumePostCoreUpdateInternal(params: ResumePostCoreUpdateParams): const parentPluginInstallRecords = await readPostCorePluginInstallRecordsFile( process.env[POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV], ); - const producedPluginUpdate = await withPluginLifecycleLease({}, async () => { + const producedPluginUpdate = await withPluginLifecycleLease({}, async (lease) => { + await completeSourceUpdateRuntime({ root: params.root, timeoutMs: params.timeoutMs, lease }); // The core migration owner committed before activation. This fresh process // reads that generation and only owns plugin convergence. const preparedConfig = await preparePostCorePluginConfig({ diff --git a/src/cli/update-cli/update-command-runtime.ts b/src/cli/update-cli/update-command-runtime.ts new file mode 100644 index 000000000000..7feed7070683 --- /dev/null +++ b/src/cli/update-cli/update-command-runtime.ts @@ -0,0 +1,116 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import type { + PrepareBundledPluginRuntime, + WithDistArtifactOwnership, +} from "../../../scripts/lib/runtime-artifact-contract.js"; +import { hasErrnoCode } from "../../infra/errno.js"; +import { resolveUpdateInstallKind } from "../../infra/update-check.js"; +import type { PluginLifecycleLeaseContext } from "../../plugins/plugin-lifecycle-lease.js"; +import { withGatewayRuntimeArtifactPublication } from "./update-command-service-maintenance.js"; + +type SourceRuntimeStaging = { prepareBundledPluginRuntime: PrepareBundledPluginRuntime }; +type SourceArtifactOwnership = { withDistArtifactOwnership: WithDistArtifactOwnership }; + +function isSourceRuntimeStaging(value: unknown): value is SourceRuntimeStaging { + return isRecord(value) && typeof value.prepareBundledPluginRuntime === "function"; +} + +function isSourceArtifactOwnership(value: unknown): value is SourceArtifactOwnership { + return isRecord(value) && typeof value.withDistArtifactOwnership === "function"; +} + +/** Complete source-install artifacts before the target loads plugin configuration. */ +export async function completeSourceUpdateRuntime(params: { + root: string; + timeoutMs: number; + lease: PluginLifecycleLeaseContext; + beforePersistentEffect?: () => void | Promise; +}): Promise<{ changed: boolean }> { + params.lease.assertOwned(); + if ((await resolveUpdateInstallKind(params.root, { signal: params.lease.signal })) !== "git") { + params.lease.assertOwned(); + return { changed: false }; + } + const root = await fs.realpath(params.root); + params.lease.assertOwned(); + const stagingFile = path.join(root, "scripts", "stage-bundled-plugin-runtime.mts"); + const stagingPresent = await fs.lstat(stagingFile).then( + () => true, + (error: unknown) => { + if (hasErrnoCode(error, "ENOENT")) { + return false; + } + throw error; + }, + ); + params.lease.assertOwned(); + // Older downgrade targets have no completion contract: 2026.4.27 predates + // this .mts module, and 2026.9.4 exports only the destructive legacy stager. + if (!stagingPresent) { + return { changed: false }; + } + // These source-checkout modules are native Node TypeScript. The packaged + // updater must load the installed target's generator, not its retained code. + const staging: unknown = await import(pathToFileURL(stagingFile).href); + params.lease.assertOwned(); + if ( + isRecord(staging) && + staging.prepareBundledPluginRuntime === undefined && + typeof staging.stageBundledPluginRuntime === "function" + ) { + return { changed: false }; + } + if (!isSourceRuntimeStaging(staging)) { + throw new Error("The installed source checkout cannot complete its runtime artifacts."); + } + const ownership: unknown = await import( + pathToFileURL(path.join(root, "scripts", "lib", "dist-artifact-ownership.mts")).href + ); + params.lease.assertOwned(); + if (!isSourceArtifactOwnership(ownership)) { + throw new Error("The installed source checkout cannot complete its runtime artifacts."); + } + + return await ownership.withDistArtifactOwnership(root, async () => { + params.lease.assertOwned(); + const prepared = staging.prepareBundledPluginRuntime({ repoRoot: root }); + try { + params.lease.assertOwned(); + if (prepared.changed) { + await withGatewayRuntimeArtifactPublication( + { + root, + env: process.env, + timeoutMs: params.timeoutMs, + assertCurrent: () => params.lease.assertOwned(), + }, + async (assertPublicationCurrent) => { + await prepared.publish(async () => { + await params.beforePersistentEffect?.(); + await assertPublicationCurrent(); + params.lease.assertOwned(); + }); + }, + ); + } + } catch (error) { + try { + await prepared.cleanup(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Runtime completion and staging cleanup failed.", + { + cause: cleanupError, + }, + ); + } + throw error; + } + await prepared.cleanup(); + return { changed: prepared.changed }; + }); +} diff --git a/src/cli/update-cli/update-command-service-maintenance.ts b/src/cli/update-cli/update-command-service-maintenance.ts index b2d87fd99cc1..911ca1a75c9c 100644 --- a/src/cli/update-cli/update-command-service-maintenance.ts +++ b/src/cli/update-cli/update-command-service-maintenance.ts @@ -5,10 +5,6 @@ import { theme } from "../../../packages/terminal-core/src/theme.js"; import { isGatewayServiceEnv, resolveGatewayProfileSuffix } from "../../daemon/constants.js"; import { resolveLaunchAgentLabel } from "../../daemon/launchd-label.js"; import { resolveTaskName } from "../../daemon/schtasks-layout.js"; -import { - isScheduledTaskDefinitelyNotRunning, - readWindowsStartupFallbackRuntimeForUpdate, -} from "../../daemon/schtasks-runtime.js"; import { ScheduledTaskAutoStartRecoveryError } from "../../daemon/schtasks-update-recovery.js"; import { formatServiceInspectionReason, @@ -42,12 +38,17 @@ import { resolveUpdatedGatewayRestartPort, type ManagedGatewayUpdateVerdict, } from "./update-command-service-plan.js"; +import { + isManagedGatewayServiceOffline, + observedSystemdManagerUid, +} from "./update-command-service-publication.js"; import { createWindowsTaskAutoStartRecovery, UpdateCommandAbort, type WindowsTaskAutoStartRecovery, } from "./update-command-windows-task.js"; +export { withGatewayRuntimeArtifactPublication } from "./update-command-service-publication.js"; export type { PreManagedServiceStop } from "./update-command-service-context-types.js"; export { UpdateCommandAbort } from "./update-command-windows-task.js"; @@ -82,13 +83,6 @@ export function resolvePreparedGatewayUpdatePolicy( }; } -function observedSystemdManagerUid(state: GatewayServiceState): number | undefined { - const uid = state.runtime?.systemd?.managerUid; - return typeof uid === "number" && Number.isInteger(uid) && uid >= 0 && uid < 0xffffffff - ? uid - : undefined; -} - async function inspectManagedGatewayServiceBeforeUpdate(params: { root: string; state: GatewayServiceState; @@ -472,21 +466,7 @@ async function stopManagedServiceBeforeMutableUpdate( inspected: true, runtimeInspected: ["running", "stopped"].includes(serviceState.runtime?.status ?? ""), running: serviceState.running, - // Enabled systemd units may be manually stopped; loaded LaunchAgents can - // respawn. Windows needs the live numeric task state, not its last result. - offline: - serviceState.runtime?.status === "stopped" && - (process.platform === "darwin" - ? serviceState.loadState.status === "not-loaded" || - (serviceState.loadState.status === "loaded" && - (await service - .isEnabled?.({ env: serviceState.env, timeoutMs: params.timeoutMs }) - .catch(() => undefined)) === false) - : process.platform === "win32" - ? isScheduledTaskDefinitelyNotRunning(resolveTaskName(serviceState.env)) || - (await readWindowsStartupFallbackRuntimeForUpdate(serviceState.env).catch(() => null)) - ?.status === "stopped" - : process.platform === "linux"), + offline: await isManagedGatewayServiceOffline(service, serviceState, params.timeoutMs), serviceEnv: serviceState.env, serviceDefinitionEnv: resolveManagedGatewayServiceCommand(serviceState.command)?.environment ?? {}, diff --git a/src/cli/update-cli/update-command-service-publication.test.ts b/src/cli/update-cli/update-command-service-publication.test.ts new file mode 100644 index 000000000000..006bbcaffc8f --- /dev/null +++ b/src/cli/update-cli/update-command-service-publication.test.ts @@ -0,0 +1,469 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; +import { withGatewayServiceOperationLock } from "../../daemon/service-operation-lock.js"; +import type { GatewayService } from "../../daemon/service.js"; +import { + createMockGatewayService, + mockSystemAccountHome, +} from "../../daemon/service.test-helpers.js"; +import * as gatewayLocks from "../../infra/gateway-lock.js"; +import * as portProbe from "../../infra/ports-probe.js"; +import { tryAcquireExclusiveSqliteCoordinator } from "../../infra/sqlite-coordinator.js"; +import { acquireGatewayLifecycleCoordinator } from "../../infra/state-database-coordinator.js"; +import * as openClawTmp from "../../infra/tmp-openclaw-dir.js"; +import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; +import { withEnvAsync } from "../../test-utils/env.js"; +import { mockProcessPlatform } from "../../test-utils/vitest-spies.js"; +import { withGatewayRuntimeArtifactPublication } from "./update-command-service-maintenance.js"; + +const mocks = vi.hoisted(() => ({ service: vi.fn<() => GatewayService>() })); +vi.mock("../../daemon/service.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveGatewayService: mocks.service, +})); + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +beforeEach(() => mockSystemAccountHome()); +afterEach(() => vi.restoreAllMocks()); + +async function withServiceHome(run: (home: string) => Promise): Promise { + const home = tempDirs.make("openclaw-runtime-publication-"); + vi.spyOn(openClawTmp, "resolvePreferredOpenClawTmpDir").mockReturnValue(home); + await withEnvAsync( + { + HOME: home, + USERPROFILE: home, + APPDATA: path.join(home, "AppData"), + OPENCLAW_GATEWAY_PORT: undefined, + OPENCLAW_HOME: undefined, + OPENCLAW_STATE_DIR: undefined, + OPENCLAW_CONFIG_PATH: undefined, + OPENCLAW_PROFILE: undefined, + OPENCLAW_SUPERVISOR_MODE: undefined, + OPENCLAW_SERVICE_MARKER: undefined, + OPENCLAW_SERVICE_KIND: undefined, + }, + () => run(home), + ); +} + +async function withRuntimePublicationFixture( + run: (fixture: { + home: string; + root: string; + env: NodeJS.ProcessEnv; + service: GatewayService; + coordinatorPath: string; + }) => Promise, +): Promise { + await withServiceHome(async (home) => { + mockProcessPlatform("linux"); + const root = path.join(home, "checkout"); + await fs.mkdir(path.join(root, "dist"), { recursive: true }); + await fs.mkdir(path.join(root, "dist-runtime")); + await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" })); + await fs.writeFile(path.join(root, "dist", "entry.js"), "export {};\n"); + const env = { ...process.env }; + const service = createMockGatewayService({ + readCommand: vi.fn(async () => ({ + programArguments: [process.execPath, path.join(root, "dist", "entry.js"), "gateway"], + })), + readRuntime: vi.fn(async () => ({ + status: "stopped", + systemd: { managerUid: 2001 }, + })), + isLoaded: vi.fn(async () => true), + isEnabled: vi.fn(async () => false), + }); + mocks.service.mockReturnValue(service); + vi.spyOn(gatewayLocks, "readActiveGatewayLockIdentity").mockResolvedValue(undefined); + vi.spyOn(portProbe, "probePortUsage").mockResolvedValue("free"); + const coordinator = acquireGatewayLifecycleCoordinator({ + databasePath: resolveOpenClawStateSqlitePath(env), + busyTimeoutMs: 0, + }); + coordinator.release(); + await run({ home, root, env, service, coordinatorPath: coordinator.path }); + expect(service.stop).not.toHaveBeenCalled(); + expect(service.start).not.toHaveBeenCalled(); + expect(service.restart).not.toHaveBeenCalled(); + expect(service.install).not.toHaveBeenCalled(); + }); +} + +it.each([ + "running", + "unknown runtime", + "unknown command", + "unknown load state", + "respawn enabled", + "active lock", + "unknown lock", + "busy listener", + "explicit listener", + "unknown listener", + "running after coordinator", + "lock after coordinator", +])("refuses changed runtime publication with %s", (scenario) => + withRuntimePublicationFixture(async ({ root, env, service }) => { + if (scenario === "running" || scenario === "unknown runtime") { + vi.mocked(service.readRuntime).mockResolvedValue({ + status: scenario === "running" ? "running" : "unknown", + systemd: { managerUid: 2001 }, + }); + } else if (scenario === "unknown command") { + vi.mocked(service.readCommand).mockResolvedValue(null); + } else if (scenario === "unknown load state") { + vi.mocked(service.isLoaded).mockRejectedValue(new Error("inspection failed")); + } else if (scenario === "respawn enabled") { + mockProcessPlatform("darwin"); + vi.mocked(service.isEnabled!).mockResolvedValue(true); + } else if (scenario === "active lock" || scenario === "lock after coordinator") { + const lock = vi.mocked(gatewayLocks.readActiveGatewayLockIdentity); + lock.mockResolvedValue({ pid: process.pid, createdAt: "now", port: 18789 }); + if (scenario === "lock after coordinator") { + lock.mockResolvedValueOnce(undefined); + } + } else if (scenario === "unknown lock") { + vi.mocked(gatewayLocks.readActiveGatewayLockIdentity).mockRejectedValue(new Error("unknown")); + } else if (scenario === "explicit listener") { + vi.mocked(service.readCommand).mockResolvedValue({ + programArguments: [ + process.execPath, + path.join(root, "dist", "entry.js"), + "gateway", + "--port", + "19420", + ], + }); + vi.mocked(portProbe.probePortUsage).mockImplementation(async (port) => + port === 19420 ? "busy" : "free", + ); + } else if (scenario === "busy listener" || scenario === "unknown listener") { + vi.mocked(portProbe.probePortUsage).mockResolvedValue( + scenario === "busy listener" ? "busy" : "unknown", + ); + } else { + vi.mocked(service.readRuntime) + .mockResolvedValueOnce({ status: "stopped", systemd: { managerUid: 2001 } }) + .mockResolvedValue({ status: "running", systemd: { managerUid: 2001 } }); + } + const publish = vi.fn(async () => "published"); + await expect( + withGatewayRuntimeArtifactPublication( + { root, env, timeoutMs: 200, assertCurrent() {} }, + publish, + ), + ).rejects.toThrow(/affected Gateway.*retry the update/); + expect(publish).not.toHaveBeenCalled(); + }), +); + +it("refuses changed runtime publication while another process owns Gateway presence", () => + withRuntimePublicationFixture(async ({ root, env, coordinatorPath }) => { + const other = tryAcquireExclusiveSqliteCoordinator(coordinatorPath); + expect(other).not.toBeNull(); + const publish = vi.fn(async () => "published"); + try { + await expect( + withGatewayRuntimeArtifactPublication( + { root, env, timeoutMs: 200, assertCurrent() {} }, + publish, + ), + ).rejects.toThrow(/affected Gateway/); + expect(publish).not.toHaveBeenCalled(); + } finally { + other?.release(); + } + })); + +it.each(["stopped", "absent"])( + "publishes changed artifacts for an affirmatively %s Gateway", + (state) => + withRuntimePublicationFixture(async ({ root, env, service, coordinatorPath }) => { + if (state === "absent") { + service.isAbsent = vi.fn(async () => true); + } + await expect( + withGatewayRuntimeArtifactPublication( + { root, env, timeoutMs: 200, assertCurrent() {} }, + async (assertCurrent) => { + await Promise.resolve(); + await assertCurrent(); + expect(tryAcquireExclusiveSqliteCoordinator(coordinatorPath)).toBeNull(); + return "published"; + }, + ), + ).resolves.toBe("published"); + }), +); + +it.each([ + "disjoint", + "shared overlay", + "shared SDK alias", + "shared SDK parent", + "nested shared output", +])("distinguishes physical runtime paths from current/releases ownership: %s", (scenario) => + withRuntimePublicationFixture(async ({ home, root, env, service, coordinatorPath }) => { + const snapshot = path.join(home, "releases", "previous"); + await fs.mkdir(path.join(snapshot, "dist"), { recursive: true }); + await fs.writeFile(path.join(snapshot, "package.json"), JSON.stringify({ name: "openclaw" })); + await fs.writeFile(path.join(snapshot, "dist", "entry.js"), "export {};\n"); + const current = path.join(home, "current"); + await fs.symlink(snapshot, current, "junction"); + if (scenario === "shared overlay") { + await fs.symlink( + path.join(root, "dist-runtime"), + path.join(snapshot, "dist-runtime"), + "junction", + ); + } else if (scenario === "shared SDK alias") { + const aliasParent = path.join(snapshot, "dist", "extensions", "node_modules"); + await fs.mkdir(aliasParent, { recursive: true }); + await fs.symlink(root, path.join(aliasParent, "openclaw"), "junction"); + } else if (scenario === "shared SDK parent") { + const aliasParent = path.join(root, "dist", "extensions", "node_modules"); + await fs.mkdir(aliasParent, { recursive: true }); + await fs.mkdir(path.join(snapshot, "dist", "extensions")); + await fs.symlink( + aliasParent, + path.join(snapshot, "dist", "extensions", "node_modules"), + "junction", + ); + } else if (scenario === "nested shared output") { + const nested = path.join(root, "dist-runtime", "extensions", "demo"); + const aliasParent = path.join(snapshot, "dist", "extensions", "node_modules"); + await fs.mkdir(nested, { recursive: true }); + await fs.mkdir(aliasParent, { recursive: true }); + await fs.symlink(nested, path.join(aliasParent, "openclaw"), "junction"); + } + vi.mocked(service.readCommand).mockResolvedValue({ + programArguments: [process.execPath, path.join(current, "dist", "entry.js"), "gateway"], + managedDefinition: { + programArguments: [process.execPath, path.join(root, "dist", "entry.js"), "gateway"], + }, + }); + vi.mocked(service.readRuntime).mockResolvedValue({ + status: "running", + systemd: { managerUid: 2001 }, + }); + vi.mocked(portProbe.probePortUsage).mockResolvedValue("busy"); + const other = tryAcquireExclusiveSqliteCoordinator(coordinatorPath); + expect(other).not.toBeNull(); + const publish = vi.fn(async (assertCurrent: () => Promise) => { + await assertCurrent(); + return "published"; + }); + try { + const result = withGatewayRuntimeArtifactPublication( + { root, env, timeoutMs: 200, assertCurrent() {} }, + publish, + ); + if (scenario === "disjoint") { + await expect(result).resolves.toBe("published"); + } else { + await expect(result).rejects.toThrow(/affected Gateway/); + expect(publish).not.toHaveBeenCalled(); + } + } finally { + other?.release(); + } + }), +); + +it.each(["inspection", "publication"])( + "retains the caller's publication lease across %s awaits", + (when) => + withRuntimePublicationFixture(async ({ root, env, service }) => { + let current = true; + if (when === "inspection") { + vi.mocked(service.readRuntime).mockImplementation(async () => { + await Promise.resolve(); + current = false; + return { status: "stopped", systemd: { managerUid: 2001 } }; + }); + } + const mutate = vi.fn(); + await expect( + withGatewayRuntimeArtifactPublication( + { + root, + env, + timeoutMs: 200, + assertCurrent() { + if (!current) { + throw new Error("publication lease lost"); + } + }, + }, + async (assertCurrent) => { + await Promise.resolve(); + current = false; + await assertCurrent(); + mutate(); + }, + ), + ).rejects.toThrow("publication lease lost"); + expect(mutate).not.toHaveBeenCalled(); + }), +); + +it.each([ + "running", + "unknown runtime", + "changed launcher", + "replaced entrypoint", + "changed manager", + "changed state directory", + "disjoint becomes affected", + "disjoint becomes unknown", +])("rechecks publication authority after an awaited boundary: %s", (change) => + withRuntimePublicationFixture(async ({ home, root, env, service }) => { + if (change.startsWith("disjoint")) { + const snapshot = path.join(root, ".artifacts", "serving"); + await fs.mkdir(path.join(snapshot, "dist"), { recursive: true }); + await fs.writeFile(path.join(snapshot, "package.json"), JSON.stringify({ name: "openclaw" })); + await fs.writeFile(path.join(snapshot, "dist", "entry.js"), "export {};\n"); + vi.mocked(service.readCommand).mockResolvedValue({ + programArguments: [process.execPath, path.join(snapshot, "dist", "entry.js"), "gateway"], + }); + } + const untouched = path.join(root, "dist-runtime", "unchanged.txt"); + await fs.writeFile(untouched, "original"); + const beforePersistentEffect = async () => { + await Promise.resolve(); + if (change === "running" || change === "unknown runtime") { + vi.mocked(service.readRuntime).mockResolvedValue({ + status: change === "running" ? "running" : "unknown", + systemd: { managerUid: 2001 }, + }); + } else if (change === "changed manager") { + vi.mocked(service.readRuntime).mockResolvedValue({ + status: "stopped", + systemd: { managerUid: 3002 }, + }); + } else if (change === "changed state directory") { + env.OPENCLAW_STATE_DIR = path.join(home, "replacement-state"); + } else if (change === "replaced entrypoint") { + const entry = path.join(root, "dist", "entry.js"); + await fs.rename(entry, `${entry}.previous`); + await fs.writeFile(entry, "export const replaced = true;\n"); + } else if (change === "disjoint becomes unknown") { + vi.mocked(service.readCommand).mockResolvedValue(null); + } else { + vi.mocked(service.readCommand).mockResolvedValue({ + programArguments: [ + process.execPath, + path.join(root, "dist", "entry.js"), + "gateway", + ...(change === "changed launcher" ? ["--verbose"] : []), + ], + }); + } + }; + await expect( + withGatewayRuntimeArtifactPublication( + { root, env, timeoutMs: 200, assertCurrent() {} }, + async (assertPublicationCurrent) => { + await beforePersistentEffect(); + await assertPublicationCurrent(); + await fs.writeFile(untouched, "published"); + }, + ), + ).rejects.toThrow(/affected Gateway/); + expect(await fs.readFile(untouched, "utf8")).toBe("original"); + }), +); + +it.each(["repository", "existing alias parent", "missing alias parent"])( + "rejects an awaited physical target redirection with no native service: %s", + (change) => + withRuntimePublicationFixture(async ({ home, root, env, service }) => { + service.isAbsent = vi.fn(async () => true); + const parent = path.join(root, "dist", "extensions", "node_modules"); + const replacement = path.join(home, "replacement"); + await fs.mkdir(replacement); + if (change === "existing alias parent") { + await fs.mkdir(parent, { recursive: true }); + } + await expect( + withGatewayRuntimeArtifactPublication( + { root, env, timeoutMs: 200, assertCurrent() {} }, + async (assertPublicationCurrent) => { + await Promise.resolve(); + if (change === "repository") { + await fs.rename(root, `${root}-before`); + await fs.symlink(replacement, root, "junction"); + } else { + if (change === "existing alias parent") { + await fs.rename(parent, `${parent}-before`); + } else { + await fs.mkdir(path.dirname(parent), { recursive: true }); + } + await fs.symlink(replacement, parent, "junction"); + } + await assertPublicationCurrent(); + await fs.writeFile(path.join(replacement, "published.txt"), "changed"); + }, + ), + ).rejects.toThrow(/affected Gateway/); + expect(await fs.readdir(replacement)).toEqual([]); + }), +); + +it("permits its output-root replacement and new alias descendants while retaining parent identity", () => + withRuntimePublicationFixture(async ({ root, env }) => { + const runtime = path.join(root, "dist-runtime"); + const previous = path.join(root, "previous-runtime"); + const alias = path.join(root, "dist", "extensions", "node_modules", "openclaw"); + await withGatewayRuntimeArtifactPublication( + { root, env, timeoutMs: 200, assertCurrent() {} }, + async (assertCurrent) => { + await assertCurrent(); + await fs.rename(runtime, previous); + await assertCurrent(); + await fs.mkdir(runtime); + await assertCurrent(); + await fs.mkdir(alias, { recursive: true }); + await assertCurrent(); + await fs.writeFile(path.join(runtime, "published.txt"), "new runtime"); + }, + ); + expect(await fs.readFile(path.join(runtime, "published.txt"), "utf8")).toBe("new runtime"); + expect((await fs.stat(alias)).isDirectory()).toBe(true); + })); + +it("holds native and Gateway exclusion through publication rollback and closes its assertion", () => + withRuntimePublicationFixture(async ({ root, env, coordinatorPath }) => { + let retainedAssertion: (() => Promise) | undefined; + let rolledBack = false; + await expect( + withGatewayRuntimeArtifactPublication( + { root, env, timeoutMs: 200, assertCurrent() {} }, + async (assertCurrent) => { + retainedAssertion = assertCurrent; + try { + await assertCurrent(); + expect(tryAcquireExclusiveSqliteCoordinator(coordinatorPath)).toBeNull(); + throw new Error("publication failed"); + } finally { + await withGatewayServiceOperationLock(env, async (assertNative) => { + await Promise.resolve(); + await assertCurrent(); + assertNative(); + expect(tryAcquireExclusiveSqliteCoordinator(coordinatorPath)).toBeNull(); + rolledBack = true; + }); + } + }, + ), + ).rejects.toThrow("publication failed"); + expect(rolledBack).toBe(true); + await expect(retainedAssertion!()).rejects.toThrow(/ownership has closed/); + const released = tryAcquireExclusiveSqliteCoordinator(coordinatorPath); + expect(released).not.toBeNull(); + released?.release(); + })); diff --git a/src/cli/update-cli/update-command-service-publication.ts b/src/cli/update-cli/update-command-service-publication.ts new file mode 100644 index 000000000000..b24422132590 --- /dev/null +++ b/src/cli/update-cli/update-command-service-publication.ts @@ -0,0 +1,315 @@ +// Physical runtime publication remains part of the managed-service maintenance boundary. +import type { Stats } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { stableStringify } from "@openclaw/normalization-core/stable-stringify"; +import { resolveGatewayProfileSuffix } from "../../daemon/constants.js"; +import { resolveLaunchAgentLabel } from "../../daemon/launchd-label.js"; +import { resolveTaskName } from "../../daemon/schtasks-layout.js"; +import { + isScheduledTaskDefinitelyNotRunning, + readWindowsStartupFallbackRuntimeForUpdate, +} from "../../daemon/schtasks-runtime.js"; +import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js"; +import { withGatewayServiceOperationLock } from "../../daemon/service-operation-lock.js"; +import type { GatewayServiceState } from "../../daemon/service-types.js"; +import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js"; +import { resolveSystemdServiceName } from "../../daemon/systemd-service-files.js"; +import { readActiveGatewayLockIdentity } from "../../infra/gateway-lock.js"; +import { hasNodeErrorCode, isPathInside } from "../../infra/path-guards.js"; +import { probePortUsage } from "../../infra/ports-probe.js"; +import { acquireGatewayLifecycleCoordinator } from "../../infra/state-database-coordinator.js"; +import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; +import { UpdatePreMutationError } from "./shared.js"; +import { resolveUpdatedGatewayRestartPort } from "./update-command-service-plan.js"; + +export function observedSystemdManagerUid(state: GatewayServiceState): number | undefined { + const uid = state.runtime?.systemd?.managerUid; + return typeof uid === "number" && Number.isInteger(uid) && uid >= 0 && uid < 0xffffffff + ? uid + : undefined; +} + +export async function isManagedGatewayServiceOffline( + service: ReturnType, + state: GatewayServiceState, + timeoutMs: number | undefined, +): Promise { + // Enabled systemd units may be manually stopped; loaded LaunchAgents can + // respawn. Windows needs the live numeric task state, not its last result. + return ( + state.runtime?.status === "stopped" && + (process.platform === "darwin" + ? state.loadState.status === "not-loaded" || + (state.loadState.status === "loaded" && + (await service.isEnabled?.({ env: state.env, timeoutMs }).catch(() => undefined)) === + false) + : process.platform === "win32" + ? isScheduledTaskDefinitelyNotRunning(resolveTaskName(state.env)) || + (await readWindowsStartupFallbackRuntimeForUpdate(state.env).catch(() => null)) + ?.status === "stopped" + : process.platform === "linux") + ); +} + +/** Changed runtime artifacts require an offline physical target, not logical + * ownership of a deployment's current/releases namespace. No service is stopped here. */ +export async function withGatewayRuntimeArtifactPublication( + params: { + root: string; + env: NodeJS.ProcessEnv; + timeoutMs: number; + assertCurrent: () => void; + }, + publish: (assertPublicationCurrent: () => Promise) => Promise, +): Promise { + const assertCaller = params.assertCurrent; + assertCaller(); + return await withGatewayServiceOperationLock(params.env, async (assertNative) => { + const assertCurrent = () => { + assertCaller(); + assertNative(); + }; + const refuse = (cause?: unknown): never => { + throw new UpdatePreMutationError( + "runtime-artifact-publication", + "Runtime artifacts changed, but the affected Gateway is running or its offline state could not be verified. Run `openclaw gateway status --deep`, stop the affected Gateway through its service owner, and retry the update.", + { cause }, + ); + }; + const service = resolveGatewayService(); + type PathIdentity = { real: string; stat?: Stats }; + const identity = async (file: string) => { + const real = await fs.realpath(file); + assertCurrent(); + const stat = await fs.stat(file); + assertCurrent(); + return { real, stat }; + }; + const outputIdentity = async (file: string): Promise => { + try { + return await identity(file); + } catch (error) { + assertCurrent(); + if (!hasNodeErrorCode(error, "ENOENT")) { + throw error; + } + // Missing descendants retain their existing ancestor's physical namespace; + // a dangling symlink cannot attest a disjoint publication destination. + const present = await fs.lstat(file).catch((statError: unknown) => { + if (!hasNodeErrorCode(statError, "ENOENT")) { + throw statError; + } + return undefined; + }); + assertCurrent(); + if (present) { + throw error; + } + const parent = await outputIdentity(path.dirname(file)); + assertCurrent(); + return { real: path.join(parent.real, path.basename(file)) }; + } + }; + const same = (a: PathIdentity, b: PathIdentity) => + a.real === b.real || + Boolean(a.stat && b.stat && a.stat.dev === b.stat.dev && a.stat.ino === b.stat.ino); + const outputPaths = [ + "dist-runtime", + path.join("dist", "extensions", "node_modules", "openclaw"), + ]; + const readInspection = async () => { + assertCurrent(); + // Parents are stable across publication; output roots themselves are renamed. + // Record missing descendants too, so creating them cannot redirect a later effect. + const parents = await Promise.all( + [ + "", + "dist", + path.join("dist", "extensions"), + path.join("dist", "extensions", "node_modules"), + ].map((relative) => + relative ? outputIdentity(path.join(params.root, relative)) : identity(params.root), + ), + ); + assertCurrent(); + if (parents.some((parent) => parent.stat && !parent.stat.isDirectory())) { + refuse(); + } + const target = parents[0]!; + const destinations = await Promise.all( + outputPaths.map((output) => outputIdentity(path.join(params.root, output))), + ); + assertCurrent(); + const state = await readGatewayServiceState(service, { + env: params.env, + requireEffective: true, + requireLoadedCommand: true, + timeoutMs: params.timeoutMs, + }); + assertCurrent(); + const layout = await summarizeGatewayServiceLayout(state.command); + assertCurrent(); + const database = await outputIdentity(resolveOpenClawStateSqlitePath(state.env)); + assertCurrent(); + const serviceName = + process.platform === "darwin" + ? resolveLaunchAgentLabel(state.env) + : process.platform === "win32" + ? resolveTaskName(state.env) + : resolveSystemdServiceName(state.env); + const nativeIdentity = stableStringify({ + command: state.command, + serviceName, + profile: resolveGatewayProfileSuffix(state.env.OPENCLAW_PROFILE), + managerUid: observedSystemdManagerUid(state), + }); + let serving: { root: PathIdentity; entrypoint: PathIdentity } | undefined; + let disjoint = false; + if (layout?.packageRootReal && layout.entrypointReal) { + const [installed, entrypoint] = await Promise.all([ + identity(layout.packageRootReal), + outputIdentity(layout.entrypointReal), + ]); + assertCurrent(); + serving = { root: installed, entrypoint }; + const servingOutputs = await Promise.all( + outputPaths.map((output) => outputIdentity(path.join(installed.real, output))), + ); + assertCurrent(); + disjoint = + !same(target, installed) && + !destinations.some( + (destination) => + same(destination, installed) || + isPathInside(destination.real, entrypoint.real) || + servingOutputs.some( + (output) => + same(destination, output) || + isPathInside(destination.real, output.real) || + isPathInside(output.real, destination.real), + ), + ); + } else if ( + state.command || + state.installed || + state.loadState.status !== "not-loaded" || + !state.runtime?.missingUnit + ) { + refuse(); + } + const absent = + !state.command && + !state.installed && + state.loadState.status === "not-loaded" && + state.runtime?.missingUnit === true; + if ( + !disjoint && + (state.running || + (!absent && + (state.loadState.status === "unknown" || + (process.platform === "linux" && observedSystemdManagerUid(state) === undefined) || + !(await isManagedGatewayServiceOffline(service, state, params.timeoutMs))))) + ) { + refuse(); + } + assertCurrent(); + if (!disjoint) { + const activeLock = await readActiveGatewayLockIdentity({ + env: state.env, + requireInspection: true, + }); + assertCurrent(); + if (activeLock) { + refuse(); + } + const port = await resolveUpdatedGatewayRestartPort({ + serviceEnv: state.env, + serviceCommand: state.command, + }); + assertCurrent(); + const usage = await probePortUsage(port); + assertCurrent(); + if (usage !== "free") { + refuse(); + } + } + return { state, disjoint, parents, destinations, database, nativeIdentity, serving }; + }; + const inspect = async () => { + try { + return await readInspection(); + } catch (error) { + assertCurrent(); + if (error instanceof UpdatePreMutationError) { + throw error; + } + return refuse(error); + } + }; + const before = await inspect(); + assertCurrent(); + if (before.serving && !before.serving.entrypoint.stat) { + refuse(); + } + const assertPublicationCurrent = async () => { + const current = await inspect(); + assertCurrent(); + const changedIdentity = (previous: PathIdentity, next: PathIdentity) => + previous.real !== next.real || + Boolean( + previous.stat && + (!next.stat || + previous.stat.dev !== next.stat.dev || + previous.stat.ino !== next.stat.ino), + ); + if ( + before.disjoint !== current.disjoint || + before.database.real !== current.database.real || + before.nativeIdentity !== current.nativeIdentity || + before.parents.some((parent, index) => changedIdentity(parent, current.parents[index]!)) || + before.destinations.some( + (destination, index) => destination.real !== current.destinations[index]!.real, + ) || + (before.serving && + (!current.serving || + changedIdentity(before.serving.root, current.serving.root) || + before.serving.entrypoint.real !== current.serving.entrypoint.real || + (!before.destinations.some((destination) => + isPathInside(destination.real, current.serving!.entrypoint.real), + ) && + changedIdentity(before.serving.entrypoint, current.serving.entrypoint)))) + ) { + refuse(); + } + assertCurrent(); + }; + let coordinator: ReturnType | undefined; + try { + try { + assertCurrent(); + if (!before.disjoint) { + coordinator = acquireGatewayLifecycleCoordinator({ + databasePath: before.database.real, + busyTimeoutMs: 0, + }); + } + await assertPublicationCurrent(); + assertCurrent(); + } catch (error) { + assertCurrent(); + if (error instanceof UpdatePreMutationError) { + throw error; + } + refuse(error); + } + assertCurrent(); + // The publisher joins its rollback before settling, keeping both exclusions held. + const result = await publish(assertPublicationCurrent); + assertCurrent(); + return result; + } finally { + coordinator?.release(); + } + }); +} diff --git a/src/daemon/service-absence.test.ts b/src/daemon/service-absence.test.ts new file mode 100644 index 000000000000..68e8d80b2122 --- /dev/null +++ b/src/daemon/service-absence.test.ts @@ -0,0 +1,155 @@ +import fs from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mockProcessPlatform } from "../test-utils/vitest-spies.js"; +import { readGatewayServiceState, resolveGatewayService, type GatewayService } from "./service.js"; +import { createMockGatewayService, mockSystemAccountHome } from "./service.test-helpers.js"; + +beforeEach(() => { + mockSystemAccountHome(); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("readGatewayServiceState absence", () => { + it.each(["current", "revoked", "expired"])( + "preserves the admitted binding and deadline through an absent projection (%s)", + async (condition) => { + let current = true; + let now = 100; + vi.spyOn(performance, "now").mockImplementation(() => now); + const binding = { + unit: "openclaw-gateway.service", + managerUid: 1000, + destination: ":1.0", + verify: vi.fn(() => { + if (!current) { + throw new Error("original binding retired"); + } + }), + query: vi.fn(async () => []), + close: vi.fn(async () => {}), + }; + const isAbsent = vi.fn>(async (args) => { + if (!args.strictCommandAbsent) { + return false; + } + current = condition !== "revoked"; + if (condition === "expired") { + now += 1001; + } + return true; + }); + const readCommand = vi.fn(async () => null); + const readRuntime = vi.fn(async () => ({ + status: "unknown", + })); + const observed = readGatewayServiceState( + createMockGatewayService({ isAbsent, readCommand, readRuntime }), + { + env: { HOME: "/openclaw-service-proof" }, + requireEffective: true, + requireLoadedCommand: true, + systemdReadBinding: binding, + timeoutMs: 1000, + }, + ); + if (condition === "current") { + await expect(observed).resolves.toMatchObject({ + command: null, + runtime: { status: "stopped", missingUnit: true }, + }); + } else { + await expect(observed).rejects.toThrow( + condition === "revoked" ? "original binding retired" : "deadline expired", + ); + } + expect(readCommand).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ systemdReadBinding: binding }), + ); + expect(readRuntime).not.toHaveBeenCalled(); + expect(binding.close).not.toHaveBeenCalled(); + }, + ); + + it.each([ + "absent", + "system-loaded", + "system-definition", + "system-unavailable", + "user-unavailable", + ])( + "preserves strict Linux service absence only with both scopes verified (%s)", + async (condition) => { + mockProcessPlatform("linux"); + const missing = () => Object.assign(new Error("missing"), { code: "ENOENT" }); + vi.spyOn(fs, "readFile").mockRejectedValue(missing()); + vi.spyOn(fs, "access").mockRejectedValue(missing()); + vi.spyOn(fs, "readdir").mockResolvedValue([]); + const present = await fs.lstat(process.cwd()); + vi.spyOn(fs, "lstat").mockImplementation(async (target) => { + if ( + condition === "system-definition" && + String(target) === "/etc/systemd/system/openclaw-gateway.service" + ) { + return present; + } + throw missing(); + }); + const run = vi.spyOn(await import("./exec-file.js"), "execFileUtf8"); + run.mockImplementation(async (_command, args) => { + const system = args.includes("--system"); + const success = (type: string, data: unknown) => ({ + code: 0, + termination: "exit" as const, + stdout: JSON.stringify({ type, data }), + stderr: "", + }); + const failure = (stderr: string) => ({ + code: 1, + termination: "exit" as const, + stdout: "", + stderr, + }); + if (condition === (system ? "system-unavailable" : "user-unavailable")) { + return failure("Failed to connect to bus: No such file or directory"); + } + if (args.includes("GetNameOwner")) { + return success("s", [":1.2"]); + } + if (args.includes("GetUnit")) { + return system && condition === "system-loaded" + ? success("o", ["/org/freedesktop/systemd1/unit/openclaw_2dgateway_2eservice"]) + : failure("Call failed: Unit openclaw-gateway.service not loaded."); + } + if (args.includes("GetUnitFileState")) { + return failure("Call failed: No such file or directory"); + } + if (system && args.includes("UnitPath")) { + return success("as", ["/etc/systemd/system"]); + } + return failure("Unexpected native query"); + }); + const result = readGatewayServiceState(resolveGatewayService(), { + env: { HOME: "/openclaw-service-proof", DBUS_SESSION_BUS_ADDRESS: "unix:path=/proof/bus" }, + requireEffective: true, + requireLoadedCommand: true, + }); + if (condition === "user-unavailable") { + await expect(result).rejects.toThrow(); + } else if (condition === "absent") { + await expect(result).resolves.toMatchObject({ + installed: false, + command: null, + loadState: { status: "not-loaded" }, + runtime: { status: "stopped", missingUnit: true }, + running: false, + }); + } else { + expect((await result).runtime?.missingUnit).not.toBe(true); + } + expect(run.mock.calls.some((call) => call[1].includes("LoadUnit"))).toBe(false); + }, + ); +}); diff --git a/src/daemon/service.ts b/src/daemon/service.ts index bb3c348aa08b..68b2912f122a 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -103,7 +103,7 @@ export type GatewayService = { isLoaded: (args: GatewayServiceEnvArgs) => Promise; isEnabled?: (args: GatewayServiceEnvArgs) => Promise; hasInstalledDefinition?: (args: GatewayServiceEnvArgs) => Promise; - isAbsent?: (args: GatewayServiceEnvArgs) => Promise; + isAbsent?: (args: GatewayServiceEnvArgs & { strictCommandAbsent?: true }) => Promise; readDefinitionMutationCapability?: ( args: GatewayServiceEnvArgs & { environment?: GatewayServiceEnv; @@ -260,39 +260,63 @@ async function readGatewayServiceStateWithBinding( ): Promise { const baseEnv = args.env ?? process.env; const { timeoutMs, systemdReadBinding } = args; + const deadline = performance.now() + (timeoutMs && timeoutMs > 0 ? timeoutMs : 5000); systemdReadBinding?.verify(); - // Native absence is affirmative evidence; failed effective-command inspection is not. - if (await service.isAbsent?.({ env: baseEnv, timeoutMs }).catch(() => false)) { - args.validateEnvBeforeStatusRead?.(baseEnv); + let absent = await service.isAbsent?.({ env: baseEnv, timeoutMs }).catch(() => false); + systemdReadBinding?.verify(); + let commandInspectionReason: ServiceInspectionReason | undefined; + const command = absent + ? null + : args.requireEffective + ? await service.readCommand(baseEnv, { + timeoutMs, + requireEffective: true, + ...(systemdReadBinding ? { systemdReadBinding } : {}), + ...(args.requireLoadedCommand ? { requireLoaded: true } : {}), + ...(args.loadForInspection ? { loadForInspection: args.loadForInspection } : {}), + }) + : await service + .readCommand(baseEnv, { + timeoutMs, + onInspectionFailure: (reason) => { + commandInspectionReason = reason; + }, + }) + .catch(() => null); + const env = mergeGatewayServiceEnv(baseEnv, command); + // Reject persisted selector drift before invoking the native service manager. + args.validateEnvBeforeStatusRead?.(env); + // Strict user-unit absence still needs the platform owner's system-scope proof. + if ( + !absent && + service.isAbsent && + args.requireEffective && + args.requireLoadedCommand && + command === null + ) { + systemdReadBinding?.verify(); + const remaining = deadline - performance.now(); + if (remaining <= 0) { + throw new Error("Original systemd read admission deadline expired."); + } + absent = await service + .isAbsent({ env, timeoutMs: remaining, strictCommandAbsent: true }) + .catch(() => false); + systemdReadBinding?.verify(); + if (performance.now() >= deadline) { + throw new Error("Original systemd read admission deadline expired."); + } + } + if (absent) { return { installed: false, loadState: { status: "not-loaded" }, running: false, - env: baseEnv, + env, command: null, runtime: { status: "stopped", missingUnit: true }, }; } - let commandInspectionReason: ServiceInspectionReason | undefined; - const command = args.requireEffective - ? await service.readCommand(baseEnv, { - timeoutMs, - requireEffective: true, - ...(systemdReadBinding ? { systemdReadBinding } : {}), - ...(args.requireLoadedCommand ? { requireLoaded: true } : {}), - ...(args.loadForInspection ? { loadForInspection: args.loadForInspection } : {}), - }) - : await service - .readCommand(baseEnv, { - timeoutMs, - onInspectionFailure: (reason) => { - commandInspectionReason = reason; - }, - }) - .catch(() => null); - const env = mergeGatewayServiceEnv(baseEnv, command); - // Reject persisted selector drift before invoking the native service manager. - args.validateEnvBeforeStatusRead?.(env); const [installed, loadState, runtime, definitionMutationCapability] = await Promise.all([ command !== null ? true @@ -490,7 +514,8 @@ const GATEWAY_SERVICE_REGISTRY: Record isSystemdServiceAbsent(env ?? process.env), + isAbsent: ({ env, timeoutMs, strictCommandAbsent }) => + isSystemdServiceAbsent(env ?? process.env, { timeoutMs, strictCommandAbsent }), hasInstalledDefinition: async ({ env }) => (await findInstalledSystemdGatewayScope(env ?? process.env)) !== null, readDefinitionMutationCapability: ({ diff --git a/src/daemon/systemd-command-query.ts b/src/daemon/systemd-command-query.ts index 63206f0c2b27..bb8954827250 100644 --- a/src/daemon/systemd-command-query.ts +++ b/src/daemon/systemd-command-query.ts @@ -59,7 +59,8 @@ export async function createSystemdCommandQuery( (detail === `Call failed: Unit ${unitName} not loaded.` || detail === `Call failed: Unit ${unitName} not found.`)) || (args.includes("GetUnitFileState") && - detail === `Call failed: Unit file ${unitName} does not exist.`)) + (detail === `Call failed: Unit file ${unitName} does not exist.` || + detail === "Call failed: No such file or directory"))) ) { return null; } diff --git a/src/daemon/systemd-peer-native.ts b/src/daemon/systemd-peer-native.ts index 1a4b90e3c08c..2fbd4d220d56 100644 --- a/src/daemon/systemd-peer-native.ts +++ b/src/daemon/systemd-peer-native.ts @@ -308,7 +308,8 @@ async function openSystemdConnection( (["GetUnit", "LoadUnit"].includes(member) && native.errorHasName(error, "org.freedesktop.systemd1.NoSuchUnit")) || (args[4] === "GetUnitFileState" && - native.errorHasName(error, "org.freedesktop.systemd1.NoSuchUnitFile")) + (native.errorHasName(error, "org.freedesktop.systemd1.NoSuchUnitFile") || + native.errorHasName(error, "org.freedesktop.DBus.Error.FileNotFound"))) ) { return null; } diff --git a/src/daemon/systemd-scope.ts b/src/daemon/systemd-scope.ts index 1b33cd81cb76..6b1059cfa9e1 100644 --- a/src/daemon/systemd-scope.ts +++ b/src/daemon/systemd-scope.ts @@ -17,7 +17,20 @@ const SYSTEM_SYSTEMD_UNIT_DIRS = [ ] as const; /** Proves service absence without interpreting failed manager commands as absence. */ -export async function isSystemdServiceAbsent(env: GatewayServiceEnv): Promise { +export async function isSystemdServiceAbsent( + env: GatewayServiceEnv, + opts?: { timeoutMs?: number; strictCommandAbsent?: true }, +): Promise { + if (opts?.strictCommandAbsent) { + // The caller just proved user-unit absence without loading it. System + // ownership needs its own live manager and complete unit-path inspection. + await assertNoSystemSystemdOwnership( + `${resolveSystemdServiceName(env)}.service`, + opts.timeoutMs, + { requireLoaded: true }, + ); + return (await findInstalledSystemdGatewayScope(env)) === null; + } if ( env.DBUS_SESSION_BUS_ADDRESS || env.DBUS_SYSTEM_BUS_ADDRESS || diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index e7a75837bb04..c767f89fa90b 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -1672,7 +1672,7 @@ describe("readSystemdServiceExecStart", () => { expect(execFileMock.mock.calls[0]?.[1]).toContain("GetUnit"); }); - it.each(["local", "global", "absent", "unreadable"] as const)( + it.each(["local", "global", "absent", "absent-enoent", "unreadable", "bus-unavailable"] as const)( "loaded-only inspection does not activate or adopt an unloaded %s definition", async (scenario) => { execFileMock.mockReset(); @@ -1691,7 +1691,11 @@ describe("readSystemdServiceExecStart", () => { const message = args.includes("GetUnitFileState") ? scenario === "absent" ? `Call failed: Unit file ${GATEWAY_SERVICE} does not exist.` - : "Call failed: Permission denied" + : scenario === "absent-enoent" + ? "Call failed: No such file or directory" + : scenario === "bus-unavailable" + ? "Failed to connect to bus: No such file or directory" + : "Call failed: Permission denied" : `Call failed: Unit ${GATEWAY_SERVICE} not loaded.`; callback(createExecFileError(message), "", message); }); @@ -1699,8 +1703,10 @@ describe("readSystemdServiceExecStart", () => { { HOME: TEST_SERVICE_HOME }, { requireEffective: true, requireLoaded: true }, ); - if (scenario === "absent") { + if (scenario === "absent" || scenario === "absent-enoent") { await expect(result).resolves.toBeNull(); + } else if (scenario === "bus-unavailable") { + await expect(result).rejects.toThrow("systemd user session bus is unavailable"); } else { await expect(result).rejects.toThrow("could not be inspected"); } @@ -1709,7 +1715,9 @@ describe("readSystemdServiceExecStart", () => { (call) => call[1].includes("GetUnit") || call[1].includes("GetUnitFileState"), ), ).toBe(true); - expect(execFileMock).toHaveBeenCalledTimes(scenario === "local" ? 1 : 2); + expect(execFileMock.mock.calls.some((call) => call[1].includes("GetUnitFileState"))).toBe( + scenario !== "local", + ); }, ); diff --git a/src/infra/update-runner-git-candidate.test.ts b/src/infra/update-runner-git-candidate.test.ts index f84bd0968005..d1115ec42b82 100644 --- a/src/infra/update-runner-git-candidate.test.ts +++ b/src/infra/update-runner-git-candidate.test.ts @@ -20,6 +20,8 @@ async function git(root: string, ...args: string[]) { } const runtimeImports = [ + "../dist-runtime/identity.cjs", + "../packages/runtime/dist-runtime/identity.cjs", "../node_modules/identity.cjs", "workspace-runtime", "relative-workspace-runtime", @@ -41,11 +43,7 @@ async function writeRuntime(directory: string, sha: string, store: string, layou const root = await fs.realpath(directory); const dist = path.join(root, "dist"); const external = path.join(store, sha); - await fs.mkdir(external, { recursive: true }); - await fs.writeFile(path.join(external, "index.js"), `module.exports = ${JSON.stringify(sha)};`); await fs.mkdir(path.join(dist, "control-ui"), { recursive: true }); - await fs.mkdir(path.join(root, "node_modules"), { recursive: true }); - await fs.mkdir(path.join(root, "packages", "runtime", "node_modules"), { recursive: true }); const virtualStore = layout === "external" ? path.join(store, "virtual-store") @@ -57,11 +55,17 @@ async function writeRuntime(directory: string, sha: string, store: string, layou await fs.symlink(linkedStore, virtualStore, "junction"); } const virtualPackage = path.join(virtualStore, sha, "node_modules", "virtual-runtime"); - await fs.mkdir(virtualPackage, { recursive: true }); - await fs.writeFile( + for (const file of [ + path.join(external, "index.js"), path.join(virtualPackage, "index.js"), - `module.exports = ${JSON.stringify(sha)};`, - ); + path.join(root, "node_modules", "identity.cjs"), + path.join(root, "packages", "runtime", "node_modules", "nested.cjs"), + path.join(root, "dist-runtime", "identity.cjs"), + path.join(root, "packages", "runtime", "dist-runtime", "identity.cjs"), + ]) { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, `module.exports = ${JSON.stringify(sha)};`); + } await fs.rm(path.join(root, "node_modules", "workspace-runtime"), { force: true }); await fs.symlink( path.join(root, "packages", "runtime"), @@ -93,14 +97,6 @@ async function writeRuntime(directory: string, sha: string, store: string, layou : path.relative(path.join(root, "node_modules"), virtualStore), }), ), - fs.writeFile( - path.join(root, "packages", "runtime", "node_modules", "nested.cjs"), - `module.exports = ${JSON.stringify(sha)};`, - ), - fs.writeFile( - path.join(root, "node_modules", "identity.cjs"), - `module.exports = ${JSON.stringify(sha)};`, - ), fs.writeFile( path.join(dist, "entry.js"), runtimeImports @@ -157,7 +153,7 @@ describe("Git candidate activation", () => { ); await fs.writeFile( path.join(remote, ".gitignore"), - "node_modules/\ndist/\n.artifacts\n.pnpm\ncache/\n", + "node_modules/\ndist/\ndist-runtime/\n.artifacts\n.pnpm\ncache/\n", ); await git(remote, "add", "."); await git(remote, "commit", "-m", "base"); diff --git a/src/infra/update-runner-git-runtime.ts b/src/infra/update-runner-git-runtime.ts index b2835ec67171..310788cd7b6d 100644 --- a/src/infra/update-runner-git-runtime.ts +++ b/src/infra/update-runner-git-runtime.ts @@ -29,8 +29,10 @@ async function collectRuntimeDirectories( "-z", "--", "dist", + "dist-runtime", "node_modules", "**/dist", + "**/dist-runtime", "**/node_modules", ":(exclude).artifacts/**", ":(exclude).worktrees/**", @@ -49,7 +51,7 @@ async function collectRuntimeDirectories( // Git's --directory can collapse an excluded subtree to its ignored parent. .filter( (entry) => - ["dist", "node_modules"].includes(path.basename(entry)) && + ["dist", "dist-runtime", "node_modules"].includes(path.basename(entry)) && !entry.split("/").some((part) => part.startsWith(".")), ) ); diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index 8eaa8ece7e06..44639e188a51 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -195,7 +195,7 @@ describe("package scripts", () => { expect(check).toBeGreaterThanOrEqual(0); for (const prerequisite of [ - "scripts/runtime-postbuild.mjs", + "scripts/runtime-postbuild.mts", "scripts/write-plugin-sdk-entry-dts.ts", ]) { const publication = targets.indexOf(prerequisite); diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts index de115cdccb6e..882475f8c4ce 100644 --- a/test/scripts/build-all.test.ts +++ b/test/scripts/build-all.test.ts @@ -215,25 +215,6 @@ describe("resolveBuildAllStep", () => { } }); - it("keeps node steps on the current node binary", () => { - const step = getBuildAllStep("runtime-postbuild"); - - const result = resolveBuildAllStep(step, { - nodeExecPath: "/custom/node", - env: { FOO: "bar" }, - }); - - expect(result).toEqual({ - command: "/custom/node", - args: ["scripts/runtime-postbuild.mjs"], - options: { - stdio: "inherit", - env: { FOO: "bar" }, - shell: false, - }, - }); - }); - it("passes encoded import URLs literally to managed Node on Windows", () => { const importUrl = "file:///C:/Users/RUNNER%7E1/Project/scripts/tsx.mjs"; const result = resolveBuildAllStep( @@ -257,6 +238,11 @@ describe("resolveBuildAllStep", () => { }); it.each([ + { + label: "runtime-postbuild", + scriptPath: "scripts/runtime-postbuild.mts", + expectedEnv: { FOO: "bar" }, + }, { label: "write-plugin-sdk-entry-dts", scriptPath: "scripts/write-plugin-sdk-entry-dts.ts", @@ -928,7 +914,7 @@ describe("resolveBuildAllSteps", () => { it.each([ ["external-plugins:local-dist", "scripts/build-external-plugin-local-dist.mts"], - ["runtime-postbuild", "scripts/runtime-postbuild.mjs"], + ["runtime-postbuild", "scripts/runtime-postbuild.mts"], ])("does not stamp qaRuntime after %s fails", async (label, script) => { const invocations: ReturnType[] = []; const result = await runBuildAllSteps("qaRuntime", { diff --git a/test/scripts/dist-artifact-ownership.test.ts b/test/scripts/dist-artifact-ownership.test.ts index 34e0ff9431dc..34ec50b143a5 100644 --- a/test/scripts/dist-artifact-ownership.test.ts +++ b/test/scripts/dist-artifact-ownership.test.ts @@ -294,6 +294,166 @@ async function runWithProcesses( // Native TypeScript emits the declarations. Only // process completion is gated; ordering never depends on sleeps or host speed. describe.skipIf(process.platform === "win32")("dist artifact ownership", () => { + it.for([ + { signalName: "SIGINT" as const, exitCode: 130 }, + { signalName: "SIGTERM" as const, exitCode: 143 }, + ])( + "joins a singleton smoke import before releasing ownership after $signalName", + async ({ signalName, exitCode }, { signal }) => { + await withProcesses(async ({ checkpoint, waitEvent, start }) => { + const root = createCheckout(); + const smokeScript = write( + root, + "scripts/test-built-plugin-singleton.mts", + fs.readFileSync(path.join(sourceRoot, "scripts/test-built-plugin-singleton.mts"), "utf8"), + ); + for (const entry of [ + "lib", + "process-warning-filter.mts", + "stage-bundled-plugin-runtime.mts", + ]) { + fs.symlinkSync( + path.join(sourceRoot, "scripts", entry), + path.join(root, "scripts", entry), + ); + } + const importJoined = path.join(root, "import-joined"); + const smokePid = path.join(root, "smoke.pid"); + write( + root, + "dist/plugins/build-smoke-entry.js", + ` + import fs from 'node:fs'; + import { createRequire } from 'node:module'; + const require = createRequire(import.meta.url); + fs.writeFileSync(${JSON.stringify(smokePid)}, String(process.pid)); + if (process.listenerCount(${JSON.stringify(signalName)}) > 0) { + process.once(${JSON.stringify(signalName)}, () => { + ${checkpoint("smoke-signal-received")} + }); + } + await new Promise(resolve => { + ${checkpoint("smoke-import-ready")} + socket.on('close', resolve); + }); + fs.writeFileSync(${JSON.stringify(importJoined)}, 'joined'); + `, + ); + const smoke = start(root, smokeScript); + const importGate = await smoke.event("smoke-import-ready"); + const pid = Number(fs.readFileSync(smokePid, "utf8")); + expect(Number.isSafeInteger(pid) && pid > 1).toBe(true); + const writerStarted = path.join(root, "writer-started"); + const writerScript = write( + root, + "writer.mts", + ` + import fs from 'node:fs'; + import { createRequire } from 'node:module'; + import { withDistArtifactOwnership } from ${JSON.stringify(path.join(sourceRoot, "scripts/lib/dist-artifact-ownership.mts"))}; + const require = createRequire(import.meta.url); + await withDistArtifactOwnership(process.cwd(), () => new Promise(resolve => { + fs.writeFileSync(${JSON.stringify(writerStarted)}, 'started'); + ${checkpoint("smoke-contender-ready")} + socket.on('close', resolve); + })); + `, + ); + const writer = start(root, writerScript); + await Promise.race([writer.waiting, writer.event("smoke-contender-ready")]); + process.kill(pid, signalName); + const acknowledgment = await Promise.race([ + waitEvent("smoke-signal-received"), + smoke.done.then((result) => { + throw new Error(`Smoke exited before signal acknowledgment: ${JSON.stringify(result)}`); + }), + ]); + acknowledgment.write("continue"); + expect(fs.existsSync(importJoined)).toBe(false); + expect( + fs.existsSync(writerStarted), + "cancellation must join the pending artifact reader", + ).toBe(false); + + importGate.write("continue"); + const contenderGate = await writer.event("smoke-contender-ready"); + expect(fs.readFileSync(importJoined, "utf8")).toBe("joined"); + const cancelled = await smoke.done; + expect(cancelled.code, cancelled.output).toBe(exitCode); + expect(fs.existsSync(path.join(root, "dist/extensions/build-smoke-plugin"))).toBe(false); + contenderGate.write("continue"); + expect(await writer.done).toMatchObject({ code: 0 }); + let reacquired = false; + await withDistArtifactOwnership(root, async () => { + reacquired = true; + }); + expect(reacquired).toBe(true); + }, signal); + }, + ); + + it("keeps source-run postbuild behind the shared artifact writer", async ({ signal }) => { + await withProcesses(async ({ checkpoint, waitEvent, start }) => { + const root = createCheckout(); + write(root, "dist/entry.js", "export {};\n"); + write(root, "dist/.buildstamp", JSON.stringify({ head: "fixture-head" })); + const marker = path.join(root, "dist/postbuild-finished"); + const writerScript = write( + root, + "writer.mjs", + ` + import { createRequire } from 'node:module'; + import { withDistArtifactOwnership } from ${JSON.stringify(path.join(sourceRoot, "scripts/lib/dist-artifact-ownership.mts"))}; + const require = createRequire(import.meta.url); + await withDistArtifactOwnership(process.cwd(), () => new Promise(resolve => { + ${checkpoint("artifact-writer-ready")} + socket.on('close', resolve); + })); + `, + ); + const writer = start(root, writerScript); + const writerGate = await writer.event("artifact-writer-ready"); + const runnerScript = write( + root, + "source-runner.mjs", + ` + import ${JSON.stringify(path.join(sourceRoot, "scripts/tsx.mjs"))}; + import fs from 'node:fs'; + import { createRequire } from 'node:module'; + const require = createRequire(import.meta.url); + const { runNodeMain } = await import(${JSON.stringify(path.join(sourceRoot, "scripts/run-node.mts"))}); + process.exitCode = await runNodeMain({ + cwd: process.cwd(), args: ['artifact-fixture'], + env: { ...process.env, OPENCLAW_FORCE_BUILD: '0', OPENCLAW_BUILD_PRIVATE_QA: '0' }, + spawnSync: (_command, args) => ({ status: 0, stdout: args.includes('rev-parse') ? 'fixture-head' : '' }), + spawn: (_command, args) => { + if (args.includes('scripts/build-all.mts')) throw new Error('Expected postbuild-only path'); + return { on: (event, listener) => { + if (event === 'exit') queueMicrotask(() => listener(0, null)); + }}; + }, + runRuntimePostBuild: () => new Promise(resolve => { + fs.writeFileSync(${JSON.stringify(marker)}, 'complete'); + ${checkpoint("source-postbuild-ready")} + socket.on('close', resolve); + }), + }); + `, + ); + const runner = start(root, runnerScript); + await Promise.race([runner.waiting, waitEvent("source-postbuild-ready"), runner.done]); + expect(fs.existsSync(marker), "postbuild must wait for the current artifact writer").toBe( + false, + ); + writerGate.write("continue"); + expect(await writer.done).toMatchObject({ code: 0 }); + (await runner.event("source-postbuild-ready")).write("continue"); + const result = await runner.done; + expect(result.code, result.output).toBe(0); + expect(fs.readFileSync(marker, "utf8")).toBe("complete"); + }, signal); + }); + it("releases ownership after a native execFileSync ENOENT error", async () => { const root = createCheckout(); const error = await withDistArtifactOwnership(root, async () => diff --git a/test/scripts/stage-bundled-plugin-runtime.test.ts b/test/scripts/stage-bundled-plugin-runtime.test.ts index b7bb1ba04ad6..e5dc6e1b8037 100644 --- a/test/scripts/stage-bundled-plugin-runtime.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime.test.ts @@ -2,8 +2,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mts"; +import { + prepareBundledPluginRuntime, + stageBundledPluginRuntime, +} from "../../scripts/stage-bundled-plugin-runtime.mts"; async function withTempDir(run: (dir: string) => Promise) { const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-stage-runtime-")); @@ -78,3 +82,280 @@ describe("stageBundledPluginRuntime", () => { }); }); }); + +function writeRuntimeFixture(repoRoot: string) { + const files = { + "package.json": JSON.stringify({ + name: "openclaw", + type: "module", + exports: { "./plugin-sdk/demo": "./dist/plugin-sdk/demo.js" }, + }), + "dist/plugin-sdk/demo.js": "export const generation = 'candidate';\n", + "dist/extensions/demo/index.js": "export const generation = 'candidate';\n", + "dist/extensions/demo/package.json": '{"name":"demo","type":"module"}\n', + "dist/extensions/demo/assets/info.txt": "candidate asset\n", + }; + for (const [relative, content] of Object.entries(files)) { + const target = path.join(repoRoot, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + } + return { + runtimeRoot: path.join(repoRoot, "dist-runtime"), + aliasRoot: path.join(repoRoot, "dist/extensions/node_modules/openclaw"), + }; +} + +describe("prepareBundledPluginRuntime", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("prepares both roots without mutation and publishes imports valid at their final paths", async () => { + await withTempDir(async (repoRoot) => { + const { runtimeRoot, aliasRoot } = writeRuntimeFixture(repoRoot); + const prepared = prepareBundledPluginRuntime({ repoRoot }); + expect(prepared.changed).toBe(true); + expect(fs.existsSync(runtimeRoot)).toBe(false); + expect(fs.existsSync(aliasRoot)).toBe(false); + expect(fs.existsSync(path.dirname(aliasRoot))).toBe(false); + await prepared.publish(() => undefined); + await prepared.cleanup(); + + const runtime = await import( + pathToFileURL(path.join(runtimeRoot, "extensions/demo/index.js")).href + ); + const sdk = await import(pathToFileURL(path.join(aliasRoot, "plugin-sdk/demo.js")).href); + expect(runtime.generation).toBe("candidate"); + expect(sdk.generation).toBe("candidate"); + expect( + fs.readFileSync(path.join(runtimeRoot, "extensions/demo/assets/info.txt"), "utf8"), + ).toBe("candidate asset\n"); + + const runtimeBefore = fs.statSync(runtimeRoot); + const aliasBefore = fs.statSync(aliasRoot); + const unchanged = prepareBundledPluginRuntime({ repoRoot }); + expect(unchanged.changed).toBe(false); + await unchanged.publish(() => { + throw new Error("unchanged artifacts need no publication authority"); + }); + await unchanged.cleanup(); + expect(fs.statSync(runtimeRoot).ino).toBe(runtimeBefore.ino); + expect(fs.statSync(aliasRoot).ino).toBe(aliasBefore.ino); + }); + }); + + it.each([true, false])( + "preserves canonical static asset behavior without writing through staging links (copy=%s)", + async (copyStaticAssets) => { + vi.stubEnv("OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS", copyStaticAssets ? undefined : "0"); + await withTempDir(async (repoRoot) => { + const { runtimeRoot, aliasRoot } = writeRuntimeFixture(repoRoot); + const sourceRoot = path.join(repoRoot, "dist/extensions/demo"); + const staticAssets = ["index.html", "assets/app.js", "assets/app.css"].map((name) => ({ + source: `client/${name}`, + output: `dist/control-ui/${name}`, + })); + fs.writeFileSync( + path.join(sourceRoot, "package.json"), + JSON.stringify({ name: "demo", type: "module", openclaw: { build: { staticAssets } } }), + ); + for (const asset of staticAssets) { + const output = path.join(sourceRoot, asset.output); + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.writeFileSync(output, `raw ${asset.output}\n`); + } + stageBundledPluginRuntime({ repoRoot }); + const aliasBefore = fs.statSync(aliasRoot).ino; + const liveStatic = path.join(runtimeRoot, "extensions/demo/dist/control-ui"); + const liveBefore = fs.statSync(liveStatic).ino; + const jsBefore = fs.readFileSync(path.join(liveStatic, "assets/app.js"), "utf8"); + if (copyStaticAssets) { + fs.rmSync(liveStatic, { recursive: true }); + } + const sourceBefore = fs.statSync(path.join(sourceRoot, staticAssets[0]!.output)); + + const prepared = prepareBundledPluginRuntime({ repoRoot }); + expect(prepared.changed).toBe(copyStaticAssets); + expect(fs.existsSync(liveStatic)).toBe(!copyStaticAssets); + expect(fs.statSync(path.join(sourceRoot, staticAssets[0]!.output)).mtimeMs).toBe( + sourceBefore.mtimeMs, + ); + await prepared.publish(() => { + if (!copyStaticAssets) { + throw new Error("A complete minimal build must not publish runtime changes."); + } + }); + await prepared.cleanup(); + expect(fs.statSync(aliasRoot).ino).toBe(aliasBefore); + if (copyStaticAssets) { + for (const asset of staticAssets) { + const output = path.join(runtimeRoot, "extensions/demo", asset.output); + expect(fs.lstatSync(output).isFile()).toBe(true); + expect(fs.readFileSync(output, "utf8")).toBe(`raw ${asset.output}\n`); + } + } else { + expect(fs.statSync(liveStatic).ino).toBe(liveBefore); + expect(fs.readFileSync(path.join(liveStatic, "assets/app.js"), "utf8")).toBe(jsBefore); + } + const unchanged = prepareBundledPluginRuntime({ repoRoot }); + expect(unchanged.changed).toBe(false); + await unchanged.cleanup(); + }); + }, + ); + + it.each(["content", "mode", "symlink target"] as const)( + "detects stale %s even when every required path exists", + async (difference) => { + await withTempDir(async (repoRoot) => { + const { runtimeRoot } = writeRuntimeFixture(repoRoot); + stageBundledPluginRuntime({ repoRoot }); + const metadata = path.join(runtimeRoot, "extensions/demo/package.json"); + if (difference === "content") { + fs.writeFileSync(metadata, '{"name":"stale","type":"module"}\n'); + } else if (difference === "mode") { + fs.chmodSync(metadata, 0o444); + } else { + const asset = path.join(runtimeRoot, "extensions/demo/assets/info.txt"); + fs.unlinkSync(asset); + fs.symlinkSync("../../../../dist/extensions/demo/index.js", asset); + } + const prepared = prepareBundledPluginRuntime({ repoRoot }); + expect(prepared.changed).toBe(true); + await prepared.cleanup(); + }); + }, + ); + + it("leaves both live roots intact when preparation fails", async () => { + await withTempDir(async (repoRoot) => { + const { runtimeRoot, aliasRoot } = writeRuntimeFixture(repoRoot); + stageBundledPluginRuntime({ repoRoot }); + const runtimeBefore = fs.statSync(runtimeRoot).ino; + const aliasBefore = fs.statSync(aliasRoot).ino; + const originalWrite = fs.writeFileSync.bind(fs); + vi.spyOn(fs, "writeFileSync").mockImplementation((target, ...args) => { + if (String(target).includes(".openclaw-runtime-")) { + throw new Error("staging write failed"); + } + return originalWrite(target, ...args); + }); + expect(() => prepareBundledPluginRuntime({ repoRoot })).toThrow("staging write failed"); + expect(fs.statSync(runtimeRoot).ino).toBe(runtimeBefore); + expect(fs.statSync(aliasRoot).ino).toBe(aliasBefore); + expect(fs.readdirSync(repoRoot).some((name) => name.startsWith(".openclaw-runtime-"))).toBe( + false, + ); + expect(fs.readdirSync(path.dirname(aliasRoot))).toEqual(["openclaw"]); + }); + }); + + it.each([false, true])( + "restores originals or retains failed restoration (restore fails=%s)", + async (failRestore) => { + await withTempDir(async (repoRoot) => { + const { runtimeRoot, aliasRoot } = writeRuntimeFixture(repoRoot); + stageBundledPluginRuntime({ repoRoot }); + fs.writeFileSync(path.join(runtimeRoot, "original.txt"), "original runtime"); + fs.writeFileSync(path.join(aliasRoot, "original.txt"), "original alias"); + const prepared = prepareBundledPluginRuntime({ repoRoot }); + const originalRename = fs.renameSync.bind(fs); + vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => { + if (String(destination) === aliasRoot && path.basename(String(source)) === "candidate") { + throw new Error("alias publication failed"); + } + if ( + failRestore && + String(destination) === aliasRoot && + path.basename(String(source)) === "previous" + ) { + throw new Error("alias restoration failed"); + } + originalRename(source, destination); + }); + await expect(prepared.publish(() => undefined)).rejects.toThrow( + failRestore ? "Runtime publication and restoration failed" : "alias publication failed", + ); + await prepared.cleanup(); + expect(fs.readFileSync(path.join(runtimeRoot, "original.txt"), "utf8")).toBe( + "original runtime", + ); + if (failRestore) { + const retained = fs + .readdirSync(path.dirname(aliasRoot)) + .find((name) => name.startsWith(".openclaw-runtime-")); + expect(retained).toBeDefined(); + expect( + fs.readFileSync( + path.join(path.dirname(aliasRoot), retained!, "previous/original.txt"), + "utf8", + ), + ).toBe("original alias"); + } else { + expect(fs.readFileSync(path.join(aliasRoot, "original.txt"), "utf8")).toBe( + "original alias", + ); + expect(fs.readdirSync(path.dirname(aliasRoot))).toEqual(["openclaw"]); + } + }); + }, + ); + + it("keeps live paths present when authority is revoked between root swaps", async () => { + await withTempDir(async (repoRoot) => { + const { runtimeRoot, aliasRoot } = writeRuntimeFixture(repoRoot); + stageBundledPluginRuntime({ repoRoot }); + fs.writeFileSync(path.join(runtimeRoot, "original.txt"), "original runtime"); + fs.writeFileSync(path.join(aliasRoot, "original.txt"), "original alias"); + const aliasBefore = fs.statSync(aliasRoot).ino; + const prepared = prepareBundledPluginRuntime({ repoRoot }); + let checks = 0; + await expect( + prepared.publish(() => { + expect( + fs.existsSync(runtimeRoot), + "authority checks must not expose an absent runtime", + ).toBe(true); + expect(fs.existsSync(aliasRoot), "authority checks must not expose an absent alias").toBe( + true, + ); + if (++checks > 1) { + throw new Error("authority revoked"); + } + }), + ).rejects.toThrow("Runtime publication and restoration failed"); + await prepared.cleanup(); + expect(fs.existsSync(path.join(runtimeRoot, "extensions/demo/index.js"))).toBe(true); + expect(fs.statSync(aliasRoot).ino).toBe(aliasBefore); + const retained = fs + .readdirSync(repoRoot) + .find((name) => name.startsWith(".openclaw-runtime-")); + expect(fs.readFileSync(path.join(repoRoot, retained!, "previous/original.txt"), "utf8")).toBe( + "original runtime", + ); + }); + }); + + it("keeps supported Windows copy fallbacks unchanged", async () => { + await withTempDir(async (repoRoot) => { + const { runtimeRoot } = writeRuntimeFixture(repoRoot); + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const symbolicLink = vi.spyOn(fs, "symlinkSync").mockImplementation(() => { + throw Object.assign(new Error("no symlink privilege"), { code: "EPERM" }); + }); + stageBundledPluginRuntime({ repoRoot }); + const copied = prepareBundledPluginRuntime({ repoRoot }); + expect(copied.changed).toBe(false); + await copied.cleanup(); + symbolicLink.mockRestore(); + const prepared = prepareBundledPluginRuntime({ repoRoot }); + expect(prepared.changed).toBe(false); + await prepared.cleanup(); + expect(fs.lstatSync(path.join(runtimeRoot, "extensions/demo/assets/info.txt")).isFile()).toBe( + true, + ); + }); + }); +}); diff --git a/test/tsconfig/tsconfig.core.test.services.json b/test/tsconfig/tsconfig.core.test.services.json index 89d6da78e612..6499adcf6009 100644 --- a/test/tsconfig/tsconfig.core.test.services.json +++ b/test/tsconfig/tsconfig.core.test.services.json @@ -15,8 +15,6 @@ "../../src/skills/**/*.test.ts", "../../src/skills/**/*.test.tsx", "../../src/secrets/**/*.test.ts", - "../../src/secrets/**/*.test.tsx", - "../../src/plugin-state/**/*.test.ts", - "../../src/plugin-state/**/*.test.tsx" + "../../src/secrets/**/*.test.tsx" ] } diff --git a/test/tsconfig/tsconfig.core.test.state-logging.json b/test/tsconfig/tsconfig.core.test.state-logging.json index f19f7fc9f133..846d7f0a64ba 100644 --- a/test/tsconfig/tsconfig.core.test.state-logging.json +++ b/test/tsconfig/tsconfig.core.test.state-logging.json @@ -6,6 +6,8 @@ "include": [ "../../src/state/**/*.test.ts", "../../src/state/**/*.test.tsx", + "../../src/plugin-state/**/*.test.ts", + "../../src/plugin-state/**/*.test.tsx", "../../src/logging/**/*.test.ts", "../../src/logging/**/*.test.tsx", "../../src/shared/**/*.test.ts",