fix(update): accept legacy numeric lease identities once, then pin exact bigint identities (#162245)

A Windows 2026.9.6 -> 2026.9.7 update passed every candidate check, published 9.7, then rolled back because the first delegated live Doctor reported "Candidate executor binding does not match its parent": #158491 switched the managed-update lease-directory identity to bigint lstat while keeping an exact string compare, so a 9.6 parent's numeric (precision-lost) identity never matched, and the identity read error was swallowed into an unreadable-lease classification. The shared identity owner now accepts the legacy numeric spelling once at initial admission when it equals the candidate's bigint identity rounded through the same serialization (an accepted one-hop tradeoff recorded at the comparer), pins exact bigint identities for every subsequent operation, and surfaces underlying read errors.

Closes #162130
This commit is contained in:
Peter Steinberger 2026-10-01 00:48:53 -07:00 • committed by GitHub
parent 10439b067b
commit f885caa955
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 288 additions and 48 deletions

View file

@ -103,6 +103,12 @@ without the capability marker fall back to installed checks, as do
Admission selection is CLI-only: `--admission auto` is the default, and there is
no environment-variable override.
Windows candidates accept lease identities from the `2026.9.6` updater even
when NTFS file IDs exceed JavaScript's exact numeric range. After validating
the handoff, the candidate retains exact file and parent-directory identities;
later replacement still stops the update. Lease read failures report their
underlying cause instead of a parent-binding mismatch.
Managed-service inspection is best effort. If the service manager is unavailable,
including Linux hosts without systemd, the update continues and records a warning.
It leaves unverified service definitions unchanged and skips their automatic

View file

@ -1,6 +1,7 @@
import { isDeepStrictEqual } from "node:util";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { resolveServiceManagerEnv } from "../../daemon/service-process-env.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { resolveUpdateInstallRoot } from "../../infra/update-install-root.js";
import { captureManagedUpdateLeaseDatabaseIdentity } from "../../infra/update-managed-service-handoff-database.js";
import { createManagedHandoffLeaseStore } from "../../infra/update-managed-service-handoff-lease.js";
@ -62,7 +63,7 @@ export function resolveUpdateCommandChildBinding(
!grant.databaseIdentity &&
grant.childKey === `${grant.parent.key}/.openclaw-update-child-${childName}` &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(childName);
const databaseIdentity = legacyGrant
let databaseIdentity = legacyGrant
? captureManagedUpdateLeaseDatabaseIdentity(grant.databasePath)
: grant.databaseIdentity;
const databasePath = databaseIdentity?.databasePath ?? grant.databasePath;
@ -88,6 +89,22 @@ export function resolveUpdateCommandChildBinding(
"Candidate executor lineage is missing or invalid.",
);
}
// Lineage authenticates the original bytes before legacy pins are normalized.
// Bound descendants differ from self-owned, bare-UUID legacy bridges; only
// the initial hop from an older original or its bridge can need rounding.
databaseIdentity = captureManagedUpdateLeaseDatabaseIdentity(
databasePath,
databaseIdentity,
(spawner.key === original.key ||
(spawner.key.startsWith(childPrefix) &&
isDeepStrictEqual(spawner.helper, spawner.executor) &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
spawner.key.slice(childPrefix.length),
))) &&
original.action.kind === "update" &&
(original.version === 1 ||
(original.version === 2 && original.action.mutationProtocol === undefined)),
);
const store = createManagedHandoffLeaseStore({
databasePath,
serviceManagerEnv: resolveServiceManagerEnv(),
@ -106,6 +123,14 @@ export function resolveUpdateCommandChildBinding(
const slotChild = slot ? store.read(slot.childKey) : undefined;
const retained = retainedFields ? store.read(grant.retainedParent!.key) : undefined;
const retainedChild = retainedFields ? store.read(grant.retainedChildKey!) : undefined;
for (const read of [parent, originalChild, child, slotChild, retained, retainedChild]) {
if (read?.kind === "unreadable") {
throw new UpdateCommandRecoveryPendingError(
`Candidate executor lease is unreadable: ${formatErrorMessage(read.error)}`,
{ cause: read.error },
);
}
}
if (
(slot &&
(slot.parent.key === original.key ||

View file

@ -393,7 +393,10 @@ it.each(["missing", "unknown-phase", "unknown-version", "relative-source"] as co
rows = rowBytes();
fixture.dead = true;
const mutate = vi.fn(async () => undefined);
expect(store.read(installRoot)).toEqual({ kind: "unreadable" });
expect(store.read(installRoot)).toEqual({
kind: "unreadable",
error: expect.objectContaining({ message: expect.stringContaining("incompatible") }),
});
await expect(withGatewayServiceOperationLock(env, mutate)).rejects.toThrow(/incompatible/);
expect(mutate).not.toHaveBeenCalled();
expect(store.release(lease)).toBe(false);

View file

@ -13,6 +13,10 @@ export type DatabasePathIdentity = DatabaseFileIdentity & Readonly<{ canonicalPa
// The physical host policy stays fixed across every admission in this process.
const useDatabaseBirthtime = process.platform !== "linux";
export function databaseFileIdentityKey(file: Pick<BigIntStats, "dev" | "ino">): string {
return `${file.dev}:${file.ino}`;
}
export function readDatabaseIdentityBirthtime(file: BigIntStats): string {
// Node does not expose Linux STATX_BTIME availability and can substitute ctime.
// Keep the unknown creation-time value stable across ordinary database writes.
@ -54,7 +58,7 @@ export function assertDatabaseFileIdentity(
): void {
if (
!file.isFile() ||
`file:${file.dev}:${file.ino}` !== expected.key ||
`file:${databaseFileIdentityKey(file)}` !== expected.key ||
(expected.birthtime !== undefined && readDatabaseIdentityBirthtime(file) !== expected.birthtime)
) {
throw new Error("SQLite database file identity changed before existing-only open");
@ -77,7 +81,7 @@ function existingIdentity(
throw new Error("SQLite database pathname changed during admission");
}
return {
key: `file:${file.dev}:${file.ino}`,
key: `file:${databaseFileIdentityKey(file)}`,
canonicalPath: normalizeDatabasePath(canonicalPath),
birthtime: readDatabaseIdentityBirthtime(file),
};

View file

@ -1,15 +1,24 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest";
import { mockLargeDirectoryId } from "../../test/helpers/fs-large-directory-id.js";
import { stopChildProcess } from "../../test/helpers/stop-child-process.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import {
childLineageDigest,
type UpdateCommandChildGrant,
} from "../cli/update-cli/update-command-executor-children.js";
import { resolveUpdateCommandChildBinding } from "../cli/update-cli/update-command-executor-grant.js";
import { nativeBoundaryTestEntrypoints } from "./native-boundary-runtime.test-support.js";
import { resolveRuntimeWorkerArgv, resolveRuntimeWorkerUrl } from "./runtime-worker-url.js";
import { createManagedHandoffLeaseDatabase } from "./update-managed-service-handoff-database.js";
import {
captureManagedUpdateLeaseDatabaseIdentity,
createManagedHandoffLeaseDatabase,
} from "./update-managed-service-handoff-database.js";
import { createManagedHandoffLeaseStore } from "./update-managed-service-handoff-lease.js";
const dirs = useAutoCleanupTempDirTracker(afterEach);
@ -298,9 +307,11 @@ describe("managed handoff database publication", () => {
const bytes = fs.readFileSync(databasePath);
const before = fs.statSync(databasePath);
const store = createManagedHandoffLeaseStore({ databasePath, serviceManagerEnv: {} });
expect(store.read(root)).toEqual({
kind: state === "empty-file" || state === "empty-schema" ? "absent" : "unreadable",
});
expect(store.read(root)).toEqual(
state === "empty-file" || state === "empty-schema"
? { kind: "absent" }
: { kind: "unreadable", error: expect.any(Error) },
);
expect(fs.readFileSync(databasePath)).toEqual(bytes);
expect(fs.statSync(databasePath).ino).toBe(before.ino);
expect(fs.readdirSync(root)).toEqual([path.basename(databasePath)]);
@ -461,3 +472,187 @@ describe("managed handoff database publication", () => {
}
});
});
it.each(["9.4 identity-less", "9.6 numeric", "9.6 bridge", "9.7 exact"] as const)(
"admits a %s grant and fences parent replacement",
(version) => {
const identityLess = version === "9.4 identity-less";
const numeric = version === "9.6 numeric" || version === "9.6 bridge";
const directory = root;
const store = createManagedHandoffLeaseStore({ databasePath, serviceManagerEnv: {} });
const executor = store.processIdentity(process.ppid);
const owner = randomUUID();
const runId = randomUUID();
const spawnerKey =
version === "9.6 bridge" ? `${root}/.openclaw-update-child-${randomUUID()}` : root;
const payload = JSON.stringify({
version: 2,
helper: executor,
executor,
action: {
kind: "update",
...(version === "9.7 exact" ? { mutationProtocol: "original-cancellation-v1" } : {}),
},
});
const write = createManagedHandoffLeaseDatabase(databasePath);
write(true, (db) => {
db.prepare(
"INSERT INTO managed_update_handoffs (install_root, owner, payload_json, updated_at) VALUES (?, ?, ?, ?)",
).run(root, owner, payload, 1);
if (spawnerKey !== root) {
db.prepare(
"INSERT INTO managed_update_handoffs (install_root, owner, payload_json, updated_at) VALUES (?, ?, ?, ?)",
).run(spawnerKey, runId, payload, 1);
}
});
const parent = store.read(root);
assert(parent.kind === "current");
const spawner = store.read(spawnerKey);
assert(spawner.kind === "current");
// Model NTFS metadata only; the grant, live rows and candidate admission are real.
const lstat = fs.lstatSync;
const initialInode = identityLess ? 9007199254740992n : 168040561096346671n;
let parentInode = initialInode;
let readError: Error | undefined;
vi.spyOn(fs, "lstatSync").mockImplementation((...args) => {
if (String(args[0]) === directory && readError) {
throw readError;
}
const stat = lstat(...args);
if (stat && String(args[0]) === directory) {
Object.defineProperty(stat, "ino", {
value: typeof stat.ino === "bigint" ? parentInode : Number(parentInode),
});
}
return stat;
});
// Published v2026.9.6 used numeric lstatSync for both transported identities.
const numericIdentity = (pathname: string) => {
const stat = fs.lstatSync(pathname);
return `${stat.dev}:${stat.ino}`;
};
const databaseIdentity = numeric
? {
databasePath,
databaseIdentity: numericIdentity(databasePath),
parentIdentity: numericIdentity(directory),
}
: captureManagedUpdateLeaseDatabaseIdentity(databasePath);
expect(databaseIdentity.parentIdentity).toMatch(
numeric ? /:168040561096346660$/ : new RegExp(`:${initialInode}$`),
);
const childKey = `${spawnerKey}/.openclaw-update-child-${randomUUID()}${identityLess ? "" : `-lineage-${childLineageDigest(parent.lease, spawner.lease, parent.lease, databaseIdentity)}`}`;
const childPayload = identityLess
? JSON.stringify({
version: 2,
helper: executor,
executor: store.processIdentity(process.pid),
action: { kind: "update" },
})
: payload;
const db = new DatabaseSync(databasePath);
try {
db.prepare(
"INSERT INTO managed_update_handoffs (install_root, owner, payload_json, updated_at) VALUES (?, ?, ?, ?)",
).run(childKey, runId, childPayload, 2);
} finally {
db.close();
}
const grant: UpdateCommandChildGrant = {
runId,
root,
databasePath,
parent: parent.lease,
childKey,
...(identityLess
? {}
: {
databaseIdentity,
originalParent: parent.lease,
spawner: spawner.lease,
originalChildKey: childKey,
}),
};
const admitted = resolveUpdateCommandChildBinding(grant, runId, root);
assert(admitted.databaseIdentity);
expect(admitted.databaseIdentity.parentIdentity).toMatch(new RegExp(`^\\d+:${initialInode}$`));
expect(admitted.store.read(root)).toMatchObject({ kind: "current" });
const descendantKey = `${childKey}/.openclaw-update-child-${randomUUID()}-lineage-${childLineageDigest(parent.lease, admitted.child, parent.lease, admitted.databaseIdentity)}`;
const descendants = new DatabaseSync(databasePath);
try {
descendants
.prepare(
"INSERT INTO managed_update_handoffs (install_root, owner, payload_json, updated_at) VALUES (?, ?, ?, ?)",
)
.run(
descendantKey,
runId,
JSON.stringify({
version: 2,
helper: admitted.child.executor,
executor: admitted.child.executor,
action: admitted.child.action,
}),
3,
);
} finally {
descendants.close();
}
const descendantGrant = {
...grant,
originalParent: parent.lease,
spawner: admitted.child,
databaseIdentity: admitted.databaseIdentity,
originalChildKey: descendantKey,
childKey: descendantKey,
};
const resolveDescendant = () => {
// Model the next receiver's parent PID without booting another source runtime.
const descriptor = Object.getOwnPropertyDescriptor(process, "ppid");
assert(descriptor);
Object.defineProperty(process, "ppid", {
configurable: true,
value: admitted.child.executor.pid,
});
try {
return resolveUpdateCommandChildBinding(descendantGrant, runId, root);
} finally {
Object.defineProperty(process, "ppid", descriptor);
}
};
expect(resolveDescendant().child.key).toBe(descendantKey);
parentInode += identityLess ? 1n : -1n;
expect(numericIdentity(directory)).toMatch(
identityLess ? /:9007199254740992$/ : /:168040561096346660$/,
);
expect(() => admitted.store.acquire(root, "replacement", { kind: "update" })).toThrow(
"identity changed",
);
expect(resolveDescendant).toThrow("identity changed");
if (version === "9.7 exact") {
expect(() => resolveUpdateCommandChildBinding(grant, runId, root)).toThrow(
"identity changed",
);
}
parentInode += 4096n;
if (!identityLess) {
expect(() => resolveUpdateCommandChildBinding(grant, runId, root)).toThrow(
"identity changed",
);
}
readError = new Error("lease parent metadata unavailable");
expect(() => resolveUpdateCommandChildBinding(grant, runId, root)).toThrow(readError.message);
readError = undefined;
parentInode = initialInode;
const damaged = new DatabaseSync(databasePath);
try {
damaged.exec("DROP TABLE managed_update_handoffs");
} finally {
damaged.close();
}
expect(() => resolveUpdateCommandChildBinding(grant, runId, root)).toThrow(
/lease is unreadable:.*no such table/i,
);
},
);

View file

@ -1,7 +1,6 @@
import fs, { type BigIntStats, type Stats } from "node:fs";
import path from "node:path";
import type { DatabaseSync as HandoffDatabase } from "node:sqlite";
import { sameFileIdentity } from "@openclaw/fs-safe/advanced";
import { sql } from "kysely";
import { z } from "zod";
import { ensureColumn } from "../state/openclaw-state-db-schema-helpers.js";
@ -26,6 +25,7 @@ import {
runSqliteImmediateTransactionSync,
type SqliteTransactionOptions,
} from "./sqlite-transaction.js";
import { databaseFileIdentityKey } from "./sqlite-worker-identity.js";
import type { ManagedUpdateLeaseDatabaseIdentity } from "./update-managed-service-handoff-identity.js";
import type { ManagedHandoffLease } from "./update-managed-service-handoff-lease-types.js";
import { quarantineManagedHandoffStore } from "./update-managed-service-handoff-store-repair.js";
@ -101,13 +101,13 @@ function initializeLeaseSchema(db: HandoffDatabase): void {
export type { ManagedUpdateLeaseDatabaseIdentity } from "./update-managed-service-handoff-identity.js";
function assertPath(stat: Stats | BigIntStats, kind: "directory" | "file") {
function assertPath(stat: BigIntStats, kind: "directory" | "file") {
if (
stat.isSymbolicLink() ||
!(kind === "directory" ? stat.isDirectory() : stat.isFile()) ||
(kind === "file" && BigInt(stat.nlink) !== 1n) ||
(typeof process.getuid === "function" && BigInt(stat.uid) !== BigInt(process.getuid())) ||
(process.platform !== "win32" && (BigInt(stat.mode) & 0o077n) !== 0n)
(kind === "file" && stat.nlink !== 1n) ||
(typeof process.getuid === "function" && stat.uid !== BigInt(process.getuid())) ||
(process.platform !== "win32" && (stat.mode & 0o077n) !== 0n)
) {
throw new Error("managed handoff lease " + kind + " is unsafe");
}
@ -128,34 +128,32 @@ function assertPath(stat: Stats | BigIntStats, kind: "directory" | "file") {
* thing the directory guarantee would not restore. Ownership, type and link count
* are likewise not ours to repair; all of those still refuse in assertPath.
*/
function repairPrivateFileMode(databasePath: string, stat: Stats): Stats {
function repairPrivateFileMode(databasePath: string, stat: BigIntStats): BigIntStats {
if (
process.platform === "win32" ||
(stat.mode & 0o077) === 0 ||
(stat.mode & 0o022) !== 0 ||
(stat.mode & 0o077n) === 0n ||
(stat.mode & 0o022n) !== 0n ||
stat.isSymbolicLink() ||
!stat.isFile() ||
stat.nlink !== 1 ||
(typeof process.getuid === "function" && stat.uid !== process.getuid())
stat.nlink !== 1n ||
(typeof process.getuid === "function" && stat.uid !== BigInt(process.getuid()))
) {
return stat;
}
fs.chmodSync(databasePath, 0o600);
return fs.lstatSync(databasePath);
return fs.lstatSync(databasePath, { bigint: true });
}
function assertSamePath(
stat: Stats | BigIntStats,
expected: Stats | BigIntStats,
stat: BigIntStats,
expected: BigIntStats,
kind: "directory" | "file",
): void {
assertPath(stat, kind);
if (
(process.platform === "win32" &&
[stat.dev, stat.ino, expected.dev, expected.ino].some(
(value) => value === 0 || value === 0n,
)) ||
!sameFileIdentity(stat, expected)
[stat.dev, stat.ino, expected.dev, expected.ino].includes(0n)) ||
databaseFileIdentityKey(stat) !== databaseFileIdentityKey(expected)
) {
throw new Error("managed handoff lease " + kind + " changed during initialization");
}
@ -198,12 +196,9 @@ function createMissingDatabaseFile(
// Windows file IDs can exceed Number's exact integer range.
const identity =
descriptor === undefined
? repairPrivateFileMode(databasePath, fs.lstatSync(databasePath))
? repairPrivateFileMode(databasePath, fs.lstatSync(databasePath, { bigint: true }))
: fs.fstatSync(descriptor, { bigint: true });
const currentIdentity =
descriptor === undefined
? fs.lstatSync(databasePath)
: fs.lstatSync(databasePath, { bigint: true });
const currentIdentity = fs.lstatSync(databasePath, { bigint: true });
assertSamePath(currentIdentity, identity, "file");
assertSamePath(
fs.lstatSync(parentReceipt.path, { bigint: true }),
@ -239,30 +234,38 @@ function isUnadoptableStore(stat: Stats): boolean {
/** Capture only an already-admitted database, never provision one during recovery. */
export function captureManagedUpdateLeaseDatabaseIdentity(
databasePath: string,
previous?: ManagedUpdateLeaseDatabaseIdentity,
legacyNumeric = false,
): ManagedUpdateLeaseDatabaseIdentity {
const canonical = fs.realpathSync(databasePath);
const file = fs.lstatSync(canonical, { bigint: true });
const parent = fs.lstatSync(path.dirname(canonical), { bigint: true });
assertPath(file, "file");
assertPath(parent, "directory");
// Accepted <=9.6 one-hop tradeoff: Number serialization can hide an inode collision.
// Admit its shipped spelling once, then pin bigint identities for every later check.
const matches = (stat: BigIntStats, expected: string) =>
expected === databaseFileIdentityKey(stat) ||
(legacyNumeric && expected === `${Number(stat.dev)}:${Number(stat.ino)}`);
if (
previous &&
(canonical !== previous.databasePath ||
!matches(file, previous.databaseIdentity) ||
!matches(parent, previous.parentIdentity))
) {
throw new Error("managed handoff lease database identity changed");
}
return Object.freeze({
databasePath: canonical,
databaseIdentity: `${file.dev}:${file.ino}`,
parentIdentity: `${parent.dev}:${parent.ino}`,
databaseIdentity: databaseFileIdentityKey(file),
parentIdentity: databaseFileIdentityKey(parent),
});
}
export function assertManagedUpdateLeaseDatabaseIdentity(
binding: ManagedUpdateLeaseDatabaseIdentity,
): void {
const actual = captureManagedUpdateLeaseDatabaseIdentity(binding.databasePath);
if (
actual.databasePath !== binding.databasePath ||
actual.databaseIdentity !== binding.databaseIdentity ||
actual.parentIdentity !== binding.parentIdentity
) {
throw new Error("managed handoff lease database identity changed");
}
captureManagedUpdateLeaseDatabaseIdentity(binding.databasePath, binding);
}
/** Existing managed-update lease storage; extraction does not change its schema. */
@ -396,7 +399,10 @@ export function createManagedHandoffLeaseDatabase(
identity: directoryIdentity,
});
}
const databaseIdentity = repairPrivateFileMode(databasePath, fs.lstatSync(databasePath));
const databaseIdentity = repairPrivateFileMode(
databasePath,
fs.lstatSync(databasePath, { bigint: true }),
);
assertPath(databaseIdentity, "file");
const db = openNodeSqliteDatabase(
write ? resolveExistingSqliteFileUri(databasePath) : databasePath,
@ -404,7 +410,7 @@ export function createManagedHandoffLeaseDatabase(
);
try {
assertSamePath(fs.lstatSync(dir, { bigint: true }), directoryIdentity, "directory");
assertSamePath(fs.lstatSync(databasePath), databaseIdentity, "file");
assertSamePath(fs.lstatSync(databasePath, { bigint: true }), databaseIdentity, "file");
setSqliteBusyTimeout(db, 5000);
if (write) {
initializeLeaseSchema(db);

View file

@ -365,7 +365,7 @@ unix.each(["update", "foreground", "retarget"] as const)(
const rows = () =>
db.prepare("SELECT * FROM managed_update_handoffs ORDER BY install_root").all();
const before = rows();
expect(store.read(to)).toEqual({ kind: "unreadable" });
expect(store.read(to)).toEqual({ kind: "unreadable", error: expect.any(Error) });
expect(rows()).toEqual(before);
const result =
admission === "retarget"

View file

@ -40,7 +40,8 @@ export const triageFailureSchema = z.strictObject({
const text = managedHandoffLeaseText;
type LeaseRead =
| { kind: "absent" | "unreadable" }
| { kind: "absent" }
| { kind: "unreadable"; error: unknown }
| { kind: "current"; lease: ManagedHandoffLease };
export function createManagedHandoffLeaseRows(
@ -136,8 +137,8 @@ export function createManagedHandoffLeaseRows(
const value = row(db, root);
return value ? { kind: "current", lease: handle(root, value) } : { kind: "absent" };
});
} catch {
return { kind: "unreadable" };
} catch (error) {
return { kind: "unreadable", error };
}
}
function readRetainedSources(): ManagedHandoffLease[] {

View file

@ -131,7 +131,7 @@ export function registerPreparedCoordinatorAdmissionTest(params: {
await fs.promises.rename(coordinator, displaced);
moved = true;
expect(originalStore.read(root)).toEqual({ kind: "unreadable" });
expect(originalStore.read(root)).toEqual({ kind: "unreadable", error: expect.any(Error) });
await expect(
start().then((result) => {
latest = result;