diff --git a/docs/cli/update/how-updates-run.md b/docs/cli/update/how-updates-run.md index dd2d3cbc00fb..d97a1029ae3a 100644 --- a/docs/cli/update/how-updates-run.md +++ b/docs/cli/update/how-updates-run.md @@ -44,6 +44,13 @@ reported cause, then run the warning's exact cleanup command or that cannot boot or pass readiness still block completion. See [Status and history](/cli/update/status-and-history) to inspect recorded warnings. +The baseline package fingerprint is best effort. If its bounded scan times out, +the update records a warning and continues with the retained package copy. +Rollback then verifies the restored directory identity, package version, and +affected launchers, and records that full fingerprint verification was unavailable. +A timeout alone does not fail the update or rollback; detected changes to the +retained copy still refuse restoration. + Interrupting a fresh local update before activation records a failed, `interrupted` history entry while its installation owner is still held. An interrupted update is not a successful update or a verified rollback. diff --git a/src/cli/update-cli/update-command-run.test.ts b/src/cli/update-cli/update-command-run.test.ts index 589303a706d9..d447e25cd615 100644 --- a/src/cli/update-cli/update-command-run.test.ts +++ b/src/cli/update-cli/update-command-run.test.ts @@ -32,14 +32,24 @@ import { withUpdatePreviewSignals, } from "./update-command-run.js"; import * as servicePlan from "./update-command-service-plan.js"; +import { publishUpdateCommandTerminalResult } from "./update-command-terminal.js"; const dirs = useAutoCleanupTempDirTracker(afterEach); -it.each([0, 86])("persists and surfaces successful Doctor warnings (exit %s)", (exitCode) => { +it.each([ + { kind: "package-post-install-doctor", name: "openclaw doctor", exitCode: 0 }, + { kind: "package-post-install-doctor", name: "openclaw doctor", exitCode: 86 }, + { kind: "recoverable-maintenance", name: "global install swap", exitCode: 0 }, +] as const)("persists and surfaces $kind warnings (exit $exitCode)", ({ kind, name, exitCode }) => { const env = { OPENCLAW_STATE_DIR: dirs.make("update-warning-ledger-") }; const run = { runId: createUpdateRun({ trigger: "cli" }, { env }).runId, env }; - const message = "Skipped derived cache cleanup: permission denied. Run openclaw doctor --fix."; + const message = + kind === "recoverable-maintenance" + ? "baseline package fingerprint incomplete after 30 s; rollback will be verified by the retained package copy" + : "Skipped derived cache cleanup: permission denied. Run openclaw doctor --fix."; const otherWarning = - "Skipped legacy cache cleanup: read-only directory. Run openclaw doctor --fix."; + kind === "recoverable-maintenance" + ? "Package fingerprint verification unavailable; rollback verified by the retained package copy's directory identity and version." + : "Skipped legacy cache cleanup: read-only directory. Run openclaw doctor --fix."; const result = completeUpdateCommandRun( { status: "ok", @@ -47,12 +57,12 @@ it.each([0, 86])("persists and surfaces successful Doctor warnings (exit %s)", ( durationMs: 1, steps: [ { - name: "openclaw doctor", - command: "openclaw doctor --fix", + name, + command: name, cwd: "/tmp/update-fixture", durationMs: 1, exitCode, - advisory: { kind: "package-post-install-doctor", message }, + advisory: { kind, message }, warnings: [message, otherWarning], }, ], @@ -65,12 +75,12 @@ it.each([0, 86])("persists and surfaces successful Doctor warnings (exit %s)", ( status: "succeeded", steps: expect.arrayContaining([ expect.objectContaining({ - step: "warning:openclaw doctor", + step: `warning:${name}`, status: "completed", detail: message, }), expect.objectContaining({ - step: "warning:openclaw doctor:2", + step: `warning:${name}:2`, status: "completed", detail: otherWarning, }), @@ -85,6 +95,60 @@ afterEach(() => { vi.restoreAllMocks(); }); +it("persists fingerprint warnings before closing a rolled-back run", () => { + const env = { OPENCLAW_STATE_DIR: dirs.make("rollback-fingerprint-warning-") }; + const run = { runId: createUpdateRun({ trigger: "cli" }, { env }).runId, env }; + const warnings = [ + "baseline package fingerprint incomplete after 30 s; rollback will be verified by the retained package copy", + "Package fingerprint verification unavailable; rollback verified by the retained package copy's directory identity and version.", + ]; + vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {}); + const result = publishUpdateCommandTerminalResult( + { opts: { json: true, run }, ownedManagedUpdateEnv: env }, + { + status: "error", + mode: "npm", + reason: "doctor-failed", + before: { version: "1.0.0" }, + after: { version: "1.0.0" }, + recovery: { + serviceRestartSafe: true, + packageRollbackVerified: true, + service: "healthy", + version: "1.0.0", + }, + durationMs: 50, + steps: [ + { + name: "global install rollback", + command: "restore", + cwd: env.OPENCLAW_STATE_DIR, + durationMs: 1, + exitCode: 0, + advisory: { kind: "recoverable-maintenance", message: warnings.join("\n") }, + warnings, + }, + ], + }, + { rolledBack: true, downtimeMs: 25 }, + ); + expect(result).toMatchObject({ status: "error", reason: "doctor-failed" }); + const recorded = getUpdateRun(run.runId, { env })!; + expect(recorded).toMatchObject({ + status: "rolled-back", + reason: "doctor-failed", + downtimeMs: 25, + }); + expect(recorded.steps).toEqual( + expect.arrayContaining( + warnings.map((detail) => expect.objectContaining({ status: "completed", detail })), + ), + ); + for (const warning of warnings) { + expect(renderUpdateRunReport(recorded).markdown).toContain(warning); + } +}); + it("presents committed steps without reopening the ledger for display", () => { const env = { OPENCLAW_STATE_DIR: dirs.make("update-progress-committed-") }; const run = { runId: createUpdateRun({ trigger: "cli" }, { env }).runId, env }; diff --git a/src/cli/update-cli/update-command-run.ts b/src/cli/update-cli/update-command-run.ts index ac549021da69..e40c6ae30422 100644 --- a/src/cli/update-cli/update-command-run.ts +++ b/src/cli/update-cli/update-command-run.ts @@ -337,7 +337,7 @@ export function createUpdateRunProgress( export function completeUpdateCommandRun( result: UpdateRunResult, run: UpdateCommandOptions["run"], - downtimeMs?: number, + completion: { rolledBack?: boolean; downtimeMs?: number } = {}, ): UpdateRunResult { if (!run) { return result; @@ -402,15 +402,16 @@ export function completeUpdateCommandRun( finishUpdateRun( run.runId, { - status: - normalized.status === "ok" + status: completion.rolledBack + ? "rolled-back" + : normalized.status === "ok" ? "succeeded" : normalized.status === "error" ? "failed" : "skipped", reason: normalized.reason, after: normalized.after, - downtimeMs, + downtimeMs: completion.downtimeMs, }, recordOptions, ); diff --git a/src/cli/update-cli/update-command-terminal.ts b/src/cli/update-cli/update-command-terminal.ts index cf02e9cb6213..bf30f3ac5d1d 100644 --- a/src/cli/update-cli/update-command-terminal.ts +++ b/src/cli/update-cli/update-command-terminal.ts @@ -4,11 +4,7 @@ import { type ControlPlaneUpdateSentinelMetaFile, } from "../../infra/update-control-plane-sentinel.js"; import { verifyPackageUpdateRecovery } from "../../infra/update-global.js"; -import { - finishUpdateRun, - getUpdateRun, - recordUpdateRunPhase, -} from "../../infra/update-run-ledger.js"; +import { getUpdateRun, recordUpdateRunPhase } from "../../infra/update-run-ledger.js"; import { assertUpdateRecoveryAdmission } from "../../infra/update-run-recovery-admission.js"; import { readCurrentGitUpdateRecovery } from "../../infra/update-runner-git-recovery.js"; import type { UpdateRunResult, UpdateStepResult } from "../../infra/update-runner.js"; @@ -245,16 +241,7 @@ export function publishUpdateCommandTerminalResult( outcome: { rolledBack: boolean; downtimeMs?: number }, ): UpdateRunResult { const nextAction = recordUpdateResultNextAction(params, input); - const run = params.opts.run; - const { downtimeMs } = outcome; - if (run && outcome.rolledBack) { - finishUpdateRun( - run.runId, - { status: "rolled-back", reason: input.reason, after: input.after, downtimeMs }, - { env: run.env }, - ); - } - const result = completeUpdateCommandRun(input, run, downtimeMs); + const result = completeUpdateCommandRun(input, params.opts.run, outcome); printResult(result, params.opts, { nextAction }); return result; } diff --git a/src/infra/package-update-integrity.ts b/src/infra/package-update-integrity.ts index 19789925682e..6aae649e4aae 100644 --- a/src/infra/package-update-integrity.ts +++ b/src/infra/package-update-integrity.ts @@ -16,6 +16,13 @@ const log = createSubsystemLogger("update/package-integrity"); let readerSequence = 0; export type PackageIntegrityFingerprint = { digest: string; identity: string; version: string }; +export type PackageDirectoryIdentity = Pick; + +export class PackageIntegrityTimeoutError extends Error { + constructor(readonly budgetMs: number) { + super("Package rollback verification timed out"); + } +} export type PackageRootIntegrityFingerprint = | { kind: "directory"; tree: PackageIntegrityFingerprint } @@ -121,7 +128,7 @@ export function createPackageIntegrityReader(timeoutMs = MAX_SCAN_MS) { ) .catch(() => {}); } - throw new Error("Package rollback verification timed out"); + throw new PackageIntegrityTimeoutError(budget); } return value; } @@ -170,18 +177,17 @@ export function createPackageIntegrityReader(timeoutMs = MAX_SCAN_MS) { } const hash = createHash("sha256"); const buffer = Buffer.allocUnsafe(64 * 1024); + const size = Number(stat.size); let position = 0; - while (true) { + // The final stat detects growth; an extra EOF read costs one OS call per file. + while (position < size) { const { bytesRead } = await read(() => - handle.read(buffer, 0, Math.min(buffer.length, remainingBytes - position + 1), position), + handle.read(buffer, 0, Math.min(buffer.length, size - position), position), ); if (bytesRead === 0) { - break; + throw new Error("Package rollback file changed while reading"); } position += bytesRead; - if (position > remainingBytes) { - throw new Error("Package rollback verification byte limit exceeded"); - } hash.update(buffer.subarray(0, bytesRead)); } if (!unchanged(stat, await read(() => handle.stat({ bigint: true })))) { @@ -306,6 +312,21 @@ export function createPackageIntegrityReader(timeoutMs = MAX_SCAN_MS) { return { kind: "link", metadata: metadata(stat).slice(0, -1), target }; } + async function directoryIdentity(root: string): Promise { + const stat = await read(() => fs.lstat(root, { bigint: true })); + if (stat.isSymbolicLink()) { + return null; + } + if (!stat.isDirectory() || stat.ino === 0n) { + throw new Error("Package rollback filesystem identity is unavailable"); + } + const version = await read(() => readPackageVersion(root, { maxBytes: MAX_MANIFEST_BYTES })); + if (!version || !unchanged(stat, await read(() => fs.lstat(root, { bigint: true })))) { + throw new Error("Package rollback identity changed or version is unavailable"); + } + return { identity: identity(stat), version }; + } + async function launcher(file: string): Promise { const stat = await read(() => fs.lstat(file, { bigint: true })); const contents = stat.isSymbolicLink() @@ -337,5 +358,5 @@ export function createPackageIntegrityReader(timeoutMs = MAX_SCAN_MS) { } } - return { tree, rootEntry, launcher, exists, entries, observe }; + return { tree, rootEntry, directoryIdentity, launcher, exists, entries, observe }; } diff --git a/src/infra/package-update-npm-root.ts b/src/infra/package-update-npm-root.ts index c6a1cb541b2a..39cbfa1755ed 100644 --- a/src/infra/package-update-npm-root.ts +++ b/src/infra/package-update-npm-root.ts @@ -3,6 +3,7 @@ import { isDeepStrictEqual } from "node:util"; import { formatErrorMessage } from "./errors.js"; import { createPackageIntegrityReader, + type PackageDirectoryIdentity, type PackageRootIntegrityFingerprint, } from "./package-update-integrity.js"; @@ -63,6 +64,7 @@ export async function verifyNpmRootRecovery( fromBackup: boolean; hadPackage: boolean; previousRoot: PackageRootIntegrityFingerprint | undefined; + previousIdentity?: PackageDirectoryIdentity; targetSwapRoot: string; shims: readonly { destination: string; backup: string | null; fingerprint?: string }[]; }, @@ -73,11 +75,13 @@ export async function verifyNpmRootRecovery( await reader.observe(fromBackup ? "retained" : "restored", async () => { if ( hadPackage - ? !previousRoot || - !isDeepStrictEqual( - await reader.rootEntry(root, targetSwapRoot, previousRoot.kind), - previousRoot, - ) + ? previousRoot + ? !isDeepStrictEqual( + await reader.rootEntry(root, targetSwapRoot, previousRoot.kind), + previousRoot, + ) + : !params.previousIdentity || + !isDeepStrictEqual(await reader.directoryIdentity(root), params.previousIdentity) : !fromBackup && (await reader.exists(root)) ) { throw new Error( diff --git a/src/infra/package-update-swap.bounds.test.ts b/src/infra/package-update-swap.bounds.test.ts index 5d6ab13eb7df..59b543baefa1 100644 --- a/src/infra/package-update-swap.bounds.test.ts +++ b/src/infra/package-update-swap.bounds.test.ts @@ -7,6 +7,7 @@ import { createDeferredCore } from "../shared/deferred.js"; import { withTestDir } from "../test-helpers/temp-dir.js"; import { swapStagedPackageInstall, type PackageUpdateTransaction } from "./package-update-swap.js"; import { createPackageSwapFixture } from "./package-update-swap.test-support.js"; +import { updateRunStepsFromResultStep } from "./update-run-step.js"; afterEach(() => { setLoggerOverride(null); @@ -29,6 +30,88 @@ function captureReaderLogs() { } describe("package verification bounds", () => { + it.each(["activation", "rollback", "changed identity", "changed version"] as const)( + "handles %s after the baseline fingerprint times out", + async (outcome) => { + await withTestDir({ prefix: "openclaw-fingerprint-advisory-" }, async (base) => { + const { params, packageRoot, launcher } = await createPackageSwapFixture(base); + const original = await fs.stat(packageRoot); + const open = fs.open.bind(fs); + const blocked = createDeferredCore(); + let entered = false; + vi.spyOn(fs, "open").mockImplementation(async (...args) => { + if (!entered && String(args[0]) === path.join(packageRoot, "dist", "index.js")) { + entered = true; + await blocked.promise; + } + return open(...args); + }); + let transaction: PackageUpdateTransaction | undefined; + const beforeActivate = vi.fn(); + try { + const result = await swapStagedPackageInstall({ + ...params, + timeoutMs: 200, + beforeActivate, + onTransaction: (value) => { + transaction = value; + }, + }); + expect(entered).toBe(true); + expect(result.status, result.step.stderrTail ?? "").toBe("committed"); + expect(beforeActivate).toHaveBeenCalledOnce(); + expect(result.step.advisory?.message).toContain( + "baseline package fingerprint incomplete", + ); + expect(updateRunStepsFromResultStep(result.step)).toContainEqual( + expect.objectContaining({ step: "warning:global install swap", status: "completed" }), + ); + expect(await fs.readFile(launcher, "utf8")).toBe("candidate launcher\n"); + if (!transaction) { + throw new Error("Missing package transaction"); + } + if (outcome === "changed identity" || outcome === "changed version") { + if (outcome === "changed identity") { + await fs.rename(transaction.backupRoot, `${transaction.backupRoot}.original`); + await fs.cp(`${transaction.backupRoot}.original`, transaction.backupRoot, { + recursive: true, + }); + } else { + await fs.writeFile( + path.join(transaction.backupRoot, "package.json"), + '{"version":"3.0.0"}', + ); + } + const refused = await transaction.rollback(() => {}); + expect(refused.exitCode).toBe(1); + expect(refused.advisory).toBeUndefined(); + expect(refused.stderrTail).toContain("retained package tree changed"); + expect(await fs.readFile(launcher, "utf8")).toBe("candidate launcher\n"); + await expect(fs.stat(transaction.backupRoot)).resolves.toBeDefined(); + return; + } + if (outcome === "rollback") { + const restored = await transaction.rollback(() => {}); + expect(restored).toMatchObject({ exitCode: 0, activePackageRoot: packageRoot }); + expect(restored.advisory?.message).toContain("fingerprint verification unavailable"); + expect(restored.stderrTail ?? "").not.toMatch(/unverified|verification failed/); + expect(await fs.readFile(launcher, "utf8")).toBe("old launcher\n"); + expect(await fs.readFile(path.join(packageRoot, "package.json"), "utf8")).toContain( + '"version":"1.0.0"', + ); + const actual = await fs.stat(packageRoot); + expect([actual.dev, actual.ino]).toEqual([original.dev, original.ino]); + } + expect( + await transaction.complete({ activationVerified: outcome === "activation" }, () => {}), + ).toBeUndefined(); + } finally { + blocked.resolve(); + } + }); + }, + ); + it.each([1024 * 1024 + 1, 1024 * 1024 * 1024 + 1])( "rejects manifest growth to %i bytes without attempting an oversized metadata allocation", async (size) => { @@ -95,8 +178,13 @@ describe("package verification bounds", () => { expect((await transactions[0]!.rollback(() => {})).exitCode).toBe(0); await expect(fs.readFile(manifest, "utf8")).resolves.toHaveLength(1024 * 1024); const finished = observations.filter((record) => record.event === "reader-settled"); - expect(finished.map((record) => record.phase)).toEqual(["baseline", "retained", "restored"]); - expect(new Set(finished.map((record) => record.readerId)).size).toBe(3); + expect(finished.map((record) => record.phase)).toEqual([ + "baseline", + "baseline", + "retained", + "restored", + ]); + expect(new Set(finished.map((record) => record.readerId)).size).toBe(4); for (const record of finished) { expect(record).toMatchObject({ outcome: "completed", budgetMs: 30_000, pendingIo: 0 }); expect(record.timeoutObservedAtMonotonicMs).toBeUndefined(); @@ -179,6 +267,9 @@ describe("package verification bounds", () => { const close = vi.spyOn(handle, "close"); const read = vi.spyOn(handle, "read"); const open = vi.spyOn(fs, "open").mockImplementation(async (...args) => { + if (String(args[0]) !== path.join(packageRoot, "dist", "index.js")) { + return realOpen(...args); + } if (operation === "open") { return late.promise; } @@ -197,13 +288,21 @@ describe("package verification bounds", () => { onLiveMutation, timeoutMs: 40, }); - expect(result.status).toBe("failed"); - expect(result.step.stderrTail).toContain("timed out"); + expect(result.status).toBe("committed"); + expect(result.step.advisory?.message).toContain( + "baseline package fingerprint incomplete", + ); expect(Date.now() - started).toBeLessThan(2000); - expect(beforeActivate).not.toHaveBeenCalled(); - expect(onLiveMutation).not.toHaveBeenCalled(); - expect(open).toHaveBeenCalledTimes(1); - const baseline = observations.filter((record) => record.phase === "baseline"); + expect(beforeActivate).toHaveBeenCalledOnce(); + expect(onLiveMutation).toHaveBeenCalledOnce(); + expect( + open.mock.calls.filter( + ([file]) => String(file) === path.join(packageRoot, "dist", "index.js"), + ), + ).toHaveLength(1); + const baseline = observations.filter( + (record) => record.readerId === observations[0]?.readerId, + ); expect(baseline).toHaveLength(2); const [begin, settled] = baseline; expect(begin).toMatchObject({ event: "reader-started", budgetMs: 40 }); @@ -230,8 +329,8 @@ describe("package verification bounds", () => { } await expect( fs.readFile(path.join(packageRoot, "package.json"), "utf8"), - ).resolves.toContain('"version":"1.0.0"'); - await expect(fs.readFile(launcher, "utf8")).resolves.toBe("old launcher\n"); + ).resolves.toContain('"version":"2.0.0"'); + await expect(fs.readFile(launcher, "utf8")).resolves.toBe("candidate launcher\n"); } finally { late.resolve(handle); await handle.close(); @@ -289,7 +388,7 @@ describe("package verification bounds", () => { const result = await swapStagedPackageInstall({ ...params, timeoutMs: 40 }); // Preserve the existing best-effort close policy, but report its timeout. expect(result.status).toBe("committed"); - expect(observations.find((record) => record.event === "reader-settled")).toMatchObject({ + expect(observations.findLast((record) => record.event === "reader-settled")).toMatchObject({ phase: "baseline", outcome: "timed-out", pendingIo: 1, diff --git a/src/infra/package-update-swap.ts b/src/infra/package-update-swap.ts index 1d3cab899e55..995366a1b56a 100644 --- a/src/infra/package-update-swap.ts +++ b/src/infra/package-update-swap.ts @@ -18,7 +18,9 @@ import { } from "./package-update-filesystem.js"; import { createPackageIntegrityReader, + PackageIntegrityTimeoutError, readPackageVersionIfPresent, + type PackageDirectoryIdentity, type PackageRootIntegrityFingerprint, } from "./package-update-integrity.js"; import { @@ -82,6 +84,7 @@ export async function swapStagedPackageInstall(params: { : params.installTarget.packageRoot; const targetSwapRoot = native?.liveProjectRoot ?? targetPackageRoot; const stagedSwapRoot = native?.projectRoot ?? params.stage.packageRoot; + const warnings: string[] = []; const step = ( exitCode: number, stdoutTail: string | null, @@ -94,6 +97,15 @@ export async function swapStagedPackageInstall(params: { exitCode, stdoutTail, stderrTail, + ...(exitCode === 0 && warnings.length > 0 + ? { + advisory: { + kind: "recoverable-maintenance" as const, + message: warnings.join("\n"), + }, + warnings: [...warnings], + } + : {}), }); if (!targetLayout || !targetPackageRoot || !targetSwapRoot) { return { @@ -115,6 +127,7 @@ export async function swapStagedPackageInstall(params: { let previousVersion: string | null = null; let previousDistFiles: string[] | undefined; let previousRoot: PackageRootIntegrityFingerprint | undefined; + let previousIdentity: PackageDirectoryIdentity | undefined; let rootLink: ReturnType | undefined; let packageBackedUp = false; let displacedCandidateRoot: string | undefined; @@ -132,7 +145,7 @@ export async function swapStagedPackageInstall(params: { let activationCompleted = false; const verifyNpmRecovery = (root: string, fromBackup: boolean) => verifyNpmRootRecovery( - { root, fromBackup, hadPackage, previousRoot, targetSwapRoot, shims }, + { root, fromBackup, hadPackage, previousRoot, previousIdentity, targetSwapRoot, shims }, params.timeoutMs, ); const restoreSwap = async (assertCurrent = () => {}): Promise => { @@ -191,7 +204,15 @@ export async function swapStagedPackageInstall(params: { await verifyNpmRecovery(targetSwapRoot, false); // Returning to absence cannot establish a verified previous runtime. packageRollbackVerified = - hadPackage && previousRoot?.kind === "directory" && messages.length === 0; + hadPackage && + (previousRoot?.kind === "directory" || + (!previousRoot && previousIdentity !== undefined)) && + messages.length === 0; + if (packageRollbackVerified && !previousRoot) { + warnings.push( + "Package fingerprint verification unavailable; rollback verified by the retained package copy's directory identity and version.", + ); + } if (previousRoot?.kind === "link" && messages.length === 0) { messages.push( `${rollback.length > 0 ? "Restored" : "Verified"} the npm package link and affected launchers; external checkout runtime integrity is unverified.`, @@ -263,11 +284,29 @@ export async function swapStagedPackageInstall(params: { ? await readPackageVersionIfPresent(params.installTarget.packageRoot) : null; if (hadPackage && !native) { - // Unreadable or unbounded rollback material must fail while the old - // package is still live, before beforeActivate may stop its service. - previousRoot = await baseline.rootEntry(targetSwapRoot); - previousVersion = previousRoot.kind === "directory" ? previousRoot.tree.version : null; - if (previousRoot.kind === "link") { + try { + previousRoot = await baseline.rootEntry(targetSwapRoot); + } catch (error) { + if (!(error instanceof PackageIntegrityTimeoutError)) { + throw error; + } + // Capture the identity before mutation even when the full walk exhausted its budget. + previousIdentity = + (await createPackageIntegrityReader(params.timeoutMs).directoryIdentity( + targetSwapRoot, + )) ?? undefined; + if (!previousIdentity) { + throw error; + } + warnings.push( + `baseline package fingerprint incomplete after ${error.budgetMs / 1000} s; rollback will be verified by the retained package copy`, + ); + } + previousVersion = + previousRoot?.kind === "directory" + ? previousRoot.tree.version + : (previousIdentity?.version ?? null); + if (previousRoot?.kind === "link") { rootLink = createNpmPackageRootLinkLifecycle({ liveRoot: targetSwapRoot, backupRoot, @@ -282,6 +321,8 @@ export async function swapStagedPackageInstall(params: { (await collectPackageDistInventory(params.installTarget.packageRoot!)); } packageRollbackVerified = hadPackage && previousVersion !== null; + }; + const readLaunchers = async (launcherReader: ReturnType) => { await fs.mkdir(targetLayout.globalRoot, { recursive: true }); const shimNames = new Set([params.packageName, "openclaw"]); const shimEntries = @@ -291,7 +332,7 @@ export async function swapStagedPackageInstall(params: { await ( native ? fs.readdir(params.stage.layout.binDir) - : baseline.entries(params.stage.layout.binDir) + : launcherReader.entries(params.stage.layout.binDir) ).catch((error: unknown) => { if (hasErrnoCode(error, "ENOENT")) { return []; @@ -312,13 +353,14 @@ export async function swapStagedPackageInstall(params: { const destination = path.join(targetLayout.binDir, entry); const backup = (await (native ? pathEntryExists(destination) - : baseline.exists(destination))) + : launcherReader.exists(destination))) ? path.join(shimBackupDir, entry) : null; - const fingerprint = backup && !native ? await baseline.launcher(destination) : undefined; + const fingerprint = + backup && !native ? await launcherReader.launcher(destination) : undefined; if (backup) { await copyPathEntry(destination, backup); - if (!native && (await baseline.launcher(backup)) !== fingerprint) { + if (!native && (await launcherReader.launcher(backup)) !== fingerprint) { throw new Error(`Package rollback launcher backup changed: ${destination}`); } } @@ -333,6 +375,9 @@ export async function swapStagedPackageInstall(params: { }; try { await (native ? readBaseline() : baseline.observe("baseline", readBaseline)); + // The optional tree scan must not consume the launcher backup's deadline. + const launcherReader = createPackageIntegrityReader(params.timeoutMs); + await launcherReader.observe("baseline", () => readLaunchers(launcherReader)); // Validation and launcher backup finish while the old Gateway is serving. // Only this boundary authorizes the orchestrator to suspend the service. const assertProjectUnchanged = native @@ -536,7 +581,10 @@ export async function swapStagedPackageInstall(params: { } activePackageRoot = null; packageBackedUp = true; - packageRollbackVerified = native !== undefined || previousRoot?.kind === "directory"; + packageRollbackVerified = + native !== undefined || + previousRoot?.kind === "directory" || + previousIdentity !== undefined; } rollback.push(async (assertCurrent) => { if (!native && hadPackage) {