diff --git a/docs/cli/update.md b/docs/cli/update.md index 11f516dd3318..af3ab7f6c1e9 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -227,6 +227,14 @@ worker-launch code; installing a corrected candidate cannot repair that first ho Source updates retain a retired workspace dependency link when only its ignored `node_modules` directory remains. An older installed updater that fails at `updater-runtime-retention` needs this correction in its running code before retrying; a newer candidate cannot repair that earlier step. +Runtime retention excludes updater-owned package backups in the global module +directory, including backup symlinks to source checkouts. An older installed +updater such as `2026.9.6` can still follow a retained +`.openclaw.package-backup-*` link and refuse an update with a host-owned plugin-link +error. Preserve that historical link outside the global module directory, keeping +its resolved target unchanged, before retrying. Do not delete its source checkout +or move backups belonging to an active or unresolved update. + The installed updater reads the candidate's `package.json` before running its pending lifecycle scripts. `openclaw.updateAdmissionProtocol: 1` advertises the internal admission command. Reading this marker does not execute candidate code. @@ -379,6 +387,12 @@ Cleanup checks this budget between filesystem operations and waits for operation already in flight to settle, so stalled storage can extend the cleanup wait. Ownership and path-identity failures remain distinct from cleanup expiry. +A later verified package activation also retires historical package backups +captured before that update began. Symlink retirement removes only the link; +source checkouts remain untouched. Failed updates and rollbacks preserve those +historical backups, and separately retained database snapshots keep their own +recovery lifetime. + Post-plugin config validation and readiness checks use the measured shared and agent database sizes after Doctor finishes, including WAL files. Post-core plugin installation and update work have no default deadline when `--timeout` is omitted; diff --git a/src/infra/package-update-backup-paths.ts b/src/infra/package-update-backup-paths.ts new file mode 100644 index 000000000000..3fdf1748d589 --- /dev/null +++ b/src/infra/package-update-backup-paths.ts @@ -0,0 +1,9 @@ +import { isPackageActivationControlName } from "./package-update-activation-paths.js"; + +export function isLegacyPackageBackupName(name: string): boolean { + return /^\.openclaw[.-]package-backup-\d+-\d+$/u.test(name); +} + +export function isPackageUpdateRecoveryArtifactName(name: string): boolean { + return /^\.openclaw[.-]package-backup-/u.test(name) || isPackageActivationControlName(name); +} diff --git a/src/infra/package-update-filesystem.ts b/src/infra/package-update-filesystem.ts index 97499af0dafb..83c51e39d9de 100644 --- a/src/infra/package-update-filesystem.ts +++ b/src/infra/package-update-filesystem.ts @@ -21,7 +21,10 @@ import { UPDATE_CLEANUP_BUDGET_MS } from "./update-maintenance.js"; export const PACKAGE_MANAGER_SWAP_SOURCE_HARDLINKS = "allow" as const; const log = createSubsystemLogger("update/package-launchers"); -function assertPackagePathIdentity(filePath: string, expected: BigIntStats | undefined): void { +export function assertPackagePathIdentity( + filePath: string, + expected: BigIntStats | undefined, +): void { let current: BigIntStats | undefined; try { current = fsSync.lstatSync(filePath, { bigint: true, throwIfNoEntry: false }); diff --git a/src/infra/package-update-swap-retirement.ts b/src/infra/package-update-swap-retirement.ts index 8cac8b3a7e30..70071fe60a96 100644 --- a/src/infra/package-update-swap-retirement.ts +++ b/src/infra/package-update-swap-retirement.ts @@ -1,5 +1,11 @@ +import fsSync, { type BigIntStats } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { formatErrorMessage } from "./errors.js"; import { retainMutationAuthority } from "./mutation-authority.js"; +import { isLegacyPackageBackupName } from "./package-update-backup-paths.js"; import { + assertPackagePathIdentity, discardPackageUpdateBackup, discardPackageLauncherBackup, type PackageLauncherBackup, @@ -7,9 +13,118 @@ import { import type { PackageRootIntegrityFingerprint } from "./package-update-integrity.js"; import type { createNpmPackageRootLinkLifecycle } from "./package-update-npm-root.js"; import { PackageUpdateActivationError } from "./package-update-swap-contract.js"; +import { + createFreeBsdPkgOwnershipInspection, + FreeBsdPkgOwnershipError, + PKG_INSPECTION_TIMEOUT_MS, +} from "./update-freebsd-pkg-ownership.js"; import { UPDATE_CLEANUP_BUDGET_MS } from "./update-maintenance.js"; import type { UpdateStepResult } from "./update-step-result.js"; +type RetireLegacyPackageBackups = ( + assertCurrent: () => void, + cleanupDeadlineAtMs: number, +) => Promise; + +/** Later verified activation may retire only the historical objects observed before this swap. */ +export async function captureLegacyPackageBackupRetirement( + globalRoot: string, + assertCaller = () => {}, +): Promise { + const assertCurrent = retainMutationAuthority(assertCaller); + const warnings: string[] = []; + let captured: + | { root: string; parent: BigIntStats; entries: Array<{ path: string; identity: BigIntStats }> } + | undefined; + try { + assertCurrent(); + const root = fsSync.realpathSync(globalRoot); + const parent = fsSync.lstatSync(root, { bigint: true }); + const names = await fs.readdir(root); + assertCurrent(); + assertPackagePathIdentity(root, parent); + const entries = names.filter(isLegacyPackageBackupName).flatMap((name) => { + const entry = path.join(root, name); + const identity = fsSync.lstatSync(entry, { bigint: true, throwIfNoEntry: false }); + return identity && (identity.isDirectory() || identity.isSymbolicLink()) + ? [{ path: entry, identity }] + : []; + }); + captured = { root, parent, entries }; + } catch (error) { + assertCurrent(); + warnings.push( + `Historical package backups were not inspected in ${globalRoot}; retained for later cleanup: ${formatErrorMessage(error)}`, + ); + } + return async (assertRetirementOwner: () => void, cleanupDeadlineAtMs: number) => { + const assertOwner = retainMutationAuthority(assertRetirementOwner); + assertOwner(); + const messages = [...warnings]; + if (!captured) { + return messages; + } + const { root, parent, entries } = captured; + const inspectionDeadlineAtMs = Math.min( + cleanupDeadlineAtMs, + performance.now() + PKG_INSPECTION_TIMEOUT_MS, + ); + for (const entry of entries) { + try { + assertOwner(); + if (process.platform === "freebsd") { + const remainingMs = Math.floor(inspectionDeadlineAtMs - performance.now()); + if (remainingMs <= 0) { + messages.push( + `Historical package backups retained in ${root}: FreeBSD pkg inspection budget expired`, + ); + break; + } + const inspection = createFreeBsdPkgOwnershipInspection(remainingMs); + if (entry.identity.isSymbolicLink()) { + await inspection.assertEntryUnowned(entry.path); + } else { + await inspection.assertUnowned(entry.path); + } + } + const message = await discardPackageUpdateBackup( + entry.path, + "historical package backup", + root, + () => { + assertOwner(); + if (fsSync.realpathSync(globalRoot) !== root) { + throw new Error("Global package directory changed before historical backup cleanup"); + } + assertPackagePathIdentity(root, parent); + // The owned unlink may have finished; a replacement must never be adopted. + if (fsSync.lstatSync(entry.path, { throwIfNoEntry: false })) { + assertPackagePathIdentity(entry.path, entry.identity); + } + }, + cleanupDeadlineAtMs, + ); + if (message) { + messages.push(message); + } + } catch (error) { + assertOwner(); + messages.push( + `Historical package backup retained at ${entry.path}: ${formatErrorMessage(error)}`, + ); + if ( + error instanceof FreeBsdPkgOwnershipError && + error.reason === "pkg-ownership-unavailable" + ) { + break; + } + } + } + assertOwner(); + return messages; + }; +} + /** Refusal occurred before transaction handoff or any live package mutation. */ export async function retireRefusedPackageSwap( activation: { disarmRollback: () => Promise; retire: () => Promise }, @@ -39,6 +154,7 @@ export async function retireVerifiedPackageSwap(params: { previousRoot: PackageRootIntegrityFingerprint | undefined; backupRoot: string; databaseBackupRoot: string | undefined; + retireLegacyBackups?: RetireLegacyPackageBackups; launchers: PackageLauncherBackup; packageBackedUp: boolean; globalRoot: string; @@ -113,6 +229,11 @@ export async function retireVerifiedPackageSwap(params: { messages.push(message); } } + if (params.retireLegacyBackups) { + messages.push( + ...(await params.retireLegacyBackups(assertRetirementCurrent, cleanupDeadlineAtMs)), + ); + } // Capture authority loss during the final filesystem await in the // retirement outcome, not only in the caller's later publication check. assertRetirementCurrent(); diff --git a/src/infra/package-update-swap-target.ts b/src/infra/package-update-swap-target.ts index 0286bc3fb54f..007a50b95bb5 100644 --- a/src/infra/package-update-swap-target.ts +++ b/src/infra/package-update-swap-target.ts @@ -1,6 +1,21 @@ import path from "node:path"; import type { StagedPackageSwapParams } from "./package-update-swap-contract.js"; +import { createFreeBsdPkgOwnershipInspection } from "./update-freebsd-pkg-ownership.js"; import { resolveNpmGlobalPrefixLayoutFromGlobalRoot } from "./update-npm-prefix.js"; +import { UPDATE_RUNNER_TIMEOUT_MS } from "./update-run-timeouts.js"; + +export async function assertSwapTargetUnowned( + root: string, + launchers: readonly { destination: string }[], + timeoutMs?: number, +): Promise { + // A fresh observation, not an atomic lock against an external pkg writer. + const inspection = createFreeBsdPkgOwnershipInspection(timeoutMs ?? UPDATE_RUNNER_TIMEOUT_MS); + await inspection.assertUnowned(root); + for (const launcher of launchers) { + await inspection.assertEntryUnowned(launcher.destination); + } +} export function resolveStagedPackageSwapTarget(params: StagedPackageSwapParams) { const native = params.stage.native; diff --git a/src/infra/package-update-swap.freebsd.test.ts b/src/infra/package-update-swap.freebsd.test.ts index 3fe512e8b5cd..f6256eb7177c 100644 --- a/src/infra/package-update-swap.freebsd.test.ts +++ b/src/infra/package-update-swap.freebsd.test.ts @@ -11,12 +11,95 @@ import { writePackageRoot, } from "./package-update-steps.test-support.js"; import { swapStagedPackageInstall, type PackageUpdateTransaction } from "./package-update-swap.js"; -import { createPackageSwapFixture } from "./package-update-swap.test-support.js"; +import { + createPackageSwapFixture, + createRetainedPackageSwap, +} from "./package-update-swap.test-support.js"; import { pkgQueryResult } from "./update-freebsd-pkg-ownership.test-support.js"; afterEach(() => vi.restoreAllMocks()); describe("FreeBSD package replacement ownership", () => { + it.each([ + { kind: "directory", ownership: "artifact" }, + { kind: "symlink", ownership: "artifact" }, + { kind: "symlink", ownership: "target" }, + { kind: "directory", ownership: "unavailable" }, + ] as const)( + "preserves pkg ownership when retiring a historical $kind ($ownership)", + async ({ kind, ownership }) => { + const linkType = process.platform === "win32" ? "junction" : "dir"; + await withTestDir({ prefix: "openclaw-pkg-historical-backup-" }, async (base) => { + const checkout = path.join(base, "checkout"); + await fs.mkdir(checkout); + await fs.writeFile(path.join(checkout, "sentinel"), "operator checkout"); + const query = vi.spyOn(exec, "runCommandBuffered").mockResolvedValue(pkgQueryResult()); + await withMockedPlatform("freebsd", async () => { + const { transaction, globalRoot, packageRoot } = await createRetainedPackageSwap( + base, + async ({ globalRoot: fixtureGlobalRoot }) => { + const historical = path.join(fixtureGlobalRoot, ".openclaw.package-backup-1-100"); + if (kind === "symlink") { + await fs.symlink(checkout, historical, linkType); + } else { + await fs.mkdir(historical); + await fs.writeFile(path.join(historical, "sentinel"), "historical package"); + } + const later = path.join(fixtureGlobalRoot, ".openclaw.package-backup-2-200"); + await fs.mkdir(later); + await fs.writeFile(path.join(later, "sentinel"), "second historical package"); + }, + ); + const historical = path.join(globalRoot, ".openclaw.package-backup-1-100"); + const later = path.join(globalRoot, ".openclaw.package-backup-2-200"); + const registered = + ownership === "target" + ? path.join(checkout, "sentinel") + : kind === "symlink" + ? historical + : path.join(historical, "sentinel"); + // Package ownership can change after capture and successful activation. + query + .mockClear() + .mockResolvedValue( + ownership === "unavailable" + ? pkgQueryResult("", { code: 1 }) + : pkgQueryResult(`${registered}\n`), + ); + + const completion = await transaction.complete({ activationVerified: true }, () => {}); + + if (ownership === "target") { + expect(completion).toBeUndefined(); + await expect(fs.lstat(historical)).rejects.toMatchObject({ code: "ENOENT" }); + } else { + expect(completion).toMatchObject({ + advisory: { + kind: "recoverable-maintenance", + message: expect.stringContaining("FreeBSD pkg"), + }, + }); + await expect(fs.lstat(historical)).resolves.toBeDefined(); + } + if (ownership === "unavailable") { + expect(query).toHaveBeenCalledOnce(); + expect(await fs.readFile(path.join(later, "sentinel"), "utf8")).toBe( + "second historical package", + ); + } else { + await expect(fs.lstat(later)).rejects.toMatchObject({ code: "ENOENT" }); + } + expect(await fs.readFile(path.join(checkout, "sentinel"), "utf8")).toBe( + "operator checkout", + ); + expect(await fs.readFile(path.join(packageRoot, "package.json"), "utf8")).toContain( + '"version":"2.0.0"', + ); + }); + }); + }, + ); + it("retains the installed candidate and recovery copy when pkg claims a launcher before rollback", async () => { await withTestDir({ prefix: "openclaw-pkg-rollback-" }, async (base) => { const { params, packageRoot, launcher } = await createPackageSwapFixture(base); diff --git a/src/infra/package-update-swap.test-support.ts b/src/infra/package-update-swap.test-support.ts index 1da5cd28658b..233aecb0b3d7 100644 --- a/src/infra/package-update-swap.test-support.ts +++ b/src/infra/package-update-swap.test-support.ts @@ -36,8 +36,12 @@ export async function createPackageSwapFixture(base: string) { return { params, packageRoot, globalRoot, launcher }; } -export async function createRetainedPackageSwap(base: string) { +export async function createRetainedPackageSwap( + base: string, + prepare?: (fixture: Awaited>) => Promise, +) { const fixture = await createPackageSwapFixture(base); + await prepare?.(fixture); let transaction: PackageUpdateTransaction | undefined; const result = await swapStagedPackageInstall({ ...fixture.params, diff --git a/src/infra/package-update-swap.test.ts b/src/infra/package-update-swap.test.ts index a7fe1a9379b1..655346813acf 100644 --- a/src/infra/package-update-swap.test.ts +++ b/src/infra/package-update-swap.test.ts @@ -15,6 +15,41 @@ const dirs = useAutoCleanupTempDirTracker(afterEach); afterEach(() => vi.restoreAllMocks()); describe("retained package backup retirement", () => { + it.each(["missing", "verified", "advisory"] as const)( + "preserves historical backups without an activation transaction (%s)", + async (verification) => { + const base = await fs.realpath(dirs.make("openclaw-direct-historical-backup-")); + const { params, globalRoot, packageRoot } = await createPackageSwapFixture(base); + const historical = path.join(globalRoot, ".openclaw.package-backup-1-100"); + await fs.mkdir(historical); + await fs.writeFile(path.join(historical, "sentinel"), "historical package"); + const result = await swapStagedPackageInstall({ + ...params, + postVerifyStep: + verification === "missing" + ? undefined + : async (root) => ({ + name: "package-verify", + command: "verify fixture", + cwd: root, + durationMs: 0, + exitCode: verification === "verified" ? 0 : 1, + advisory: + verification === "advisory" + ? { kind: "recoverable-maintenance", message: "verification incomplete" } + : undefined, + }), + }); + expect(result.status, result.step.stderrTail ?? "").toBe("committed"); + expect(await fs.readFile(path.join(packageRoot, "package.json"), "utf8")).toContain( + '"version":"2.0.0"', + ); + expect(await fs.readFile(path.join(historical, "sentinel"), "utf8")).toBe( + "historical package", + ); + }, + ); + it("keeps launcher evidence with a published transaction when mutation admission throws", async () => { await withTestDir({ prefix: "openclaw-retained-admission-" }, async (base) => { const { params, packageRoot, globalRoot, launcher } = await createPackageSwapFixture(base); @@ -99,14 +134,31 @@ describe("retained package backup retirement", () => { "refused rollback", ] as const)("retires backups only after a proven outcome: %s", async (outcome) => { await withTestDir({ prefix: "openclaw-retained-outcome-" }, async (base) => { - const { result, transaction, packageRoot, globalRoot } = - await createRetainedPackageSwap(base); + const checkout = path.join(base, "earlier-checkout"); + await fs.mkdir(checkout); + await fs.writeFile(path.join(checkout, "sentinel"), "operator checkout"); + const { result, transaction, packageRoot, globalRoot } = await createRetainedPackageSwap( + base, + async ({ globalRoot: fixtureGlobalRoot }) => { + const directory = path.join(fixtureGlobalRoot, ".openclaw.package-backup-1-100"); + await fs.mkdir(directory); + await fs.writeFile(path.join(directory, "sentinel"), "earlier package"); + for (const name of [".openclaw.package-backup-2-200", ".openclaw-package-backup-3-300"]) { + await fs.symlink( + checkout, + path.join(fixtureGlobalRoot, name), + process.platform === "win32" ? "junction" : "dir", + ); + } + const snapshots = `${directory}.databases`; + await fs.mkdir(snapshots); + await fs.writeFile(path.join(snapshots, "snapshot.sqlite"), "pre-migration bytes"); + }, + ); const snapshots = `${transaction.backupRoot}.databases`; - const olderSnapshots = path.join(globalRoot, ".openclaw.package-backup-older.databases"); - for (const directory of [snapshots, olderSnapshots]) { - await fs.mkdir(directory); - await fs.writeFile(path.join(directory, "snapshot.sqlite"), "pre-migration bytes"); - } + const olderSnapshots = path.join(globalRoot, ".openclaw.package-backup-1-100.databases"); + await fs.mkdir(snapshots); + await fs.writeFile(path.join(snapshots, "snapshot.sqlite"), "pre-migration bytes"); expect(result.status).toBe("committed"); if (outcome === "refused rollback") { await fs.writeFile(path.join(transaction.backupRoot, "dist", "index.js"), "changed"); @@ -144,9 +196,93 @@ describe("retained package backup retirement", () => { await expect(fs.readFile(path.join(olderSnapshots, "snapshot.sqlite"), "utf8")).resolves.toBe( "pre-migration bytes", ); + for (const name of [ + ".openclaw.package-backup-1-100", + ".openclaw.package-backup-2-200", + ".openclaw-package-backup-3-300", + ]) { + if (outcome === "verified activation") { + await expect(fs.lstat(path.join(globalRoot, name))).rejects.toMatchObject({ + code: "ENOENT", + }); + } else { + await expect(fs.lstat(path.join(globalRoot, name))).resolves.toBeDefined(); + } + } + await expect(fs.readFile(path.join(checkout, "sentinel"), "utf8")).resolves.toBe( + "operator checkout", + ); }); }); + it("preserves replacement, later-created, and unrelated recovery artifacts after activation", async () => { + const base = await fs.realpath(dirs.make("openclaw-historical-backup-replaced-")); + const { transaction, globalRoot } = await createRetainedPackageSwap( + base, + async ({ globalRoot: fixtureGlobalRoot }) => { + await fs.mkdir(path.join(fixtureGlobalRoot, ".openclaw.package-backup-1-100")); + for (const name of [ + ".openclaw.package-backup-1-100.candidate", + ".openclaw.package-backup-manual", + ]) { + await fs.mkdir(path.join(fixtureGlobalRoot, name)); + await fs.writeFile(path.join(fixtureGlobalRoot, name, "sentinel"), "recovery bytes"); + } + }, + ); + const replaced = path.join(globalRoot, ".openclaw.package-backup-1-100"); + await fs.rename(replaced, path.join(base, "captured-backup")); + const later = path.join(globalRoot, ".openclaw.package-backup-2-200"); + for (const directory of [replaced, later]) { + await fs.mkdir(directory); + await fs.writeFile(path.join(directory, "sentinel"), "successor bytes"); + } + + const completion = await transaction.complete({ activationVerified: true }, () => {}); + expect(completion).toMatchObject({ + advisory: { kind: "recoverable-maintenance", message: expect.stringContaining(replaced) }, + }); + for (const directory of [replaced, later]) { + expect(await fs.readFile(path.join(directory, "sentinel"), "utf8")).toBe("successor bytes"); + } + for (const name of [ + ".openclaw.package-backup-1-100.candidate", + ".openclaw.package-backup-manual", + ]) { + expect(await fs.readFile(path.join(globalRoot, name, "sentinel"), "utf8")).toBe( + "recovery bytes", + ); + } + expect(await transaction.complete({ activationVerified: true }, () => {})).toBe(completion); + }); + + it("finishes activation with a warning when historical backup inspection fails", async () => { + const base = await fs.realpath(dirs.make("openclaw-historical-backup-inspection-")); + const readdir = fs.readdir.bind(fs); + const { transaction, globalRoot } = await createRetainedPackageSwap( + base, + async ({ globalRoot: fixtureGlobalRoot }) => { + await fs.mkdir(path.join(fixtureGlobalRoot, ".openclaw.package-backup-1-100")); + vi.spyOn(fs, "readdir").mockImplementation(async (...args) => { + if (String(args[0]) === fixtureGlobalRoot) { + throw Object.assign(new Error("backup inspection denied"), { code: "EACCES" }); + } + return readdir(...args); + }); + }, + ); + const completion = await transaction.complete({ activationVerified: true }, () => {}); + expect(completion).toMatchObject({ + advisory: { + kind: "recoverable-maintenance", + message: expect.stringContaining("backup inspection denied"), + }, + }); + await expect( + fs.lstat(path.join(globalRoot, ".openclaw.package-backup-1-100")), + ).resolves.toBeDefined(); + }); + it("reports database cleanup failure as recoverable maintenance after verified activation", async () => { const base = await fs.realpath(dirs.make("openclaw-database-retirement-")); const { transaction } = await createRetainedPackageSwap(base); diff --git a/src/infra/package-update-swap.ts b/src/infra/package-update-swap.ts index db7249af14ee..4863fbf9adb7 100644 --- a/src/infra/package-update-swap.ts +++ b/src/infra/package-update-swap.ts @@ -43,22 +43,22 @@ import { } from "./package-update-swap-contract.js"; import { createPackageSwapResults } from "./package-update-swap-results.js"; import { + captureLegacyPackageBackupRetirement, retireRefusedPackageSwap, retireVerifiedPackageSwap, } from "./package-update-swap-retirement.js"; -import { resolveStagedPackageSwapTarget } from "./package-update-swap-target.js"; -import { runPackagePostInstallVerification } from "./package-update-verification-step.js"; import { - createFreeBsdPkgOwnershipInspection, - FreeBsdPkgOwnershipError, -} from "./update-freebsd-pkg-ownership.js"; + assertSwapTargetUnowned, + resolveStagedPackageSwapTarget, +} from "./package-update-swap-target.js"; +import { runPackagePostInstallVerification } from "./package-update-verification-step.js"; +import { FreeBsdPkgOwnershipError } from "./update-freebsd-pkg-ownership.js"; import { verifyPackageUpdateRecovery } from "./update-global.js"; import { finalizeNativePackageStage, NativePackageRollbackError, } from "./update-native-package-stage.js"; import { isFailedUpdateStep } from "./update-run-step.js"; -import { UPDATE_RUNNER_TIMEOUT_MS } from "./update-run-timeouts.js"; import type { UpdateStepResult } from "./update-step-result.js"; export { PackageUpdateActivationError } from "./package-update-swap-contract.js"; @@ -116,16 +116,6 @@ export async function swapStagedPackageInstall( let activationRetirementStarted = false; let preparationCustody = false; let activation: Awaited>; - const assertReplacementUnowned = async () => { - // A fresh observation, not an atomic lock against an external pkg writer. - const inspection = createFreeBsdPkgOwnershipInspection( - params.timeoutMs ?? UPDATE_RUNNER_TIMEOUT_MS, - ); - await inspection.assertUnowned(targetSwapRoot); - for (const shim of shims) { - await inspection.assertEntryUnowned(shim.destination); - } - }; const verifyNpmRecovery = (root: string, fromBackup: boolean) => verifyNpmRootRecovery( { root, fromBackup, hadPackage, previousRoot, previousIdentity, targetSwapRoot, shims }, @@ -163,7 +153,7 @@ export async function swapStagedPackageInstall( } if (process.platform === "freebsd" && (packageBackedUp || rollback.length > 0)) { try { - await assertReplacementUnowned(); + await assertSwapTargetUnowned(targetSwapRoot, shims, params.timeoutMs); assertCurrent(); } catch (error) { assertCurrent(); @@ -348,6 +338,12 @@ export async function swapStagedPackageInstall( await launcherReader.observe("baseline", () => capturePackageLaunchers(launchers, params, targetLayout, launcherReader), ); + const retireLegacyBackups = params.onTransaction + ? await captureLegacyPackageBackupRetirement( + targetLayout.globalRoot, + params.assertCurrent ?? params.activation?.fence.assertCurrent, + ) + : undefined; if ( params.activation && process.platform !== "freebsd" && @@ -411,7 +407,7 @@ export async function swapStagedPackageInstall( ? await finalizeNativePackageStage(native, params.packageName) : undefined; if (process.platform === "freebsd") { - await assertReplacementUnowned(); + await assertSwapTargetUnowned(targetSwapRoot, shims, params.timeoutMs); } try { await params.beforeActivate?.(); @@ -425,7 +421,7 @@ export async function swapStagedPackageInstall( if (process.platform === "freebsd") { // Draining and project validation may outlive package ownership. Refuse // before registering a transaction or replacing any live entry. - await assertReplacementUnowned(); + await assertSwapTargetUnowned(targetSwapRoot, shims, params.timeoutMs); params.assertCurrent?.(); } if (params.onTransaction) { @@ -535,6 +531,7 @@ export async function swapStagedPackageInstall( backupRoot, // Rollback snapshots remain recovery evidence even after successful restoration. databaseBackupRoot: rollbackResult ? undefined : databaseBackupRoot, + retireLegacyBackups: rollbackResult ? undefined : retireLegacyBackups, launchers, packageBackedUp, globalRoot: targetLayout.globalRoot, diff --git a/src/infra/update-candidate-plugin-tree.ts b/src/infra/update-candidate-plugin-tree.ts index f85235443129..71343b6dddd2 100644 --- a/src/infra/update-candidate-plugin-tree.ts +++ b/src/infra/update-candidate-plugin-tree.ts @@ -7,7 +7,7 @@ import { resolvePathViaExistingAncestorSync } from "./boundary-path.js"; import { root as openRoot } from "./fs-safe.js"; import { tryReadJson } from "./json-files.js"; import { parseRegistryNpmSpec } from "./npm-registry-spec.js"; -import { isPackageActivationControlName } from "./package-update-activation-paths.js"; +import { isPackageUpdateRecoveryArtifactName } from "./package-update-backup-paths.js"; import { hasNodeErrorCode, isPathInside } from "./path-guards.js"; import type { UpdateCandidatePluginCodeLink } from "./update-candidate-plugin-code-links.js"; import { @@ -112,11 +112,11 @@ export async function prepareUpdateCandidatePluginTrees(params: { const stores = new Set(); const moduleAliases = new Map(); const moduleOwners = new Set(); - const isRecoveryControl = (file: string) => { + const isRecoveryArtifact = (file: string) => { for (let current = file; path.dirname(current) !== current; current = path.dirname(current)) { if ( moduleOwners.has(path.dirname(current)) && - isPackageActivationControlName(path.basename(current)) + isPackageUpdateRecoveryArtifactName(path.basename(current)) ) { return true; } @@ -325,8 +325,8 @@ export async function prepareUpdateCandidatePluginTrees(params: { } for (const entry of entries) { const file = path.join(directory, entry.name); - if (isRecoveryControl(file)) { - // Installation control state is not a dependency of the retained code. + if (isRecoveryArtifact(file)) { + // Recovery controls and retained backups are not runtime dependencies. continue; } else if (isOwnedHostEdge(file)) { // The complete-wave owner pass records the authoritative host identity. @@ -435,11 +435,11 @@ export async function prepareUpdateCandidatePluginTrees(params: { const stagingLookup = lookupRoots(staging); let added = false; for (const [file, { real, target }] of edges) { - if (isRecoveryControl(file)) { + if (isRecoveryArtifact(file)) { edges.delete(file); continue; } - if (isRecoveryControl(real) || isRecoveryControl(target)) { + if (isRecoveryArtifact(real) || isRecoveryArtifact(target)) { throw new Error("Package recovery state cannot be a runtime dependency."); } const directory = stagingLookup(real); @@ -525,7 +525,7 @@ export async function prepareUpdateCandidatePluginTrees(params: { ); const entries = [...footprints.values()].filter((entry) => { if ( - isRecoveryControl(entry.path) || + isRecoveryArtifact(entry.path) || insideHost(entry.path) || copyOwner(entry.path) === undefined ) { diff --git a/src/infra/update-retained-runtime.backups.test.ts b/src/infra/update-retained-runtime.backups.test.ts new file mode 100644 index 000000000000..7a416614a8cc --- /dev/null +++ b/src/infra/update-retained-runtime.backups.test.ts @@ -0,0 +1,113 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } 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 { captureRuntimeWorkerSource } from "./runtime-worker-generation.js"; +import { withRetainedUpdateRuntime } from "./update-retained-runtime.js"; + +const dirs = useAutoCleanupTempDirTracker(afterEach); + +it.each(["symlink", "directory"] as const)( + "updates the npm package without traversing a historical package backup %s", + async (kind) => { + const base = await fs.realpath(dirs.make("retained-package-backup-")); + const { params, globalRoot, packageRoot: installed } = await createPackageSwapFixture(base); + const dependency = path.join(globalRoot, "fixture"); + const backupName = ".openclaw.package-backup-123-1700000000000"; + const backup = path.join(globalRoot, backupName); + const checkout = kind === "symlink" ? path.join(base, "checkout") : backup; + for (const directory of [ + path.join(installed, "dist"), + dependency, + path.join(checkout, ".claude"), + path.join(checkout, ".agents/skills"), + path.join(checkout, "node_modules"), + ]) { + await fs.mkdir(directory, { recursive: true }); + } + await fs.writeFile( + path.join(installed, "package.json"), + JSON.stringify({ + name: "openclaw", + version: "1.0.0", + type: "module", + dependencies: { fixture: "1.0.0" }, + }), + ); + await fs.writeFile( + path.join(installed, "dist/updater.mjs"), + 'export { value } from "fixture";', + ); + await fs.writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "fixture", type: "module", exports: "./index.js" }), + ); + await fs.writeFile(path.join(dependency, "index.js"), 'export const value = "hoisted";'); + await fs.writeFile(path.join(checkout, "package.json"), '{"name":"openclaw"}'); + const sentinel = path.join(checkout, ".agents/skills/keep.txt"); + await fs.writeFile(sentinel, "operator checkout"); + await fs.symlink("../.agents/skills", path.join(checkout, ".claude/skills"), "dir"); + await fs.symlink(checkout, path.join(checkout, "node_modules/openclaw"), "junction"); + if (kind === "symlink") { + await fs.symlink(checkout, backup, "junction"); + } + await fs.symlink( + checkout, + path.join(globalRoot, ".openclaw-package-backup-124-1700000000000"), + "junction", + ); + await fs.mkdir(`${backup}.databases`); + await fs.writeFile(path.join(`${backup}.databases`, "snapshot.sqlite"), "recovery data"); + const asset = path.join("dist", backupName, "asset.txt"); + await fs.mkdir(path.dirname(path.join(installed, asset)), { recursive: true }); + await fs.writeFile(path.join(installed, asset), "runtime asset outside module owner"); + const original = await fs.lstat(backup, { bigint: true }); + const moduleUrl = pathToFileURL(path.join(installed, "dist/updater.mjs")); + await withRetainedUpdateRuntime(moduleUrl.href, async (retain) => { + await retain({ + mutationRoots: [installed], + installTarget: { manager: "npm", command: "npm", globalRoot, packageRoot: installed }, + timeoutMs: 30_000, + assertCurrent() {}, + }); + const retainedUrl = captureRuntimeWorkerSource(moduleUrl).moduleUrl; + expect(retainedUrl.href).not.toBe(moduleUrl.href); + const retainedModules = path.resolve(path.dirname(fileURLToPath(retainedUrl)), "../.."); + expect((await fs.readdir(retainedModules)).toSorted()).toEqual(["fixture", "openclaw"]); + expect((await import(retainedUrl.href)).value).toBe("hoisted"); + expect(await fs.readFile(path.join(retainedModules, "openclaw", asset), "utf8")).toBe( + "runtime asset outside module owner", + ); + expect(await fs.readFile(sentinel, "utf8")).toBe("operator checkout"); + expect(await fs.lstat(backup, { bigint: true })).toMatchObject({ + dev: original.dev, + ino: original.ino, + }); + let transaction: PackageUpdateTransaction | undefined; + const result = await swapStagedPackageInstall({ + ...params, + onTransaction: (value) => { + transaction = value; + }, + }); + expect(result.status, result.step.stderrTail ?? "").toBe("committed"); + expect(await transaction!.complete({ activationVerified: true }, () => {})).toBeUndefined(); + expect( + JSON.parse(await fs.readFile(path.join(installed, "package.json"), "utf8")), + ).toMatchObject({ + version: "2.0.0", + }); + }); + if (kind === "symlink") { + expect(await fs.readFile(sentinel, "utf8")).toBe("operator checkout"); + expect(await fs.readlink(path.join(checkout, ".claude/skills"))).toBe("../.agents/skills"); + } + expect(await fs.readFile(path.join(`${backup}.databases`, "snapshot.sqlite"), "utf8")).toBe( + "recovery data", + ); + await expect(fs.lstat(backup)).rejects.toMatchObject({ code: "ENOENT" }); + }, +);