fix(update): exclude and retire historical package backups (#162246)

A retained .openclaw.package-backup-<pid>-<timestamp> symlink in the global node_modules (left by an earlier source-to-package update) made the updater inventory walk into an unrelated checkout and refuse the update with the host-owned plugin-link error. The updater now excludes its own recovery artifacts (package backups and activation control files) from inventory through one producer-owned naming predicate, and retires historical backups once a later update has completed, never the current run's backup. The already-installed 2026.9.6 driver still refuses on that first hop; the issue carries the recovery note.

Refs #161922
This commit is contained in:
Peter Steinberger 2026-09-30 19:31:46 -07:00 • committed by GitHub
parent 1b9d7e70ae
commit 9a62cc8e64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 532 additions and 37 deletions

View file

@ -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;

View file

@ -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);
}

View file

@ -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 });

View file

@ -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<string[]>;
/** Later verified activation may retire only the historical objects observed before this swap. */
export async function captureLegacyPackageBackupRetirement(
globalRoot: string,
assertCaller = () => {},
): Promise<RetireLegacyPackageBackups> {
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<boolean>; retire: () => Promise<unknown> },
@ -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();

View file

@ -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<void> {
// 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;

View file

@ -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);

View file

@ -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<ReturnType<typeof createPackageSwapFixture>>) => Promise<void>,
) {
const fixture = await createPackageSwapFixture(base);
await prepare?.(fixture);
let transaction: PackageUpdateTransaction | undefined;
const result = await swapStagedPackageInstall({
...fixture.params,

View file

@ -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);

View file

@ -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<ReturnType<typeof preparePackageActivation>>;
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,

View file

@ -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<string>();
const moduleAliases = new Map<string, string>();
const moduleOwners = new Set<string>();
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
) {

View file

@ -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" });
},
);