mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-04 02:00:10 +00:00
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:
parent
1b9d7e70ae
commit
9a62cc8e64
11 changed files with 532 additions and 37 deletions
|
|
@ -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;
|
||||
|
|
|
|||
9
src/infra/package-update-backup-paths.ts
Normal file
9
src/infra/package-update-backup-paths.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
113
src/infra/update-retained-runtime.backups.test.ts
Normal file
113
src/infra/update-retained-runtime.backups.test.ts
Normal 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" });
|
||||
},
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue