fix(update): package activation recovery rejects Bun-hosted installs (#162586)

Package recovery treated checked Bun executables as Node and dropped configured macOS SQLite selection. Carry admitted runtime facts and the shared environment choice into durable standalone recovery, preserving version-1 journals, backup custody, and separately owned service restart.

Verified focused suites on Node and Bun, real macOS custom-library recovery from a clean shell, published-driver updates and Node-free interrupted recovery on AWS, static/import checks, and independent review. CI fixture and cell-lifetime repairs retain the existing assertions and product timeout policies.
This commit is contained in:
Peter Steinberger 2026-10-01 09:54:24 -07:00 • committed by GitHub
parent c4f5599a74
commit a4a78b738e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1147 additions and 432 deletions

View file

@ -20,8 +20,9 @@ jobs:
steps:
- name: Start cell budget
run: |
# Hosted updates took 401 seconds, with up to 300 seconds of backup cleanup.
# Reserve 15 seconds for termination and one minute for diagnostics.
echo "CELL_DEADLINE_EPOCH_SECONDS=$(( $(date +%s) + 525 ))" >> "$GITHUB_ENV"
echo "CELL_DEADLINE_EPOCH_SECONDS=$(( $(date +%s) + 1125 ))" >> "$GITHUB_ENV"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
timeout-minutes: 2
with:
@ -41,7 +42,7 @@ jobs:
path: .artifacts/published-driver-package
merge-multiple: true
- name: Update published driver to candidate
timeout-minutes: 10
timeout-minutes: 20
env:
CANDIDATE_SHA256: ${{ inputs.candidate_sha256 }}
run: |

View file

@ -44,7 +44,7 @@ artifact by its immutable ID, verifies its SHA-256, and skips host dependency
installation and candidate compilation. Successful producer artifacts can be
reused when only the failed consumer job is rerun.
The cell reserves termination and diagnostic time within its ten-minute budget.
The cell reserves termination and diagnostic time within its twenty-minute budget.
Its command deadline returns a failed step with the active phase recorded,
instead of cancelling the workflow through a job-level timeout. The shared bare
Docker runner installs the latest stable npm package as the driver and supplies
@ -55,7 +55,7 @@ canary/identity/lease warnings, and no candidate-startup or authority-check
failure in the recorded run. This catches compatibility failures hidden by
tests that use the same source for both driver and candidate. The selected job
is required by `openclaw/ci-gate`, reserves one GitHub-hosted runner in the
existing capacity accounting, and has a ten-minute job budget.
existing capacity accounting, and has a twenty-minute cell budget.
Labeler skips PR edits without title or base-branch changes. These ignored edits use isolated per-run concurrency groups so they cannot cancel running labeling or replace useful pending work. Opened, reopened, synchronize, and title/base-edit events retain the shared per-PR group and supersede older labeling runs. Issue labeling and manual backfills retain their existing non-cancelling ref group.

View file

@ -61,6 +61,8 @@ Candidates must meet the WAL safety floor and support extension loading before s
SQLite storage workers inherit the main process's selected library. Opening another database or restarting a storage worker reuses that selection without repeating Bun's one-shot library initialization.
Package-update recovery retains the library selected during Bun admission. On macOS, copy the printed recovery command including its `OPENCLAW_SQLITE_LIBRARY` prefix; it works from a fresh shell without the service environment or custom `HOMEBREW_PREFIX`. If recovery cannot meet the SQLite safety floor, it refuses before opening the journal and names the recorded library input to restore. The version-1 recovery journal format is unchanged.
Set `OPENCLAW_SQLITE_LIBRARY` in the process environment before starting OpenClaw to override discovery:
```sh

View file

@ -430,7 +430,8 @@ checks still prevent completion.
### Package-publication recovery
Supported POSIX npm updates print an external-Node recovery command before
Supported POSIX npm updates print a recovery command using the selected external
Node or Bun executable before
transferring the staged package into recovery custody. Keep the printed commands;
each names one operation with required `--anchor` and `--operation` arguments.
The initial journal and helper are published together in a private control
@ -444,6 +445,12 @@ package publication, and `retire` removes only its recorded obsolete objects.
These commands do not replace post-update plugin, migration or service recovery.
Keep other package managers stopped while recovering the operation.
Bun recovery requires a supported Bun runtime with WAL-reset-safe SQLite and can
run without Node installed. The installed updater controls the first upgrade:
older releases may omit the recovery command on Bun or refuse a Bun recovery
runtime. Installing a newer candidate does not change that first-hop behavior;
subsequent updates use the candidate's recovery support.
Retirement records removal of the disposable directory before recording the
helper's final unlink intent. The helper is then removed. The bounded last
receipt remains in the control directory and is readable through

View file

@ -614,7 +614,7 @@ export async function executeMutableUpdate(
beforeActivate,
...createPackageUpdateActivationOptions({
run: opts.run,
nodeRunner: params.packageUpdateNodeRunner,
runtime: params.packageActivationRuntime,
assertCurrent: assertExecutionCurrent,
}),
managedServiceEnv: preManagedServiceStop?.serviceEnv,

View file

@ -1,4 +1,5 @@
import type { LegacyConfigUpdatePlan } from "../../commands/doctor/legacy-config-repair.js";
import type { PackageActivationRuntime } from "../../infra/package-update-swap-contract.js";
import type { DevUpdateTarget } from "../../infra/update-dev-target.js";
import type { ResolvedGlobalInstallTarget } from "../../infra/update-global.js";
import type { UpdateRunPhasePatch } from "../../infra/update-run-mutation.types.js";
@ -57,6 +58,7 @@ export type MutableUpdateExecutionParams = {
packageTargetVersion?: string;
packageTargetSchemaVersions?: OpenClawSchemaVersions;
packageUpdateNodeRunner?: string;
packageActivationRuntime?: PackageActivationRuntime;
managedServiceNodeRunner?: string;
managedServiceRootRedirect: ManagedServiceRootRedirect | null;
managedServiceRoot?: string;

View file

@ -28,7 +28,7 @@ import {
withUpdateCommandExecutorChild,
type UpdateCommandExecutor,
} from "./update-command-executor.js";
import { resolvePackageRuntimePreflight } from "./update-command-service-plan.js";
import { resolvePackageRuntimePreflight } from "./update-command-runtime-preflight.js";
import { createUpdateOperationDeadline } from "./update-operation-deadline.js";
const boundaries = vi.hoisted(() => ({ store: vi.fn(), runtime: vi.fn(), databasePath: "" }));
@ -464,6 +464,8 @@ it.each(["forced", "uncertain"] as const)(
it.each(["forced", "uncertain"] as const)(
"settles a failed runtime probe before preflight handoff release (%s)",
async (cleanupResult) => {
const fallbackNodeRunner = path.join(root, "replacement-node");
fs.writeFileSync(fallbackNodeRunner, "#!/bin/sh\nexit 1\n", { mode: 0o700 });
const admitted = createDeferredCore();
const cleanup = createDeferredCore<"forced" | "uncertain">();
let handedOff = false;
@ -479,6 +481,7 @@ it.each(["forced", "uncertain"] as const)(
const runtime = await resolvePackageRuntimePreflight({
root,
target: { version: "2026.9.17", nodeEngine: ">=24.16.0" },
fallbackNodeRunner,
alreadyCurrent: true,
shouldRestart: true,
service: {

View file

@ -9,7 +9,7 @@ import * as channelConfig from "./update-command-config.js";
import * as execution from "./update-command-execution.js";
import { installFreshUpdateFixture, targetMetadata } from "./update-command-fresh.test-support.js";
import * as packageUpdate from "./update-command-package.js";
import * as servicePlan from "./update-command-service-plan.js";
import * as runtimePlan from "./update-command-runtime-preflight.js";
import { updateCommand } from "./update-command.js";
const { fixture } = installFreshUpdateFixture();
@ -25,7 +25,7 @@ it.each([false, true])(
...targetMetadata,
schemaVersions: { ...targetMetadata.schemaVersions, state: OPENCLAW_STATE_SCHEMA_VERSION },
});
vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
vi.spyOn(runtimePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
ok: true,
value: {},
});

View file

@ -16,7 +16,7 @@ import * as execution from "./update-command-execution.js";
import { installFreshUpdateFixture } from "./update-command-fresh.test-support.js";
import * as packageUpdate from "./update-command-package.js";
import * as commandRun from "./update-command-run.js";
import * as servicePlan from "./update-command-service-plan.js";
import * as runtimePlan from "./update-command-runtime-preflight.js";
import { updateCommand } from "./update-command.js";
const { fixture, dirs } = installFreshUpdateFixture();
@ -72,7 +72,7 @@ it.each([false, true])(
...(await prepare(opts)),
timeoutMs: 30_000,
}));
vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
vi.spyOn(runtimePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
ok: true,
value: { nodeRunner: process.execPath },
});

View file

@ -24,7 +24,7 @@ import type { installFreshUpdateFixture } from "./update-command-fresh.test-supp
import * as initialization from "./update-command-initialization.js";
import * as packageUpdate from "./update-command-package.js";
import * as commandRun from "./update-command-run.js";
import * as servicePlan from "./update-command-service-plan.js";
import * as runtimePlan from "./update-command-runtime-preflight.js";
import * as commandTriage from "./update-command-triage.js";
import { updateCommand } from "./update-command.js";
@ -86,7 +86,7 @@ function expectOriginalCapture(params: {
}
export function allowPackageRuntime() {
return vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
return vi.spyOn(runtimePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
ok: true,
value: {},
});

View file

@ -43,7 +43,7 @@ import { installFreshUpdateFixture, targetMetadata } from "./update-command-fres
import * as initialization from "./update-command-initialization.js";
import * as packageUpdate from "./update-command-package.js";
import * as commandRun from "./update-command-run.js";
import * as servicePlan from "./update-command-service-plan.js";
import * as runtimePlan from "./update-command-runtime-preflight.js";
import {
deferUpdateCommandTerminalResult,
publishUpdateCommandTerminalResult,
@ -566,7 +566,7 @@ describe("update command admission with fresh state", () => {
OPENCLAW_CONFIG_PATH: serviceConfigPath,
};
vi.spyOn(commandRun, "resolveUpdateCommandAdmissionEnv").mockResolvedValue(serviceEnv);
vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
vi.spyOn(runtimePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
ok: false,
error: "fixture-stop",
});
@ -603,7 +603,7 @@ describe("update command admission with fresh state", () => {
writeStoredChannel("beta");
return targetMetadata;
});
const runtime = vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
const runtime = vi.spyOn(runtimePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
ok: false,
error: "fixture-stop",
});
@ -733,7 +733,7 @@ it.each([
return staged;
});
const runtime = vi
.spyOn(servicePlan, "resolvePackageRuntimePreflight")
.spyOn(runtimePlan, "resolvePackageRuntimePreflight")
.mockResolvedValue({ ok: true, value: {} });
const doctor = vi
.spyOn(initialization, "initializeUpdateStateFromTarget")
@ -828,7 +828,7 @@ it.each(["node", "concurrent-state"] as const)(
}
return stage;
});
vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
vi.spyOn(runtimePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
ok: false,
error: "selected artifact requires a newer Node",
});

View file

@ -7,7 +7,7 @@ import * as databaseContext from "./update-command-database-context.js";
import type { inspectUpdateDatabaseContexts } from "./update-command-database-context.js";
import { installFreshUpdateFixture } from "./update-command-fresh.test-support.js";
import * as packageUpdate from "./update-command-package.js";
import * as servicePlan from "./update-command-service-plan.js";
import * as runtimePlan from "./update-command-runtime-preflight.js";
import { updateCommand } from "./update-command.js";
async function captureFreshManagedServiceAdmission(params: {
@ -61,7 +61,7 @@ describe("update command admission with fresh state", () => {
captureFreshManagedServiceAdmission({ root: fixture.root, owned, writable, restart }),
);
const runtimePreflight = vi
.spyOn(servicePlan, "resolvePackageRuntimePreflight")
.spyOn(runtimePlan, "resolvePackageRuntimePreflight")
.mockResolvedValue({ ok: false, error: "fixture-stop" });
await expect(

View file

@ -15,11 +15,11 @@ import {
} from "./update-command-executor.js";
import { prepareUpdateCommandNativeGate } from "./update-command-native-gate.js";
import type { PackageRuntimeRecovery } from "./update-command-node-runtime-resolution.js";
import type {
PackageRuntimePreflight,
PreManagedServiceStop,
} from "./update-command-service-context-types.js";
import { resolvePackageRuntimePreflight } from "./update-command-service-plan.js";
import {
resolvePackageRuntimePreflight,
type PackageRuntimePreflight,
} from "./update-command-runtime-preflight.js";
import type { PreManagedServiceStop } from "./update-command-service-context-types.js";
/** Only a live updater may provision; discovery never reads dotenv-selected paths. */
export function createPackageRuntimeRecovery(params: {
@ -159,6 +159,7 @@ export async function preparePackageUpdateRuntime(params: {
channel: params.channel,
requestedChannel: params.requestedChannel,
target: params.packageRuntimeTarget,
installedRoot: params.root,
timeoutMs: params.timeoutMs,
nodeRunner:
params.managedServiceRoot && canRefreshManagedServiceNode

View file

@ -17,8 +17,8 @@ vi.mock("./update-command-managed-context.js", () => ({
) => context,
captureOwnedManagedUpdateContext: async () => undefined,
}));
vi.mock("./update-command-service-plan.js", async (original) => ({
...(await original<typeof import("./update-command-service-plan.js")>()),
vi.mock("./update-command-runtime-preflight.js", async (original) => ({
...(await original<typeof import("./update-command-runtime-preflight.js")>()),
resolvePackageRuntimePreflight: async () => ({
ok: true,
value: { nodeRunner: "/target/node" },

View file

@ -16,12 +16,10 @@ import { createPackageRuntimeRecovery } from "./update-command-node-runtime.js";
import { preflightConfiguredNpmPluginTargets } from "./update-command-plugin-preflight.js";
import { finishUpdate } from "./update-command-post-update.js";
import type { RefuseUpdate } from "./update-command-result.js";
import { resolvePackageRuntimePreflight } from "./update-command-runtime-preflight.js";
import type { ManagedServiceRootRedirect } from "./update-command-service-context-types.js";
import { withOwnedManagedUpdateEnv } from "./update-command-service-env.js";
import {
GatewayServiceUpdateOwnershipError,
resolvePackageRuntimePreflight,
} from "./update-command-service-plan.js";
import { GatewayServiceUpdateOwnershipError } from "./update-command-service-plan.js";
import {
maybeStopManagedServiceBeforeMutableUpdate,
mutableUpdateGatewayServiceBlock,

View file

@ -1,12 +1,13 @@
import { capturePackageActivationRuntime } from "../../infra/package-update-activation-paths.js";
import type { PackageActivationRuntime } from "../../infra/package-update-swap-contract.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveNodeRunner } from "./shared.js";
import type { MutableUpdateExecutionParams } from "./update-command-execution.types.js";
import { reserveUpdateCommandExecutorSlot } from "./update-command-executor.js";
import type { PackageInstallUpdateParams } from "./update-command-package.js";
export function createPackageUpdateActivationOptions(params: {
run: MutableUpdateExecutionParams["opts"]["run"];
nodeRunner?: string;
runtime?: PackageActivationRuntime;
assertCurrent: () => void;
}): Pick<PackageInstallUpdateParams, "reserveInstallSlot" | "getActivation"> {
return {
@ -23,7 +24,12 @@ export function createPackageUpdateActivationOptions(params: {
return run && fence
? {
fence,
nodeRunner: params.nodeRunner ?? resolveNodeRunner(),
runtime:
params.runtime ??
capturePackageActivationRuntime(
process.versions.bun ? "bun" : "node",
process.execPath,
),
onPrepared: (command: string) => {
params.assertCurrent();
defaultRuntime.error(

View file

@ -41,6 +41,7 @@ import { createPackageUpdateActivationOptions } from "./update-command-package-a
import * as packageUpdate from "./update-command-package.js";
import { runPackageInstallUpdate, stagePackageInstallUpdate } from "./update-command-package.js";
import { UpdateCommandFailure, UnreportedUpdateAdmissionOutcome } from "./update-command-result.js";
import { resolvePackageRuntimePreflight } from "./update-command-runtime-preflight.js";
import { reportPreMutationUpdateResult } from "./update-command-terminal.js";
import { resolveUpdateResultNextAction } from "./update-recovery-guidance.js";
@ -396,7 +397,7 @@ it.skipIf(process.platform === "win32" || process.platform === "freebsd").each([
expectOriginalInstallation,
} = await createPackageInstallFixture(base, "2.0.0");
const runtimeDir = path.join(base, "runtime");
const runtimePath = path.join(runtimeDir, "openclaw-test-node");
const runtimePath = path.join(runtimeDir, process.versions.bun ? "bun" : "node");
await fs.mkdir(runtimeDir);
await fs.writeFile(runtimePath, `#!/bin/sh\nexec ${quoteCliArg(process.execPath)} "$@"\n`, {
mode: 0o755,
@ -431,12 +432,18 @@ it.skipIf(process.platform === "win32" || process.platform === "freebsd").each([
try {
const result = await withEnvAsync(
{ PATH: `${runtimeDir}${path.delimiter}${process.env.PATH ?? ""}` },
() =>
staged.run({
async () => {
const preflight = await resolvePackageRuntimePreflight({
target: { version: "2.0.0", nodeEngine: null },
nodeRunner: runtime === "PATH" ? path.basename(runtimePath) : process.execPath,
});
assert(preflight.ok);
assert(preflight.value.activationRuntime);
return staged.run({
...params,
...createPackageUpdateActivationOptions({
run: { runId, env, executorFence: fence },
nodeRunner: runtime === "PATH" ? "openclaw-test-node" : process.execPath,
runtime: preflight.value.activationRuntime,
assertCurrent: fence.assertCurrent,
}),
assertCurrent: fence.assertCurrent,
@ -445,7 +452,8 @@ it.skipIf(process.platform === "win32" || process.platform === "freebsd").each([
onTransaction: (retained) => {
transaction = retained;
},
}),
});
},
);
expect(result, JSON.stringify(result)).toMatchObject({
status: "ok",

View file

@ -14,6 +14,7 @@ import {
resolvePackageActivationJournalPath,
} from "../../infra/package-update-activation-journal.js";
import { preparePackageActivationJournal } from "../../infra/package-update-activation-prepare.js";
import { packageActivationRuntimeForTest } from "../../infra/package-update-activation-runtime.test-support.js";
import { createPackageIntegrityReader } from "../../infra/package-update-integrity.js";
import { createPublicationOwner } from "../../infra/package-update-publication-owner.js";
import { writePackageRoot } from "../../infra/package-update-steps.test-support.js";
@ -64,6 +65,11 @@ beforeAll(async () => {
packageRoot = state.path("openclaw");
candidateRoot = state.path("candidate");
const runtime = await prepareCandidateAuthorityRuntime(candidateRoot);
// This compiler-owned legacy test graph is not part of the installed candidate.
await fs.promises.rm(path.join(candidateRoot, "dist", "legacy-finalizer"), {
recursive: true,
force: true,
});
// The receiver verifies its loaded package. Source imports from the checkout
// would bypass the installed-package boundary this regression must exercise.
expect(fileURLToPath(runtime.worker)).toBe(
@ -154,6 +160,7 @@ it.skipIf(process.platform === "win32")(
const { databasePath } = installPrivateUpdateHandoffStore(control);
const guardedEnv = writePrivateUpdateHandoffChildGuard(databasePath, control)(state.env);
vi.stubEnv("NODE_OPTIONS", guardedEnv.NODE_OPTIONS);
vi.stubEnv("BUN_OPTIONS", guardedEnv.BUN_OPTIONS);
const config: OpenClawConfig = {
plugins: { enabled: false, slots: { memory: "none" } },
update: { channel: "stable" },
@ -173,13 +180,14 @@ it.skipIf(process.platform === "win32")(
fs.writeFileSync(path.join(bin, "openclaw"), "previous launcher\n");
fs.writeFileSync(path.join(launchers, "openclaw"), "candidate launcher\n");
const runChild = processRunner.runUtf8CommandWithTimeout;
const runtime = packageActivationRuntimeForTest();
await withUpdateCommandExecutor(runId, async (executor) => {
const fence = await executor.enter(packageRoot);
const publicationStartedAt = performance.now();
const reader = createPackageIntegrityReader();
const prepared = await preparePackageActivationJournal({
options: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
options: { fence, runtime, onPrepared: () => {} },
liveRoot: packageRoot,
stageRoot: candidateRoot,
launcherRoot: launchers,
@ -217,6 +225,7 @@ it.skipIf(process.platform === "win32")(
pluginInstallRecords: {},
updateStartedAtMs: Date.now(),
timeoutMs: 30_000,
nodeRunner: runtime.path,
});
// Baseline raw-spawns the public CLI without a grant and is refused by

View file

@ -4,6 +4,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { Command } from "commander";
import { expect, it, vi } from "vitest";
import * as runtimePaths from "../../daemon/runtime-paths.js";
import * as activationPaths from "../../infra/package-update-activation-paths.js";
import { normalizeUpdateChannel } from "../../infra/update-channels.js";
import * as packageMetadata from "../../infra/update-check-package-target.js";
import * as updateCheck from "../../infra/update-check.js";
@ -14,8 +15,8 @@ import * as shared from "./shared.js";
import { installFreshUpdateFixture, targetMetadata } from "./update-command-fresh.test-support.js";
import * as packageUpdate from "./update-command-package.js";
import * as commandRun from "./update-command-run.js";
import * as runtimePlan from "./update-command-runtime-preflight.js";
import { unsupportedServiceRuntimeFixture } from "./update-command-runtime-recovery.test-support.js";
import * as servicePlan from "./update-command-service-plan.js";
import { updateCommand } from "./update-command.js";
vi.mock("../../infra/container-environment.js", () => ({ isContainerEnvironment: () => false }));
@ -36,6 +37,13 @@ it.each([
const config = JSON.stringify({ update: { channel } });
fs.writeFileSync(configPath, config);
fixture.managedServiceNodeRunner = "/home/operator/.nvm/versions/node/v22.18.0/bin/node";
const captureRuntime = activationPaths.capturePackageActivationRuntime;
vi.spyOn(activationPaths, "capturePackageActivationRuntime").mockImplementation(
(kind, executable) =>
executable === fixture.managedServiceNodeRunner
? { kind, path: executable, identity: "synthetic-service-node" }
: captureRuntime(kind, executable),
);
const prepare = expectDefined(
vi.mocked(commandRun.prepareUpdateCommand).getMockImplementation(),
"fixture preparation",
@ -56,7 +64,7 @@ it.each([
vi.mocked(shared.resolveTargetVersion).mockResolvedValue({ version });
const runtime = vi.spyOn(runtimePaths, "resolveNodeRuntimeInfo");
runtime.mockResolvedValue(unsupportedServiceRuntimeFixture);
const preflight = vi.spyOn(servicePlan, "resolvePackageRuntimePreflight");
const preflight = vi.spyOn(runtimePlan, "resolvePackageRuntimePreflight");
const options = {
admission: "installed" as const,

View file

@ -0,0 +1,346 @@
// Target runtime admission, executable identity, and compatible-runtime recovery guidance.
import path from "node:path";
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import { err as resultError, ok, type Result } from "@openclaw/normalization-core/result";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { minVersion, validRange, valid } from "semver";
import { detectCurrentSqliteCapabilities, nodeRuntimeFailure } from "../../../node-sqlite.mjs";
import { SUPPORTED_NODE_VERSION_RANGE } from "../../../node-version.mjs";
import { isBunRuntime } from "../../daemon/runtime-binary.js";
import {
buildRuntimeProbeEnv,
resolveBunRuntimeInfo,
resolveNodeRuntimeInfo,
} from "../../daemon/runtime-paths.js";
import { isContainerEnvironment } from "../../infra/container-environment.js";
import { tryReadJson } from "../../infra/json-files.js";
import { capturePackageActivationRuntime } from "../../infra/package-update-activation-paths.js";
import type { PackageActivationRuntime } from "../../infra/package-update-swap-contract.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
import type { UpdateChannel } from "../../infra/update-channels.js";
import {
createUpdateFailureFact,
type UpdateFailureFact,
} from "../../infra/update-failure-facts.js";
import { UPDATE_RUNNER_TIMEOUT_MS } from "../../infra/update-run-timeouts.js";
import { withCommandProcessScope } from "../../process/exec-spawn.js";
import {
createRuntimeUpdateRecoverySteps,
formatUpdateRecoverySteps,
type UpdateRecoveryStep,
} from "../../shared/update-outcome.js";
import { resolveNodeVersionManager } from "../../shared/version-manager-path.js";
import { formatCliCommand } from "../command-format.js";
import { quoteCliArg, quotePowerShellArg } from "../quote-cli-arg.js";
import { resolveNodeRunner } from "./shared.js";
import { minimumSupportedNodeVersion } from "./update-command-node-engine.js";
import type { PackageRuntimeRecovery } from "./update-command-node-runtime-resolution.js";
import type { PreManagedServiceStop } from "./update-command-service-context-types.js";
import { resolveServiceRecoveryContext } from "./update-command-service-env.js";
import {
gatewayServiceCommandUsesRoot,
tryRealpathOrResolve,
} from "./update-command-service-plan.js";
export type PackageRuntimePreflight = {
nodeRunner?: string;
activationRuntime?: PackageActivationRuntime;
replacedNodeRunner?: string;
targetVersion?: string;
};
export async function resolvePackageRuntimePreflight(params: {
channel?: UpdateChannel;
requestedChannel?: UpdateChannel | null;
target?: { version: string; nodeEngine: string | null };
installedRoot?: string;
timeoutMs?: number;
nodeRunner?: string;
root?: string;
shouldRestart?: boolean;
alreadyCurrent?: boolean;
service?: PreManagedServiceStop;
invocationCwd?: string;
/** An already-current source checkout retains its launcher across a global-prefix switch. */
sourceRoot?: string;
fallbackNodeRunner?: string;
runtimeRecovery?: PackageRuntimeRecovery;
}): Promise<
Result<PackageRuntimePreflight, string> & {
failureFacts?: UpdateFailureFact[];
recoverySteps?: UpdateRecoveryStep[];
}
> {
return await withCommandProcessScope(async () => {
const verdict = params.service?.serviceUpdateVerdict;
const nodeRunner = normalizeOptionalString(
params.alreadyCurrent && !(verdict?.kind === "owned" && verdict.requiresInstallRootRefresh)
? (params.service?.serviceNodeRunner ?? params.nodeRunner)
: params.nodeRunner,
);
const unchanged = (): PackageRuntimePreflight => (nodeRunner ? { nodeRunner } : {});
let target = params.target;
if (!target && params.installedRoot) {
const manifest = asNullableRecord(
await tryReadJson<unknown>(path.join(params.installedRoot, "package.json"), {
maxBytes: 1024 * 1024,
}),
);
const version = normalizeOptionalString(manifest?.version);
if (!version) {
return resultError(
"Cannot inspect the installed OpenClaw runtime requirement; repair its package.json before retrying openclaw update.",
);
}
target = {
version,
nodeEngine: normalizeOptionalString(asNullableRecord(manifest?.engines)?.node) ?? null,
};
}
if (!target) {
return ok(unchanged());
}
const selected = nodeRunner ?? process.execPath;
let activationRuntime: PackageActivationRuntime | undefined;
let captureError: unknown;
try {
activationRuntime = capturePackageActivationRuntime("node", selected);
} catch (error) {
captureError = error;
}
// The running Bun can have any executable name; a separate service still owns its selection.
const currentBun =
process.versions.bun &&
(!nodeRunner ||
selected === process.execPath ||
activationRuntime?.path === (await tryRealpathOrResolve(process.execPath)));
// Bun has its own capability contract; its emulated Node version is not an engine.
if (currentBun || isBunRuntime(selected)) {
const runtimeEnv = buildRuntimeProbeEnv(params.service?.serviceEnv ?? process.env);
try {
if (!activationRuntime) {
throw captureError;
}
activationRuntime = { ...activationRuntime, kind: "bun", env: runtimeEnv };
const runtime = await resolveBunRuntimeInfo(activationRuntime.path, undefined, runtimeEnv);
if (runtime.status === "probe-failed") {
throw runtime.error;
}
if (runtime.status !== "supported") {
throw new Error(
runtime.sqliteSelectionError ?? "Bun 1.4+ with WAL-reset-safe node:sqlite is required.",
);
}
// Finalization keeps the updater runtime; service recovery cannot replace it.
const updater = process.versions.bun
? ok<PackageRuntimePreflight, string>({})
: await resolvePackageRuntimePreflight({ target, timeoutMs: params.timeoutMs });
return updater.ok
? ok({ ...unchanged(), activationRuntime, targetVersion: target.version })
: updater;
} catch (error) {
return resultError(error instanceof Error ? error.message : String(error));
}
}
const runtime = await resolvePackageRuntimeForPreflight({
nodeRunner: nodeRunner ? (activationRuntime?.path ?? nodeRunner) : undefined,
timeoutMs: params.timeoutMs,
});
const satisfies = runtime.failure
? false
: nodeVersionSatisfiesEngine(runtime.version, target.nodeEngine);
const targetVersion = target.version;
const unchangedRuntime = { ...unchanged(), activationRuntime, targetVersion };
if (satisfies === true) {
if (!activationRuntime) {
return resultError(
captureError instanceof Error ? captureError.message : String(captureError),
);
}
return ok(unchangedRuntime);
}
const canRefreshCurrentService =
params.service?.running && verdict?.kind === "owned" && verdict.refreshDefinition;
const fallbackNodeRunner =
params.fallbackNodeRunner ??
(params.shouldRestart &&
!process.versions.bun &&
nodeRunner &&
(params.alreadyCurrent
? canRefreshCurrentService
: await gatewayServiceCommandUsesRoot({ root: params.root }))
? resolveNodeRunner()
: undefined);
if (nodeRunner && fallbackNodeRunner && fallbackNodeRunner !== nodeRunner) {
let fallbackActivationRuntime: PackageActivationRuntime | undefined;
try {
fallbackActivationRuntime = capturePackageActivationRuntime("node", fallbackNodeRunner);
} catch (error) {
captureError = error;
}
const fallbackRuntime = await resolvePackageRuntimeForPreflight({
nodeRunner: fallbackActivationRuntime?.path ?? fallbackNodeRunner,
timeoutMs: params.timeoutMs,
});
const fallbackSatisfies = fallbackRuntime.failure
? false
: nodeVersionSatisfiesEngine(fallbackRuntime.version, target.nodeEngine);
if (fallbackSatisfies === true) {
if (!fallbackActivationRuntime) {
return resultError(
captureError instanceof Error ? captureError.message : String(captureError),
);
}
return ok({
nodeRunner: fallbackNodeRunner,
activationRuntime: fallbackActivationRuntime,
replacedNodeRunner: nodeRunner,
targetVersion,
});
}
}
if (satisfies !== false) {
if (!activationRuntime) {
return resultError(
captureError instanceof Error ? captureError.message : String(captureError),
);
}
return ok(unchangedRuntime);
}
if (params.runtimeRecovery && target.nodeEngine) {
const { resolveTargetNodeRuntime } =
await import("./update-command-node-runtime-resolution.js");
const recovered = await resolveTargetNodeRuntime({
engine: target.nodeEngine,
recovery: params.runtimeRecovery,
timeoutMs: params.timeoutMs,
});
if (recovered) {
let recoveredRuntime: PackageActivationRuntime;
try {
recoveredRuntime = capturePackageActivationRuntime("node", recovered);
} catch (error) {
return resultError(error instanceof Error ? error.message : String(error));
}
return ok({
nodeRunner: recovered,
activationRuntime: recoveredRuntime,
replacedNodeRunner: nodeRunner ?? resolveNodeRunner(),
targetVersion,
});
}
}
const runtimeLabel = runtime.nodeRunner
? `Node ${runtime.version ?? "unknown"} at ${runtime.nodeRunner}`
: `Node ${runtime.version ?? "unknown"}`;
const engineRange = target.nodeEngine ? validRange(target.nodeEngine) : null;
const minimum = engineRange
? (minVersion(engineRange)?.version ?? "unspecified")
: "unspecified";
const recommendation = minimumSupportedNodeVersion(engineRange ?? "*");
const requirement = target.nodeEngine ? `Node ${target.nodeEngine}` : "a working Node runtime";
const context =
verdict?.kind === "owned" && params.service?.serviceEnv
? resolveServiceRecoveryContext({
serviceEnv: params.service.serviceEnv,
serviceDefinitionEnv: params.service.serviceDefinitionEnv,
invocationCwd: params.invocationCwd,
})
: undefined;
const env = context?.env ?? params.service?.serviceEnv ?? process.env;
const recoveryVersion = valid(targetVersion);
const recoveryChannel =
params.requestedChannel ??
(params.channel === "extended-stable" ? params.channel : undefined);
const recoveryTarget = [
"openclaw update",
recoveryChannel ? `--channel ${recoveryChannel}` : "",
params.sourceRoot || params.channel === "extended-stable" ? "" : `--tag ${recoveryVersion}`,
]
.filter(Boolean)
.join(" ");
const retainedRoot = params.sourceRoot ?? params.root ?? params.installedRoot;
const retainedEntry = retainedRoot ? path.resolve(retainedRoot, "openclaw.mjs") : undefined;
const continuation = retainedEntry
? formatCliCommand(recoveryTarget, env).replace(
/^openclaw\b/,
() =>
`node ${process.platform === "win32" ? quotePowerShellArg(retainedEntry) : quoteCliArg(retainedEntry)}`,
)
: undefined;
const recoverySteps =
recommendation && recoveryVersion
? createRuntimeUpdateRecoverySteps({
nodeVersion: recommendation,
targetVersion: recoveryVersion,
manager: resolveNodeVersionManager(
await tryRealpathOrResolve(runtime.nodeRunner ?? resolveNodeRunner()),
env,
),
container: isContainerEnvironment(),
contextCommand: context?.command,
continuation,
})
: undefined;
if (
recoverySteps?.at(-1)?.kind === "continue-update" &&
params.alreadyCurrent &&
nodeRunner &&
params.service?.serviceNodeRunner &&
!canRefreshCurrentService
) {
recoverySteps.splice(-1, 0, {
kind: "select-runtime",
instruction: `The Gateway service still selects ${nodeRunner}. Before continuing, have its deployment owner select Node ${recommendation} in the service definition while retaining its installation, service account, and state/config selectors. Switching the shell runtime alone does not update that service definition.`,
});
}
const upgrade = recoverySteps
? `Recovery:\n${formatUpdateRecoverySteps(recoverySteps)}`
: recommendation
? "Select a published OpenClaw version before installing it under a supported Node runtime."
: `No Node version satisfies both this range and this updater's supported range (${SUPPORTED_NODE_VERSION_RANGE}). This candidate version cannot be run by this updater with a supported Node release; install a supported Node and select a compatible OpenClaw target.`;
return {
...(recoverySteps ? { recoverySteps } : {}),
...resultError<PackageRuntimePreflight, string>(
[
`openclaw@${targetVersion} requires ${requirement}; selected runtime is ${runtimeLabel}.`,
...(runtime.failure ? [runtime.failure] : []),
upgrade,
].join("\n"),
),
failureFacts: [
createUpdateFailureFact({
check: "node-runtime",
code: "node-runtime-preflight",
affectedKey: "engines.node",
message: `Target package: openclaw@${valid(targetVersion) ?? "unknown"}; Minimum Node engine: ${minimum}; Running Node: ${valid(runtime.version ?? "") ?? "unknown"}`,
}),
],
};
});
}
async function resolvePackageRuntimeForPreflight(params: {
nodeRunner?: string;
timeoutMs?: number;
}): Promise<{ version: string | null; nodeRunner?: string; failure: string | null }> {
const nodeRunner = normalizeOptionalString(params.nodeRunner);
if (!nodeRunner) {
const version = process.versions.node ?? null;
return {
version,
failure: nodeRuntimeFailure(version, await detectCurrentSqliteCapabilities()),
};
}
const runtime = await resolveNodeRuntimeInfo(
nodeRunner,
process.env,
params.timeoutMs ?? UPDATE_RUNNER_TIMEOUT_MS,
);
return {
version: runtime.status === "probe-failed" ? null : runtime.version,
failure:
runtime.status === "probe-failed" ? runtime.error.message : (runtime.capabilityError ?? null),
nodeRunner,
};
}

View file

@ -5,6 +5,7 @@ import * as runtimePaths from "../../daemon/runtime-paths.js";
import * as daemonService from "../../daemon/service.js";
import { createMockGatewayService } from "../../daemon/service.test-helpers.js";
import * as gatewaySupervision from "../../infra/gateway-supervision.js";
import * as activationPaths from "../../infra/package-update-activation-paths.js";
import * as packageMetadata from "../../infra/update-check-package-target.js";
import * as updateGlobal from "../../infra/update-global.js";
import { defaultRuntime } from "../../runtime.js";
@ -47,6 +48,13 @@ it.each([
vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", undefined);
vi.stubEnv("OPENCLAW_DEBUG_PROXY_REQUIRE", undefined);
fixture.managedServiceNodeRunner = "/service/node";
const captureRuntime = activationPaths.capturePackageActivationRuntime;
vi.spyOn(activationPaths, "capturePackageActivationRuntime").mockImplementation(
(kind, executable) =>
executable === "/service/node" || executable === "/current/node"
? { kind, path: executable, identity: `synthetic:${executable}` }
: captureRuntime(kind, executable),
);
const provisionRuntime = vi
.spyOn(runtimeRecovery, "resolveTargetNodeRuntime")
.mockRejectedValue(new Error("A retained service runtime must not be provisioned"));

View file

@ -40,11 +40,11 @@ import {
} from "./update-command-dry-run.js";
import type { RefuseUpdate } from "./update-command-result.js";
import type { prepareUpdateCommand } from "./update-command-run.js";
import { resolvePackageRuntimePreflight } from "./update-command-runtime-preflight.js";
import type {
ManagedServiceRootRedirect,
PreManagedServiceStop,
} from "./update-command-service-context-types.js";
import { resolvePackageRuntimePreflight } from "./update-command-service-plan.js";
import type { resolveUpdateCommandTarget } from "./update-command-target.js";
/** Render prepared preview facts without initializing runtime state. */

View file

@ -102,12 +102,6 @@ export type OriginalManagedServiceRuntime = {
nodeIdentity: string;
};
export type PackageRuntimePreflight = {
nodeRunner?: string;
replacedNodeRunner?: string;
targetVersion?: string;
};
export type ManagedServiceRootRedirect = {
root: string;
previousRoot: string;

View file

@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nodeRuntimeFailure } from "../../../node-sqlite.mjs";
import { isSupportedOpenClawNodeVersion } from "../../../node-version.mjs";
import {
resolveBunRuntimeInfo,
resolveNodeRuntimeInfo,
resolvePinnedDaemonRuntimePath,
} from "../../daemon/runtime-paths.js";
@ -12,13 +13,13 @@ import { prepareUpdateFailureReport } from "../../infra/update-failure-report-pr
import { withTempDir } from "../../test-utils/temp-dir.js";
import { quoteCliArg, quotePowerShellArg } from "../quote-cli-arg.js";
import { resolveTargetNodeRuntime } from "./update-command-node-runtime-resolution.js";
import { resolvePackageRuntimePreflight } from "./update-command-runtime-preflight.js";
import {
expectedPlainRecovery,
expectedRuntimeSelectionCommand,
unsupportedServiceRuntimeFixture,
} from "./update-command-runtime-recovery.test-support.js";
import type { PreManagedServiceStop } from "./update-command-service-context-types.js";
import { resolvePackageRuntimePreflight } from "./update-command-service-plan.js";
const refreshableService: PreManagedServiceStop = {
stopped: false,
@ -37,9 +38,21 @@ const probeState = vi.hoisted(() => ({ text: true, container: false }));
vi.mock("../../infra/container-environment.js", () => ({
isContainerEnvironment: () => probeState.container,
}));
vi.mock("../../daemon/runtime-paths.js", () => ({
resolveNodeRuntimeInfo: vi.fn(),
resolvePinnedDaemonRuntimePath: vi.fn(),
vi.mock("../../daemon/runtime-paths.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../daemon/runtime-paths.js")>();
return {
...actual,
resolveBunRuntimeInfo: vi.fn(),
resolveNodeRuntimeInfo: vi.fn(),
resolvePinnedDaemonRuntimePath: vi.fn(),
};
});
vi.mock("../../infra/package-update-activation-paths.js", () => ({
capturePackageActivationRuntime: vi.fn((kind, executable) => ({
kind,
path: executable,
identity: `fixture:${executable}`,
})),
}));
vi.mock("./update-command-node-runtime-resolution.js", () => ({
resolveTargetNodeRuntime: vi.fn(),
@ -60,6 +73,13 @@ vi.mock("../../../node-sqlite.mjs", async (importOriginal) => {
describe("package runtime compatibility guidance", () => {
beforeEach(() => {
vi.mocked(resolveBunRuntimeInfo).mockResolvedValue({
status: "supported",
version: "1.4.3",
sqliteVersion: "3.51.3",
nodeSharedSqlite: false,
sqliteProbe: { available: true, version: "3.51.3", text: true, blob: true, json: true },
});
vi.stubGlobal("process", {
...process,
execPath: path.resolve("/fixture/node"),
@ -72,26 +92,57 @@ describe("package runtime compatibility guidance", () => {
probeState.text = true;
probeState.container = false;
vi.mocked(resolveNodeRuntimeInfo).mockReset();
vi.mocked(resolveBunRuntimeInfo).mockReset();
vi.mocked(resolvePinnedDaemonRuntimePath).mockReset();
vi.mocked(resolveTargetNodeRuntime).mockReset();
});
it("retains the current Bun without Node engine checks or provisioning", async () => {
vi.stubGlobal("process", {
...process,
execPath: path.resolve("/fixture/app-runtime"),
versions: { ...process.versions, bun: "1.4.3", node: "24.3.0" },
});
const result = await resolvePackageRuntimePreflight({
target: { version: "2027.1.0", nodeEngine: ">=90.0.0" },
shouldRestart: true,
service: refreshableService,
runtimeRecovery: { env: {}, installCommand: vi.fn() },
});
expect(result).toEqual({ ok: true, value: { targetVersion: "2027.1.0" } });
expect(resolveNodeRuntimeInfo).not.toHaveBeenCalled();
expect(resolveTargetNodeRuntime).not.toHaveBeenCalled();
});
it.each([undefined, "/fixture/app-runtime"])(
"retains renamed current Bun selected as %s without Node engine checks or provisioning",
async (nodeRunner) => {
const runtimeEnv = {
OPENCLAW_SQLITE_LIBRARY: "/process/sqlite.dylib",
HOMEBREW_PREFIX: "/process/homebrew",
};
vi.stubGlobal("process", {
...process,
env: {
...runtimeEnv,
OPENCLAW_GATEWAY_TOKEN: "synthetic-unrelated-secret",
NODE_OPTIONS: "--require /unrelated/preload.cjs",
},
execPath: path.resolve("/fixture/app-runtime"),
versions: { ...process.versions, bun: "1.4.3", node: "24.3.0" },
});
const result = await resolvePackageRuntimePreflight({
target: { version: "2027.1.0", nodeEngine: ">=90.0.0" },
nodeRunner,
shouldRestart: true,
service: refreshableService,
runtimeRecovery: { env: {}, installCommand: vi.fn() },
});
expect(result).toEqual({
ok: true,
value: {
...(nodeRunner ? { nodeRunner } : {}),
targetVersion: "2027.1.0",
activationRuntime: {
kind: "bun",
path: "/fixture/app-runtime",
identity: "fixture:/fixture/app-runtime",
env: runtimeEnv,
},
},
});
expect(resolveBunRuntimeInfo).toHaveBeenCalledWith(
"/fixture/app-runtime",
undefined,
runtimeEnv,
);
expect(resolveNodeRuntimeInfo).not.toHaveBeenCalled();
expect(resolveTargetNodeRuntime).not.toHaveBeenCalled();
},
);
it.each([true, false])(
"validates the selected service Bun independently of Node engines (supported=%s)",
@ -100,10 +151,21 @@ describe("package runtime compatibility guidance", () => {
"process",
Object.create(process, {
versions: { value: { ...process.versions, bun: "1.4.3" } },
env: {
value: {
OPENCLAW_SQLITE_LIBRARY: "/process/sqlite.dylib",
HOMEBREW_PREFIX: "/process/homebrew",
},
},
}),
);
const bun = "/service/bin/bun";
const env = { OPENCLAW_SQLITE_LIBRARY: "/service/sqlite.dylib" };
const runtimeEnv = { OPENCLAW_SQLITE_LIBRARY: "/service/sqlite.dylib" };
const env = {
...runtimeEnv,
OPENCLAW_GATEWAY_TOKEN: "synthetic-unrelated-secret",
NODE_OPTIONS: "--require /unrelated/preload.cjs",
};
vi.mocked(resolvePinnedDaemonRuntimePath).mockReset();
vi.mocked(resolveNodeRuntimeInfo).mockResolvedValue({
status: "supported",
@ -112,12 +174,11 @@ describe("package runtime compatibility guidance", () => {
nodeSharedSqlite: false,
sqliteProbe: { available: true, version: "3.51.3", text: true, blob: true, json: true },
});
if (supported) {
vi.mocked(resolvePinnedDaemonRuntimePath).mockResolvedValue(bun);
} else {
vi.mocked(resolvePinnedDaemonRuntimePath).mockRejectedValue(
new Error("Bun 1.4+ with WAL-reset-safe node:sqlite is required."),
);
if (!supported) {
vi.mocked(resolveBunRuntimeInfo).mockResolvedValue({
status: "probe-failed",
error: new Error("Bun 1.4+ with WAL-reset-safe node:sqlite is required."),
});
}
const result = await resolvePackageRuntimePreflight({
target: { version: "2027.1.0", nodeEngine: ">=90.0.0" },
@ -128,15 +189,61 @@ describe("package runtime compatibility guidance", () => {
});
expect(result).toEqual(
supported
? { ok: true, value: { nodeRunner: bun, targetVersion: "2027.1.0" } }
? {
ok: true,
value: {
nodeRunner: bun,
targetVersion: "2027.1.0",
activationRuntime: {
kind: "bun",
path: bun,
identity: `fixture:${bun}`,
env: runtimeEnv,
},
},
}
: { ok: false, error: "Bun 1.4+ with WAL-reset-safe node:sqlite is required." },
);
expect(resolvePinnedDaemonRuntimePath).toHaveBeenCalledWith(bun, "bun", env);
expect(resolveBunRuntimeInfo).toHaveBeenCalledWith(bun, undefined, runtimeEnv);
expect(resolveNodeRuntimeInfo).not.toHaveBeenCalled();
expect(resolveTargetNodeRuntime).not.toHaveBeenCalled();
},
);
it("carries a service's custom Homebrew selection without inheriting the process override", async () => {
const bun = "/service/bin/bun";
vi.stubGlobal("process", {
...process,
env: { OPENCLAW_SQLITE_LIBRARY: "/process/sqlite.dylib" },
versions: { ...process.versions, bun: "1.4.3" },
});
const runtimeEnv = { HOMEBREW_PREFIX: "/service/custom-homebrew" };
const serviceEnv = { ...runtimeEnv, NODE_OPTIONS: "--require /unrelated/preload.cjs" };
const result = await resolvePackageRuntimePreflight({
target: { version: "2027.1.0", nodeEngine: ">=90.0.0" },
nodeRunner: bun,
service: { ...refreshableService, serviceEnv },
});
expect(result).toEqual({
ok: true,
value: {
nodeRunner: bun,
targetVersion: "2027.1.0",
activationRuntime: {
kind: "bun",
path: bun,
identity: `fixture:${bun}`,
env: runtimeEnv,
},
},
});
expect(resolveBunRuntimeInfo).toHaveBeenCalledWith(bun, undefined, runtimeEnv);
serviceEnv.HOMEBREW_PREFIX = "/changed/after-preflight";
expect(result.ok && result.value.activationRuntime?.env).toEqual(runtimeEnv);
});
it("does not offer the current Bun as a fallback for a managed Node service", async () => {
vi.stubGlobal("process", {
...process,
@ -157,6 +264,11 @@ describe("package runtime compatibility guidance", () => {
ok: true,
value: {
nodeRunner: "/recovered/node",
activationRuntime: {
kind: "node",
path: "/recovered/node",
identity: "fixture:/recovered/node",
},
replacedNodeRunner: "/old/node",
targetVersion: "2027.1.0",
},
@ -435,6 +547,11 @@ describe("package runtime compatibility guidance", () => {
ok: true,
value: {
nodeRunner: process.execPath,
activationRuntime: {
kind: "node",
path: process.execPath,
identity: `fixture:${process.execPath}`,
},
replacedNodeRunner: "/old/node",
targetVersion: "2027.1.0",
},
@ -592,7 +709,17 @@ describe("package runtime compatibility guidance", () => {
it("preserves a compatible target", async () => {
await expect(
resolvePackageRuntimePreflight({ target: { version: "2027.1.0", nodeEngine: ">=20.0.0" } }),
).resolves.toEqual({ ok: true, value: { targetVersion: "2027.1.0" } });
).resolves.toEqual({
ok: true,
value: {
targetVersion: "2027.1.0",
activationRuntime: {
kind: "node",
path: process.execPath,
identity: `fixture:${process.execPath}`,
},
},
});
});
it("preserves an absent target", async () => {
@ -640,7 +767,15 @@ describe("package runtime compatibility guidance", () => {
if (admitted) {
expect(result).toEqual({
ok: true,
value: { nodeRunner: "/fixture/bin/node", targetVersion: "2026.9.4" },
value: {
nodeRunner: "/fixture/bin/node",
targetVersion: "2026.9.4",
activationRuntime: {
kind: "node",
path: "/fixture/bin/node",
identity: "fixture:/fixture/bin/node",
},
},
});
} else {
expect(result).toMatchObject({
@ -706,6 +841,11 @@ describe("package runtime compatibility guidance", () => {
ok: true,
value: {
nodeRunner: process.execPath,
activationRuntime: {
kind: "node",
path: process.execPath,
identity: `fixture:${process.execPath}`,
},
replacedNodeRunner: "/fixture/old/node",
targetVersion: "2026.9.3",
},

View file

@ -1,21 +1,13 @@
// Read-only managed Gateway ownership and runtime selection for update planning.
import fs from "node:fs/promises";
import path from "node:path";
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import { err as resultError, ok, type Result } from "@openclaw/normalization-core/result";
import { stableStringify } from "@openclaw/normalization-core/stable-stringify";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { minVersion, validRange, valid } from "semver";
import { detectCurrentSqliteCapabilities, nodeRuntimeFailure } from "../../../node-sqlite.mjs";
import { SUPPORTED_NODE_VERSION_RANGE } from "../../../node-version.mjs";
import { createConfigIO } from "../../config/io.js";
import { resolveGatewayPort } from "../../config/paths.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { isBunRuntime } from "../../daemon/runtime-binary.js";
import {
resolveNodeRuntimeInfo,
resolvePinnedDaemonRuntimePath,
} from "../../daemon/runtime-paths.js";
import { resolvePinnedDaemonRuntimePath } from "../../daemon/runtime-paths.js";
import {
formatServiceInspectionReason,
type ServiceInspectionReason,
@ -38,16 +30,12 @@ import {
resolveGatewayService,
type GatewayService,
} from "../../daemon/service.js";
import { isContainerEnvironment } from "../../infra/container-environment.js";
import { sha256Hex } from "../../infra/crypto-digest.js";
import { readActiveGatewayLockIdentity } from "../../infra/gateway-lock.js";
import { assertGatewayServiceMutationAllowed } from "../../infra/gateway-supervision.js";
import { formatInstallOwnerMessage, readInstallOwner } from "../../infra/install-owner.js";
import { tryReadJson } from "../../infra/json-files.js";
import { probePortUsage } from "../../infra/ports-probe.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
import { parseTcpPortFromArgs } from "../../infra/tcp-port.js";
import type { UpdateChannel } from "../../infra/update-channels.js";
import {
createUpdateFailureFact,
type UpdateFailureFact,
@ -60,24 +48,11 @@ import type { UPDATE_PREFLIGHT_DETAILS } from "../../infra/update-preflight-deta
import { UPDATE_RUNNER_TIMEOUT_MS } from "../../infra/update-run-timeouts.js";
import { hasCommandProcessCleanupError } from "../../process/exec-result.js";
import { withCommandProcessScope } from "../../process/exec-spawn.js";
import {
createRuntimeUpdateRecoverySteps,
formatUpdateRecoverySteps,
type UpdateRecoveryStep,
} from "../../shared/update-outcome.js";
import { resolveNodeVersionManager } from "../../shared/version-manager-path.js";
import { formatCliCommand } from "../command-format.js";
import { quoteCliArg, quotePowerShellArg } from "../quote-cli-arg.js";
import { resolveNodeRunner } from "./shared.js";
import { minimumSupportedNodeVersion } from "./update-command-node-engine.js";
import type { PackageRuntimeRecovery } from "./update-command-node-runtime-resolution.js";
import type {
ManagedGatewayUpdateVerdict,
ManagedServicePackageUpdatePlan,
PackageRuntimePreflight,
PreManagedServiceStop,
} from "./update-command-service-context-types.js";
import { resolveServiceRecoveryContext } from "./update-command-service-env.js";
export class GatewayServiceUpdateOwnershipError extends Error {
readonly failureFacts: UpdateFailureFact[];
@ -377,246 +352,7 @@ export async function readManagedGatewayServiceForUpdate(
});
}
export async function resolvePackageRuntimePreflight(params: {
channel?: UpdateChannel;
requestedChannel?: UpdateChannel | null;
target?: { version: string; nodeEngine: string | null };
installedRoot?: string;
timeoutMs?: number;
nodeRunner?: string;
root?: string;
shouldRestart?: boolean;
alreadyCurrent?: boolean;
service?: PreManagedServiceStop;
invocationCwd?: string;
/** An already-current source checkout retains its launcher across a global-prefix switch. */
sourceRoot?: string;
fallbackNodeRunner?: string;
runtimeRecovery?: PackageRuntimeRecovery;
}): Promise<
Result<PackageRuntimePreflight, string> & {
failureFacts?: UpdateFailureFact[];
recoverySteps?: UpdateRecoveryStep[];
}
> {
return await withCommandProcessScope(async () => {
const verdict = params.service?.serviceUpdateVerdict;
const nodeRunner = normalizeOptionalString(
params.alreadyCurrent && !(verdict?.kind === "owned" && verdict.requiresInstallRootRefresh)
? (params.service?.serviceNodeRunner ?? params.nodeRunner)
: params.nodeRunner,
);
const unchanged = (): PackageRuntimePreflight => (nodeRunner ? { nodeRunner } : {});
let target = params.target;
if (!target && params.installedRoot) {
const manifest = asNullableRecord(
await tryReadJson<unknown>(path.join(params.installedRoot, "package.json"), {
maxBytes: 1024 * 1024,
}),
);
const version = normalizeOptionalString(manifest?.version);
if (!version) {
return resultError(
"Cannot inspect the installed OpenClaw runtime requirement; repair its package.json before retrying openclaw update.",
);
}
target = {
version,
nodeEngine: normalizeOptionalString(asNullableRecord(manifest?.engines)?.node) ?? null,
};
}
if (!target) {
return ok(unchanged());
}
// Bun has its own capability contract; its emulated Node version is not an engine.
if (nodeRunner ? isBunRuntime(nodeRunner) : process.versions.bun) {
const runtimeEnv = params.service?.serviceEnv ?? process.env;
try {
await resolvePinnedDaemonRuntimePath(nodeRunner, "bun", runtimeEnv);
// Finalization keeps the updater runtime; service recovery cannot replace it.
const updater = process.versions.bun
? ok<PackageRuntimePreflight, string>({})
: await resolvePackageRuntimePreflight({ target, timeoutMs: params.timeoutMs });
return updater.ok ? ok({ ...unchanged(), targetVersion: target.version }) : updater;
} catch (error) {
return resultError(error instanceof Error ? error.message : String(error));
}
}
const runtime = await resolvePackageRuntimeForPreflight({
nodeRunner,
timeoutMs: params.timeoutMs,
});
const satisfies = runtime.failure
? false
: nodeVersionSatisfiesEngine(runtime.version, target.nodeEngine);
const targetVersion = target.version;
const unchangedRuntime = { ...unchanged(), targetVersion };
if (satisfies === true) {
return ok(unchangedRuntime);
}
const canRefreshCurrentService =
params.service?.running && verdict?.kind === "owned" && verdict.refreshDefinition;
const fallbackNodeRunner =
params.fallbackNodeRunner ??
(params.shouldRestart &&
!process.versions.bun &&
nodeRunner &&
(params.alreadyCurrent
? canRefreshCurrentService
: await gatewayServiceCommandUsesRoot({ root: params.root }))
? resolveNodeRunner()
: undefined);
if (nodeRunner && fallbackNodeRunner && fallbackNodeRunner !== nodeRunner) {
const fallbackRuntime = await resolvePackageRuntimeForPreflight({
nodeRunner: fallbackNodeRunner,
timeoutMs: params.timeoutMs,
});
const fallbackSatisfies = fallbackRuntime.failure
? false
: nodeVersionSatisfiesEngine(fallbackRuntime.version, target.nodeEngine);
if (fallbackSatisfies === true) {
return ok({
nodeRunner: fallbackNodeRunner,
replacedNodeRunner: nodeRunner,
targetVersion,
});
}
}
if (satisfies !== false) {
return ok(unchangedRuntime);
}
if (params.runtimeRecovery && target.nodeEngine) {
const { resolveTargetNodeRuntime } =
await import("./update-command-node-runtime-resolution.js");
const recovered = await resolveTargetNodeRuntime({
engine: target.nodeEngine,
recovery: params.runtimeRecovery,
timeoutMs: params.timeoutMs,
});
if (recovered) {
return ok({
nodeRunner: recovered,
replacedNodeRunner: nodeRunner ?? resolveNodeRunner(),
targetVersion,
});
}
}
const runtimeLabel = runtime.nodeRunner
? `Node ${runtime.version ?? "unknown"} at ${runtime.nodeRunner}`
: `Node ${runtime.version ?? "unknown"}`;
const engineRange = target.nodeEngine ? validRange(target.nodeEngine) : null;
const minimum = engineRange
? (minVersion(engineRange)?.version ?? "unspecified")
: "unspecified";
const recommendation = minimumSupportedNodeVersion(engineRange ?? "*");
const requirement = target.nodeEngine ? `Node ${target.nodeEngine}` : "a working Node runtime";
const context =
verdict?.kind === "owned" && params.service?.serviceEnv
? resolveServiceRecoveryContext({
serviceEnv: params.service.serviceEnv,
serviceDefinitionEnv: params.service.serviceDefinitionEnv,
invocationCwd: params.invocationCwd,
})
: undefined;
const env = context?.env ?? params.service?.serviceEnv ?? process.env;
const recoveryVersion = valid(targetVersion);
const recoveryChannel =
params.requestedChannel ??
(params.channel === "extended-stable" ? params.channel : undefined);
const recoveryTarget = [
"openclaw update",
recoveryChannel ? `--channel ${recoveryChannel}` : "",
params.sourceRoot || params.channel === "extended-stable" ? "" : `--tag ${recoveryVersion}`,
]
.filter(Boolean)
.join(" ");
const retainedRoot = params.sourceRoot ?? params.root ?? params.installedRoot;
const retainedEntry = retainedRoot ? path.resolve(retainedRoot, "openclaw.mjs") : undefined;
const continuation = retainedEntry
? formatCliCommand(recoveryTarget, env).replace(
/^openclaw\b/,
() =>
`node ${process.platform === "win32" ? quotePowerShellArg(retainedEntry) : quoteCliArg(retainedEntry)}`,
)
: undefined;
const recoverySteps =
recommendation && recoveryVersion
? createRuntimeUpdateRecoverySteps({
nodeVersion: recommendation,
targetVersion: recoveryVersion,
manager: resolveNodeVersionManager(
await tryRealpathOrResolve(runtime.nodeRunner ?? resolveNodeRunner()),
env,
),
container: isContainerEnvironment(),
contextCommand: context?.command,
continuation,
})
: undefined;
if (
recoverySteps?.at(-1)?.kind === "continue-update" &&
params.alreadyCurrent &&
nodeRunner &&
params.service?.serviceNodeRunner &&
!canRefreshCurrentService
) {
recoverySteps.splice(-1, 0, {
kind: "select-runtime",
instruction: `The Gateway service still selects ${nodeRunner}. Before continuing, have its deployment owner select Node ${recommendation} in the service definition while retaining its installation, service account, and state/config selectors. Switching the shell runtime alone does not update that service definition.`,
});
}
const upgrade = recoverySteps
? `Recovery:\n${formatUpdateRecoverySteps(recoverySteps)}`
: recommendation
? "Select a published OpenClaw version before installing it under a supported Node runtime."
: `No Node version satisfies both this range and this updater's supported range (${SUPPORTED_NODE_VERSION_RANGE}). This candidate version cannot be run by this updater with a supported Node release; install a supported Node and select a compatible OpenClaw target.`;
return {
...(recoverySteps ? { recoverySteps } : {}),
...resultError<PackageRuntimePreflight, string>(
[
`openclaw@${targetVersion} requires ${requirement}; selected runtime is ${runtimeLabel}.`,
...(runtime.failure ? [runtime.failure] : []),
upgrade,
].join("\n"),
),
failureFacts: [
createUpdateFailureFact({
check: "node-runtime",
code: "node-runtime-preflight",
affectedKey: "engines.node",
message: `Target package: openclaw@${valid(targetVersion) ?? "unknown"}; Minimum Node engine: ${minimum}; Running Node: ${valid(runtime.version ?? "") ?? "unknown"}`,
}),
],
};
});
}
async function resolvePackageRuntimeForPreflight(params: {
nodeRunner?: string;
timeoutMs?: number;
}): Promise<{ version: string | null; nodeRunner?: string; failure: string | null }> {
const nodeRunner = normalizeOptionalString(params.nodeRunner);
if (!nodeRunner) {
const version = process.versions.node ?? null;
return {
version,
failure: nodeRuntimeFailure(version, await detectCurrentSqliteCapabilities()),
};
}
const runtime = await resolveNodeRuntimeInfo(
nodeRunner,
process.env,
params.timeoutMs ?? UPDATE_RUNNER_TIMEOUT_MS,
);
return {
version: runtime.status === "probe-failed" ? null : runtime.version,
failure:
runtime.status === "probe-failed" ? runtime.error.message : (runtime.capabilityError ?? null),
nodeRunner,
};
}
async function tryRealpathOrResolve(value: string): Promise<string> {
export async function tryRealpathOrResolve(value: string): Promise<string> {
return await fs.realpath(path.resolve(value)).catch(() => path.resolve(value));
}

View file

@ -1,8 +1,9 @@
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
buildRuntimeProbeEnv,
resolveBunRuntimeInfo,
resolveNodeRuntimeInfo,
resolvePinnedDaemonRuntimePath,
} from "../../daemon/runtime-paths.js";
import { preparePackageUpdateRuntime } from "./update-command-node-runtime.js";
@ -68,9 +69,18 @@ vi.mock("../../../node-sqlite.mjs", async (original) => ({
json: true,
}),
}));
vi.mock("../../daemon/runtime-paths.js", () => ({
vi.mock("../../daemon/runtime-paths.js", async (original) => ({
...(await original<typeof import("../../daemon/runtime-paths.js")>()),
resolveBunRuntimeInfo: vi.fn(),
resolveNodeRuntimeInfo: vi.fn(),
resolvePinnedDaemonRuntimePath: vi.fn(async (value) => value),
}));
vi.mock("../../infra/package-update-activation-paths.js", async (original) => ({
...(await original<typeof import("../../infra/package-update-activation-paths.js")>()),
capturePackageActivationRuntime: vi.fn((kind, executable) => ({
kind,
path: executable,
identity: `fixture:${executable}`,
})),
}));
vi.mock("./update-command-node-runtime-resolution.js", () => ({
resolveTargetNodeRuntime: async () => undefined,
@ -82,6 +92,15 @@ beforeEach(() => {
state.manager = "npm";
state.sqliteText = true;
vi.mocked(resolveNodeRuntimeInfo).mockReset();
vi.mocked(resolveBunRuntimeInfo)
.mockReset()
.mockResolvedValue({
status: "supported",
version: "1.4.3",
sqliteVersion: "3.53.4",
nodeSharedSqlite: false,
sqliteProbe: { available: true, version: "3.53.4", text: true, blob: true, json: true },
});
});
afterEach(() => vi.unstubAllGlobals());
const rootB = path.resolve(".n1-fixture/B/node_modules/openclaw");
@ -220,7 +239,13 @@ it.each([
});
expect(result).toMatchObject(
admitted
? { ok: true, value: { nodeRunner: bun } }
? {
ok: true,
value: {
nodeRunner: bun,
activationRuntime: { kind: "bun", path: bun, identity: `fixture:${bun}` },
},
}
: {
ok: false,
error: expect.stringContaining(
@ -229,7 +254,11 @@ it.each([
failureFacts: [{ check: "node-runtime", code: "node-runtime-preflight" }],
},
);
expect(resolvePinnedDaemonRuntimePath).toHaveBeenCalledWith(bun, "bun", process.env);
expect(resolveBunRuntimeInfo).toHaveBeenCalledWith(
bun,
undefined,
buildRuntimeProbeEnv(process.env),
);
expect(resolveNodeRuntimeInfo).not.toHaveBeenCalled();
},
);

View file

@ -6,7 +6,7 @@ import * as shared from "./shared.js";
import * as execution from "./update-command-execution.js";
import { installFreshUpdateFixture } from "./update-command-fresh.test-support.js";
import * as commandRun from "./update-command-run.js";
import * as servicePlan from "./update-command-service-plan.js";
import * as runtimePlan from "./update-command-runtime-preflight.js";
import { updateCommand } from "./update-command.js";
installFreshUpdateFixture();
@ -33,7 +33,7 @@ it.each([
nodeEngine: null,
schemaVersions: { state: OPENCLAW_STATE_SCHEMA_VERSION, agent: 20 },
});
vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
vi.spyOn(runtimePlan, "resolvePackageRuntimePreflight").mockResolvedValue({
ok: true,
value: { nodeRunner: process.execPath },
});
@ -55,7 +55,7 @@ it.each([
const stepTimeoutMs =
timeout === undefined ? (trigger === "campaign" ? 45 : 30) * 60_000 : 3_600_000;
expect(servicePlan.resolvePackageRuntimePreflight).toHaveBeenCalledWith(
expect(runtimePlan.resolvePackageRuntimePreflight).toHaveBeenCalledWith(
expect.objectContaining({ timeoutMs: stepTimeoutMs }),
);
expect(execute).toHaveBeenCalledExactlyOnceWith(

View file

@ -1,4 +1,5 @@
import { theme } from "../../../packages/terminal-core/src/theme.js";
import type { PackageActivationRuntime } from "../../infra/package-update-swap-contract.js";
import { tryProcessCwd } from "../../infra/safe-cwd.js";
import { normalizeUpdateChannel } from "../../infra/update-channels.js";
import { UPDATE_RUN_ID_ENV } from "../../infra/update-control-plane-sentinel.js";
@ -289,6 +290,7 @@ async function runResolvedUpdate(
managedServiceNodeRunner,
} = target;
let { packageUpdateNodeRunner } = target;
let packageActivationRuntime: PackageActivationRuntime | undefined;
const refuseUpdate: typeof target.refuseUpdate = async (
reason,
message,
@ -447,6 +449,7 @@ async function runResolvedUpdate(
);
}
packageUpdateNodeRunner = runtimePreflight.value.nodeRunner;
packageActivationRuntime = runtimePreflight.value.activationRuntime;
recoveryState.triageTarget.nodeRunner = packageUpdateNodeRunner;
}
@ -529,6 +532,7 @@ async function runResolvedUpdate(
stagedPackage,
packageTargetVersion: targetVersion ?? undefined,
packageUpdateNodeRunner,
packageActivationRuntime,
managedServiceNodeRunner,
managedServiceRootRedirect,
managedServiceRoot,

View file

@ -75,10 +75,11 @@ function bunRuntime(
hasNodeSqlite = true,
sqliteVersion: string | null = hasNodeSqlite ? "3.51.3" : null,
sqliteSelectionError: string | null = null,
sqliteLibraryPath: string | null = null,
) {
const available = hasNodeSqlite && !sqliteSelectionError;
return {
stdout: `${JSON.stringify({ bunVersion, hasNodeSqlite, sqliteVersion, sqliteSelectionError, sqliteProbe: { available, version: sqliteVersion, text: available, blob: available, json: available } })}\n`,
stdout: `${JSON.stringify({ bunVersion, hasNodeSqlite, sqliteVersion, sqliteSelectionError, sqliteLibraryPath, sqliteProbe: { available, version: sqliteVersion, text: available, blob: available, json: available } })}\n`,
stderr: "",
};
}
@ -517,13 +518,17 @@ describe("resolvePreferredBunPath", () => {
it("probes Bun through the Gateway's SQLite library selection with a minimal env", async () => {
const bunPath = "/opt/homebrew/bin/bun";
const sqliteLibraryPath = "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib";
// Apple's SQLite would report 3.54.0 here; the selected Homebrew library is what the Gateway opens.
const execFile = vi.fn().mockResolvedValue(bunRuntime("1.4.2", true, "3.53.4"));
const execFile = vi
.fn()
.mockResolvedValue(bunRuntime("1.4.2", true, "3.53.4", null, sqliteLibraryPath));
const env = {
PATH: "/opt/homebrew/bin",
HOMEBREW_PREFIX: "/opt/homebrew",
OPENCLAW_SQLITE_LIBRARY: "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib",
OPENCLAW_SQLITE_LIBRARY: sqliteLibraryPath,
OPENCLAW_GATEWAY_TOKEN: "secret",
NODE_OPTIONS: "--require /unrelated/preload.cjs",
};
await expect(resolveBunRuntimeInfo(bunPath, execFile, env)).resolves.toEqual({
@ -531,6 +536,7 @@ describe("resolvePreferredBunPath", () => {
version: "1.4.2",
sqliteProbe: { available: true, version: "3.53.4", text: true, blob: true, json: true },
sqliteVersion: "3.53.4",
sqliteLibraryPath,
nodeSharedSqlite: false,
});
const selectionModule = fileURLToPath(
@ -556,6 +562,65 @@ describe("resolvePreferredBunPath", () => {
);
});
it("resolves the selected library path in the Bun probe before returning it", async () => {
const selectedPath = "custom homebrew/opt/sqlite/lib/libsqlite3.dylib";
const selectionModule = fileURLToPath(
resolveRuntimeWorkerUrl(runtimeProcessEntrypoints.bunSqliteLibrary),
);
const selectLibrary = vi.fn(() => ({ path: selectedPath }));
const execFile = vi.fn<NonNullable<Parameters<typeof resolveBunRuntimeInfo>[1]>>(
async (_file, args) => {
let stdout = "";
runInNewContext(args[1] ?? "", {
require: (specifier: string) => {
if (specifier === selectionModule) {
return { ensureSqliteLibrarySelected: selectLibrary };
}
if (specifier === "node:path") {
return path;
}
if (specifier === "node:sqlite") {
expect(selectLibrary).toHaveBeenCalledOnce();
return { DatabaseSync };
}
throw new Error(`Unexpected probe import: ${specifier}`);
},
Buffer,
Uint8Array,
process: {
versions: { bun: "1.4.2", node: "24.3.0" },
stdout: {
write: (value: string) => {
stdout += value;
},
},
},
});
return { stdout, stderr: "" };
},
);
await expect(resolveBunRuntimeInfo("/opt/bun", execFile, {})).resolves.toMatchObject({
version: "1.4.2",
sqliteLibraryPath: path.resolve(selectedPath),
sqliteProbe: { available: true },
});
expect(selectLibrary).toHaveBeenCalledOnce();
});
it("rejects nonabsolute library metadata from a Bun probe", async () => {
const probe = bunRuntime("1.4.2", true, "3.53.4", null, "relative/sqlite.dylib");
await expect(
resolveBunRuntimeInfo("/opt/bun", vi.fn().mockResolvedValue(probe), {}),
).resolves.toMatchObject({
status: "probe-failed",
error: expect.objectContaining({
message: expect.stringContaining("invalid version metadata"),
}),
});
});
it("never loads the SQLite library selection module into a Node probe", async () => {
mockNodePathPresent("/usr/bin/node");
const execFile = vi.fn().mockResolvedValue(nodeRuntime("26.8.1"));

View file

@ -142,7 +142,7 @@ const execFileAsync: ExecFileAsync = async (file, args, options) =>
timeoutMs: options.timeoutMs,
});
function buildRuntimeProbeEnv(env: Record<string, string | undefined>): NodeJS.ProcessEnv {
export function buildRuntimeProbeEnv(env: Record<string, string | undefined>): NodeJS.ProcessEnv {
const probeEnv: NodeJS.ProcessEnv = {};
for (const key of RUNTIME_PROBE_ENV_KEYS) {
const value = env[key];
@ -166,9 +166,11 @@ function buildRuntimeProbeScript(sqliteLibraryModulePath: string | undefined): s
const selectSqliteLibrary = ${selector};
let sqliteVersion = null;
let sqliteSelectionError = null;
let sqliteLibraryPath = null;
let sqliteProbe = { available: false, version: null, text: false, blob: false, json: false };
try {
selectSqliteLibrary();
const selection = selectSqliteLibrary();
if (selection?.path) sqliteLibraryPath = require("node:path").resolve(selection.path);
} catch (error) {
sqliteSelectionError = error instanceof Error ? error.message : String(error);
}
@ -180,7 +182,7 @@ if (sqliteSelectionError === null) {
}
const variables = (process.config && process.config.variables) || {};
const nodeSharedSqlite = variables.node_shared_sqlite === true || variables.node_shared_sqlite === "true";
process.stdout.write(JSON.stringify({ nodeVersion: process.versions.node, bunVersion: process.versions.bun ?? null, sqliteVersion, sqliteProbe, sqliteSelectionError, nodeSharedSqlite }));
process.stdout.write(JSON.stringify({ nodeVersion: process.versions.node, bunVersion: process.versions.bun ?? null, sqliteVersion, sqliteProbe, sqliteSelectionError, sqliteLibraryPath, nodeSharedSqlite }));
`;
}
@ -192,6 +194,8 @@ type RuntimeInfo =
nodeSharedSqlite: boolean;
/** Set when the runtime's SQLite library selection rejected the operator's override. */
sqliteSelectionError?: string;
/** Absolute library selected by the shared Bun SQLite owner, when applicable. */
sqliteLibraryPath?: string;
sqliteProbe: SqliteCapabilities;
capabilityError?: string;
note?: string;
@ -229,12 +233,17 @@ async function resolveRuntimeInfo(
const version = parsed[`${runtime}Version`];
const sqliteVersion = parsed.sqliteVersion;
const sqliteSelectionError = parsed.sqliteSelectionError;
const sqliteLibraryPath = parsed.sqliteLibraryPath;
const probe = parsed.sqliteProbe;
if (
!(typeof version === "string" || (runtime === "bun" && version === null)) ||
(runtime === "node" && typeof version === "string" && !parseSemver(version)) ||
!(typeof sqliteVersion === "string" || sqliteVersion === null) ||
!(typeof sqliteSelectionError === "string" || sqliteSelectionError == null) ||
!(
sqliteLibraryPath == null ||
(typeof sqliteLibraryPath === "string" && path.isAbsolute(sqliteLibraryPath))
) ||
!isRecord(probe) ||
typeof probe.available !== "boolean" ||
probe.version !== sqliteVersion ||
@ -271,6 +280,7 @@ async function resolveRuntimeInfo(
...(note ? { note } : {}),
nodeSharedSqlite: parsed.nodeSharedSqlite === true || parsed.nodeSharedSqlite === "true",
...(sqliteSelectionError ? { sqliteSelectionError } : {}),
...(typeof sqliteLibraryPath === "string" ? { sqliteLibraryPath } : {}),
};
} catch (cause) {
// A failed exec says nothing about runtime support. Preserve its cause and launch context.

View file

@ -1,7 +1,10 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withUpdateCommandExecutor } from "../cli/update-cli/update-command-executor.js";
import * as runtimePaths from "../daemon/runtime-paths.js";
import * as durability from "./directory-durability.js";
import {
openPackageActivationJournal,
@ -9,13 +12,23 @@ import {
resolvePackageActivationHelper,
} from "./package-update-activation-journal.js";
import { createPackageActivationLifetimeFixture } from "./package-update-activation-lifetime.test-support.js";
import {
capturePackageActivationRuntime,
resolvePackageActivationAnchor,
} from "./package-update-activation-paths.js";
import { preparePackageActivationJournal } from "./package-update-activation-prepare.js";
import {
readPackageActivationStatus,
runPackageActivationRecovery,
} from "./package-update-activation.js";
import { createPackageIntegrityReader } from "./package-update-integrity.js";
import { createPackageSwapFixture } from "./package-update-swap.test-support.js";
const fixture = createPackageActivationLifetimeFixture();
beforeEach(() => fixture.setup());
let root: string;
beforeEach(() => {
({ root } = fixture.setup());
});
afterEach(async () => {
try {
await fixture.lifetime.cleanup();
@ -37,6 +50,69 @@ async function recover(anchor: string) {
}
describe.skipIf(process.platform === "win32")("package preparation durability", () => {
it.each(["before-probe", "during-probe", "unsupported-bun"] as const)(
"refuses %s runtime admission before taking package custody",
async (cut) => {
const f = await createPackageSwapFixture(root);
const executable = path.join(root, "selected-runtime");
fs.writeFileSync(executable, "fixture runtime", { mode: 0o700 });
const runtime = capturePackageActivationRuntime("bun", executable);
if (cut === "before-probe") {
fs.renameSync(executable, `${executable}.previous`);
fs.writeFileSync(executable, "fixture runtime", { mode: 0o700 });
}
vi.spyOn(runtimePaths, "resolveBunRuntimeInfo").mockImplementation(async () => {
if (cut === "during-probe") {
fs.writeFileSync(executable, "changed runtime executable");
}
return {
status: cut === "unsupported-bun" ? "unsupported" : "supported",
version: cut === "unsupported-bun" ? "1.3.0" : "1.4.3",
sqliteVersion: "3.53.4",
sqliteProbe: {
available: true,
version: "3.53.4",
text: true,
blob: true,
json: true,
},
nodeSharedSqlite: false,
};
});
const anchor = resolvePackageActivationAnchor(f.packageRoot);
const onPrepared = vi.fn();
const onCustody = vi.fn();
const reader = createPackageIntegrityReader();
const previous = await reader.tree(f.packageRoot);
const candidate = await reader.tree(f.params.stage.packageRoot);
await withUpdateCommandExecutor(randomUUID(), async (executor) => {
await expect(
preparePackageActivationJournal({
options: { fence: await executor.enter(f.packageRoot), runtime, onPrepared },
liveRoot: f.packageRoot,
stageRoot: f.params.stage.packageRoot,
launcherRoot: f.params.stage.layout.binDir,
binDir: path.dirname(f.launcher),
previous,
onCustody,
launchers: [],
}),
).rejects.toThrow(
cut === "unsupported-bun"
? "supported external Bun executable"
: "changed after runtime preflight",
);
});
expect(onPrepared).not.toHaveBeenCalled();
expect(onCustody).not.toHaveBeenCalled();
expect(fs.existsSync(anchor)).toBe(false);
expect(fs.existsSync(resolvePackageActivationControl(anchor))).toBe(false);
expect(await reader.tree(f.packageRoot)).toEqual(previous);
expect(await reader.tree(f.params.stage.packageRoot)).toEqual(candidate);
expect(fs.readFileSync(f.launcher, "utf8")).toBe("old launcher\n");
},
);
it("keeps anchor removal resumable until its parent is persisted", async () => {
const f = await fixture.prepare();
await runPackageActivationRecovery(f.anchor, "repair", f.operationId);

View file

@ -21,7 +21,9 @@ import {
} from "./package-update-activation-journal.js";
import { preparePackageActivationJournal } from "./package-update-activation-prepare.js";
import { packageActivationRuntimeEntrypoint } from "./package-update-activation-runtime-assets.js";
import { packageActivationRuntimeForTest } from "./package-update-activation-runtime.test-support.js";
import { createPackageIntegrityReader } from "./package-update-integrity.js";
import type { PackageActivationRuntime } from "./package-update-swap-contract.js";
import { createPackageSwapFixture } from "./package-update-swap.test-support.js";
import * as runtimeWorker from "./runtime-worker-url.js";
@ -47,7 +49,11 @@ export function createPackageActivationLifetimeFixture() {
return { root, assertDatabasePath, childGuardEnv };
}
async function prepare(cut?: (anchor: string) => void, onCustody?: (retained: boolean) => void) {
async function prepare(
cut?: (anchor: string) => void,
onCustody?: (retained: boolean) => void,
runtime: PackageActivationRuntime = packageActivationRuntimeForTest(),
) {
const f = await createPackageSwapFixture(root);
const anchor = resolvePackageActivationAnchor(f.packageRoot);
const previous = await createPackageIntegrityReader().tree(f.packageRoot);
@ -55,7 +61,7 @@ export function createPackageActivationLifetimeFixture() {
const fence = await executor.enter(f.packageRoot);
cut?.(anchor);
await preparePackageActivationJournal({
options: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
options: { fence, runtime, onPrepared: () => {} },
liveRoot: f.packageRoot,
stageRoot: f.params.stage.packageRoot,
launcherRoot: f.params.stage.layout.binDir,

View file

@ -24,6 +24,7 @@ import {
resolvePackageActivationJournalPath,
} from "./package-update-activation-journal.js";
import { createPackageActivationLifetimeFixture } from "./package-update-activation-lifetime.test-support.js";
import { packageActivationRuntimeForTest } from "./package-update-activation-runtime.test-support.js";
import {
readPackageActivationStatus,
readPackageActivationReceipt,
@ -38,7 +39,7 @@ const fixtures = createPackageActivationLifetimeFixture();
const { lifetime, setup, prepare, spawnChild, stopChild, killUncommittedWrite } = fixtures;
let root: string;
let assertDatabasePath: (path: string) => void;
let childGuardEnv: (env: NodeJS.ProcessEnv) => NodeJS.ProcessEnv;
let childGuardEnv: ReturnType<typeof setup>["childGuardEnv"];
beforeEach(() => {
({ root, assertDatabasePath, childGuardEnv } = setup());
});
@ -61,7 +62,7 @@ describe.skipIf(process.platform === "win32")(
let transaction: PackageUpdateTransaction | undefined;
const result = await swapStagedPackageInstall({
...f.params,
activation: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
activation: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
onTransaction: async (value) => {
transaction = value;
expect(value.databaseBackupRoot).toBeDefined();
@ -111,7 +112,7 @@ describe.skipIf(process.platform === "win32")(
const fence = await executor.enter(f.packageRoot);
return swapStagedPackageInstall({
...f.params,
activation: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
activation: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
});
});
expect(interrupted).toBe(true);
@ -204,7 +205,7 @@ describe.skipIf(process.platform === "win32")(
...f.params,
activation: {
fence,
nodeRunner: process.execPath,
runtime: packageActivationRuntimeForTest(),
onPrepared: () => {
prepared = true;
},
@ -754,9 +755,11 @@ describe.skipIf(process.platform === "win32")(
const before = openPackageActivationJournal(first.anchor).read();
const failure = new Error(cut);
const mkdir = fsp.mkdtemp.bind(fsp);
const write = fs.writeFileSync.bind(fs);
const openHelper = fs.openSync.bind(fs);
const sync = fs.fsyncSync.bind(fs);
const rename = fsp.rename.bind(fsp);
const open = nodeSqlite.openNodeSqliteDatabase;
let helperFd: number | undefined;
let fired = false;
vi.spyOn(fsp, "mkdtemp").mockImplementation(async (prefix, options) => {
const created = await mkdir(prefix, options);
@ -766,14 +769,20 @@ describe.skipIf(process.platform === "win32")(
}
return created;
});
vi.spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => {
write(file, data, options);
vi.spyOn(fs, "openSync").mockImplementation((file, flags, mode) => {
const fd = openHelper(file, flags, mode);
if (
!fired &&
cut === "staged-helper" &&
flags === "wx" &&
String(file).includes(".activation-anchor-") &&
String(file).endsWith(".recovery.mjs")
) {
helperFd = fd;
}
return fd;
});
vi.spyOn(fs, "fsyncSync").mockImplementation((fd) => {
sync(fd);
if (!fired && cut === "staged-helper" && fd === helperFd) {
fired = true;
throw failure;
}
@ -809,8 +818,13 @@ describe.skipIf(process.platform === "win32")(
});
await expect(prepare()).rejects.toBe(failure);
expect(fired).toBe(true);
if (cut === "staged-helper") {
expect(helperFd).toBeTypeOf("number");
expect(() => fs.fstatSync(helperFd!)).toThrow();
}
vi.mocked(fsp.mkdtemp).mockRestore();
vi.mocked(fs.writeFileSync).mockRestore();
vi.mocked(fs.openSync).mockRestore();
vi.mocked(fs.fsyncSync).mockRestore();
vi.mocked(fsp.rename).mockRestore();
vi.mocked(nodeSqlite.openNodeSqliteDatabase).mockRestore();
const after = openPackageActivationJournal(first.anchor).read();
@ -938,7 +952,11 @@ describe.skipIf(process.platform === "win32")(
packageRoot: f.packageRoot,
runCommand: createRootRunner(f.globalRoot),
timeoutMs: 5000,
getActivation: () => ({ fence, nodeRunner: process.execPath, onPrepared: () => {} }),
getActivation: () => ({
fence,
runtime: packageActivationRuntimeForTest(),
onPrepared: () => {},
}),
runStep: async ({ name, argv, cwd }) => {
if (name !== "package-install") {
throw new Error(`unexpected package-manager leaf ${name}`);

View file

@ -1,10 +1,33 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveExecutablePath } from "./executable-path.js";
import type { PackageActivationRecord } from "./package-update-activation-schema.js";
import type { PackageActivationRuntime } from "./package-update-swap-contract.js";
const PACKAGE_ACTIVATION_PREFIX = ".openclaw.package-activation-";
export function packageActivationRuntimeIdentity(file: string): string {
const stat = fs.lstatSync(file, { bigint: true });
// System runtimes may be root-owned even when the installation is user-owned.
if (!stat.isFile() || stat.ino === 0n) {
throw new Error("Package recovery requires a regular external runtime executable.");
}
return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
}
export function capturePackageActivationRuntime(
kind: PackageActivationRuntime["kind"],
executable: string,
): PackageActivationRuntime {
const resolved = resolveExecutablePath(executable, { useCache: false });
if (!resolved) {
throw new Error("The selected package recovery executable could not be resolved.");
}
const runtimePath = fs.realpathSync(resolved);
return { kind, path: runtimePath, identity: packageActivationRuntimeIdentity(runtimePath) };
}
export function resolvePackageActivationAnchor(installKey: string): string {
const key = createHash("sha256").update(installKey).digest("hex").slice(0, 24);
return path.join(path.dirname(installKey), `${PACKAGE_ACTIVATION_PREFIX}${key}`);

View file

@ -3,7 +3,9 @@ import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { quoteCliArg } from "../cli/quote-cli-arg.js";
import { captureUpdateCommandExecutorAuthority } from "../cli/update-cli/update-command-executor.js";
import { resolveBunRuntimeInfo } from "../daemon/runtime-paths.js";
import { requireDirectorySync, syncDirectory } from "./directory-durability.js";
import { retainMutationAuthority } from "./mutation-authority.js";
import {
@ -23,7 +25,13 @@ import {
resolvePackageActivationAnchor,
encodePackageActivationLauncher,
} from "./package-update-activation-journal.js";
import { packageActivationRuntimeIdentity } from "./package-update-activation-paths.js";
import { packageActivationRuntimeEntrypoint } from "./package-update-activation-runtime-assets.js";
import {
packageActivationSqliteEnvironment,
readPackageActivationSqliteLibrary,
sealPackageActivationSqliteLibrary,
} from "./package-update-activation-sqlite.js";
import {
createPackageIntegrityReader,
type PackageIntegrityFingerprint,
@ -57,9 +65,10 @@ function packageActivationRecoveryCommand(
anchor: string,
operationId: string,
helper = resolvePackageActivationHelper(anchor),
sqliteLibrary?: string,
): string {
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
return `${quote(node)} ${quote(helper)} --anchor ${quote(anchor)} --operation ${quote(operationId)}`;
const prefix = sqliteLibrary ? `${packageActivationSqliteEnvironment(sqliteLibrary)} ` : "";
return `${prefix}${quoteCliArg(node)} ${quoteCliArg(helper)} --anchor ${quoteCliArg(anchor)} --operation ${quoteCliArg(operationId)}`;
}
export function resolvePackageActivationRecoveryCommand(record: PackageActivationRecord): string {
@ -77,7 +86,17 @@ export function resolvePackageActivationRecoveryCommand(record: PackageActivatio
helper = custody.moved ? custody.destination : custody.source;
}
const node = record.descriptor.recoveryNodePath;
return packageActivationRecoveryCommand(node, anchor, record.descriptor.operationId, helper);
const bytes = fs.readFileSync(helper);
if (createHash("sha256").update(bytes).digest("hex") !== record.descriptor.helperDigest) {
throw new Error("Package recovery helper digest changed.");
}
return packageActivationRecoveryCommand(
node,
anchor,
record.descriptor.operationId,
helper,
readPackageActivationSqliteLibrary(bytes),
);
}
export async function preparePackageActivationJournal(
@ -96,21 +115,41 @@ export async function preparePackageActivationJournal(
if (fs.realpathSync(parent) !== parent || fs.realpathSync(params.binDir) !== params.binDir) {
throw new Error("Package publication recovery requires canonical installation parents.");
}
const node = fs.realpathSync(params.options.nodeRunner);
const runtime = params.options.runtime;
const node = fs.realpathSync(runtime.path);
const assertRuntime = () => {
if (node !== runtime.path || packageActivationRuntimeIdentity(node) !== runtime.identity) {
throw new Error("The selected package recovery executable changed after runtime preflight.");
}
};
assertRuntime();
for (const root of [params.liveRoot, params.stageRoot, anchor]) {
if (node === root || node.startsWith(`${root}${path.sep}`)) {
throw new Error("Recovery requires an external Node executable.");
}
}
const version = spawnSync(node, ["--version"], {
env: {},
encoding: "utf8",
timeout: 5000,
stdio: ["ignore", "pipe", "pipe"],
});
if (version.status !== 0 || !isSupportedNodeVersion(version.stdout.trim().replace(/^v/u, ""))) {
throw new Error("Recovery requires a supported external Node executable.");
let sqliteLibrary: string | undefined;
if (runtime.kind === "bun") {
const info = await resolveBunRuntimeInfo(node, undefined, runtime.env ?? process.env);
if (info.status !== "supported") {
throw new Error("Recovery requires a supported external Bun executable.", {
cause: info.status === "probe-failed" ? info.error : undefined,
});
}
sqliteLibrary = info.sqliteLibraryPath;
} else {
const version = spawnSync(node, ["--version"], {
env: {},
encoding: "utf8",
timeout: 5000,
stdio: ["ignore", "pipe", "pipe"],
});
if (version.status !== 0 || !isSupportedNodeVersion(version.stdout.trim().replace(/^v/u, ""))) {
throw new Error("Recovery requires a supported external Node executable.");
}
}
assertCurrent();
assertRuntime();
const reader = createPackageIntegrityReader();
const candidate = await reader.tree(params.stageRoot);
const launchers = [];
@ -168,8 +207,12 @@ export async function preparePackageActivationJournal(
sourceParentIdentity: packageActivationIdentity(path.dirname(entry.source), true),
}));
// Preflight the sealed helper before creating any blocking recovery artifact.
const helperBytes = readPackageActivationRuntime();
const helperBytes = sealPackageActivationSqliteLibrary(
readPackageActivationRuntime(),
sqliteLibrary,
);
assertCurrent();
assertRuntime();
// These objects remain inside the existing stage cleanup owner's prefix
// until the stable slot records their exact identities. A failed replacement
// cannot create blocking artifacts over the prior completion receipt.
@ -184,7 +227,13 @@ export async function preparePackageActivationJournal(
? path.join(stagedControl, "recovery.mjs")
: `${stagedAnchor}.recovery.mjs`;
assertCurrent();
fs.writeFileSync(stagedHelper, helperBytes, { flag: "wx", mode: 0o600, flush: true });
const helperFd = fs.openSync(stagedHelper, "wx", 0o600);
try {
fs.writeFileSync(helperFd, helperBytes);
fs.fsyncSync(helperFd);
} finally {
fs.closeSync(helperFd);
}
// The durable journal may refer to these staged objects immediately after
// its CAS. Persist their contents and names before handing cleanup custody off.
for (const directory of new Set([
@ -268,13 +317,19 @@ export async function preparePackageActivationJournal(
throw error;
}
}
const command = packageActivationRecoveryCommand(node, anchor, descriptor.operationId);
const command = packageActivationRecoveryCommand(
node,
anchor,
descriptor.operationId,
undefined,
sqliteLibrary,
);
assertCurrent();
// A replacement's bootstrap command is valid only while that recorded helper
// remains staged. Never advertise the stable name before its inode is present.
if (prior) {
params.options.onPrepared(
`${packageActivationRecoveryCommand(node, anchor, descriptor.operationId, stagedHelper)} status`,
`${packageActivationRecoveryCommand(node, anchor, descriptor.operationId, stagedHelper, sqliteLibrary)} status`,
);
}
await completePackageActivationCustody(anchor, journal, assertCurrent, () =>

View file

@ -18,7 +18,7 @@ afterEach(async () => {
});
it.skipIf(process.platform === "win32")(
"replays the pending receipt with the validated Node when PATH has no node",
"replays the pending receipt with the validated runtime when PATH has no node",
() =>
fixture.lifetime.run(async () => {
const { root, childGuardEnv } = fixture.setup();

View file

@ -0,0 +1,6 @@
import { capturePackageActivationRuntime } from "./package-update-activation-paths.js";
import type { PackageActivationRuntime } from "./package-update-swap-contract.js";
export function packageActivationRuntimeForTest(): PackageActivationRuntime {
return capturePackageActivationRuntime(process.versions.bun ? "bun" : "node", process.execPath);
}

View file

@ -9,20 +9,15 @@ import {
packageActivationIdentity,
resolvePackageActivationHelper,
} from "./package-update-activation-journal.js";
import { assertPackageActivationRecoveryRuntime } from "./package-update-activation-sqlite.js";
import {
readPackageActivationStatus,
runPackageActivationRecovery,
} from "./package-update-activation.js";
import { isSupportedNodeVersion } from "./runtime-guard.js";
try {
if (
process.platform === "win32" ||
!isSupportedNodeVersion(process.versions.node) ||
process.versions.bun
) {
throw new Error("Package publication recovery requires supported external Node on POSIX.");
}
const helper = fileURLToPath(import.meta.url);
await assertPackageActivationRecoveryRuntime(helper);
const anchor = process.argv[3];
const operationId = process.argv[5];
const action = process.argv[6];
@ -35,10 +30,9 @@ try {
(action !== "status" && action !== "repair" && action !== "retire")
) {
throw new Error(
"Usage: node recovery.mjs --anchor absolute-path --operation operation-id status|repair|retire",
"Usage: <node|bun> recovery.mjs --anchor absolute-path --operation operation-id status|repair|retire",
);
}
const helper = fileURLToPath(import.meta.url);
if (path.resolve(anchor) !== anchor) {
throw new Error("Package recovery anchor must be an absolute canonical path.");
}

View file

@ -0,0 +1,95 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { quoteCliArg } from "../cli/quote-cli-arg.js";
import * as runtimePaths from "../daemon/runtime-paths.js";
import { openPackageActivationJournal } from "./package-update-activation-journal.js";
import { createPackageActivationLifetimeFixture } from "./package-update-activation-lifetime.test-support.js";
import { capturePackageActivationRuntime } from "./package-update-activation-paths.js";
import {
assertPackageActivationRecoveryRuntime,
sealPackageActivationSqliteLibrary,
} from "./package-update-activation-sqlite.js";
import { readPackageActivationReceipt } from "./package-update-activation.js";
import * as runtimeGuard from "./runtime-guard.js";
const fixture = createPackageActivationLifetimeFixture();
afterEach(async () => {
try {
await fixture.lifetime.cleanup();
} finally {
vi.restoreAllMocks();
vi.unstubAllGlobals();
}
});
it
.skipIf(process.platform === "win32")
.each(["OPENCLAW_SQLITE_LIBRARY", "HOMEBREW_PREFIX"] as const)(
"retains admitted %s selection in the helper and clean-shell receipt command",
async (key) => {
const { root, childGuardEnv } = fixture.setup();
const library = path.join(root, "operator's custom $sqlite", "libsqlite3.dylib");
const env = { [key]: key === "OPENCLAW_SQLITE_LIBRARY" ? library : root };
const probe = vi
.spyOn(runtimePaths, "resolveBunRuntimeInfo")
.mockImplementation(async (_runtime, _exec, actualEnv) => ({
status: actualEnv?.[key] === env[key] ? "supported" : "unsupported",
version: "1.4.3",
sqliteVersion: "3.53.4",
sqliteLibraryPath: library,
sqliteProbe: { available: true, version: "3.53.4", text: true, blob: true, json: true },
nodeSharedSqlite: false,
}));
// The delivery helper exposes the environment actually seen by the new process.
fs.writeFileSync(
path.join(root, "sealed.mjs"),
"console.log(JSON.stringify({library:process.env.OPENCLAW_SQLITE_LIBRARY,args:process.argv.slice(2)}));\n",
);
const runtime = { ...capturePackageActivationRuntime("bun", process.execPath), env };
const prepared = await fixture.prepare(undefined, undefined, runtime);
expect(probe).toHaveBeenCalledWith(runtime.path, undefined, env);
const descriptor = openPackageActivationJournal(prepared.anchor).read().descriptor;
expect(descriptor.version).toBe(1);
expect(descriptor).not.toHaveProperty("sqliteLibraryPath");
expect(descriptor).not.toHaveProperty("env");
const command = readPackageActivationReceipt(prepared.packageRoot)?.recoveryCommand;
expect(command).toMatch(/^OPENCLAW_SQLITE_LIBRARY=/);
expect(command).toContain(`OPENCLAW_SQLITE_LIBRARY=${quoteCliArg(library)} `);
const result = spawnSync("/bin/sh", ["-c", command!], {
env: childGuardEnv({}),
encoding: "utf8",
timeout: 10_000,
});
expect(result.error, result.stderr).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
library,
args: ["--anchor", prepared.anchor, "--operation", prepared.operationId, "status"],
});
// The old empty-environment admission loses the supported selection before custody.
await expect(fixture.prepare(undefined, undefined, { ...runtime, env: {} })).rejects.toThrow(
"supported external Bun executable",
);
},
);
it("refuses unsafe recovery discovery with the exact retained library input", async () => {
const { root } = fixture.setup();
const library = path.join(root, "custom SQLite", "libsqlite3.dylib");
const helper = path.join(root, "sealed.mjs");
fs.writeFileSync(helper, sealPackageActivationSqliteLibrary(Buffer.from("// helper\n"), library));
vi.stubGlobal("process", {
...process,
platform: "darwin",
versions: { ...process.versions, bun: "1.4.3" },
env: {},
});
const probe = vi.spyOn(runtimeGuard, "isCurrentRuntimeSupported").mockResolvedValue(false);
await expect(assertPackageActivationRecoveryRuntime(helper)).rejects.toThrow(
`Retry with OPENCLAW_SQLITE_LIBRARY=${quoteCliArg(library)}.`,
);
expect(probe).toHaveBeenCalledOnce();
expect(process.env).toEqual({});
});

View file

@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";
import { quoteCliArg } from "../cli/quote-cli-arg.js";
import { isCurrentRuntimeSupported, isSupportedNodeVersion } from "./runtime-guard.js";
const SQLITE_LIBRARY_HEADER = "// openclaw-package-recovery-sqlite: ";
/** The existing helper digest binds this recovery hint without changing the v1 journal. */
export function sealPackageActivationSqliteLibrary(helper: Buffer, library?: string): Buffer {
return library
? Buffer.concat([Buffer.from(`${SQLITE_LIBRARY_HEADER}${JSON.stringify(library)}\n`), helper])
: helper;
}
export function readPackageActivationSqliteLibrary(helper: Buffer): string | undefined {
const end = helper.indexOf(10);
const firstLine = helper.subarray(0, end < 0 ? helper.length : end).toString("utf8");
if (!firstLine.startsWith(SQLITE_LIBRARY_HEADER)) {
return undefined;
}
const library: unknown = JSON.parse(firstLine.slice(SQLITE_LIBRARY_HEADER.length));
if (typeof library !== "string" || !path.isAbsolute(library)) {
throw new Error("Package recovery SQLite library must be an absolute path.");
}
return library;
}
export function packageActivationSqliteEnvironment(library: string): string {
return `OPENCLAW_SQLITE_LIBRARY=${quoteCliArg(library)}`;
}
export async function assertPackageActivationRecoveryRuntime(helper: string): Promise<void> {
if (
process.platform !== "win32" &&
(process.versions.bun
? await isCurrentRuntimeSupported()
: isSupportedNodeVersion(process.versions.node))
) {
return;
}
const library = readPackageActivationSqliteLibrary(fs.readFileSync(helper));
throw new Error(
"Package publication recovery requires supported external Node or Bun on POSIX." +
(library ? ` Retry with ${packageActivationSqliteEnvironment(library)}.` : ""),
);
}

View file

@ -6,6 +6,7 @@ import { DatabaseSync } from "node:sqlite";
import type { captureUpdateCommandExecutorAuthority } from "../cli/update-cli/update-command-executor.js";
import { encodePackageActivationLauncher } from "./package-update-activation-journal.js";
import type { PackageActivationRecord } from "./package-update-activation-journal.js";
import { packageActivationRuntimeForTest } from "./package-update-activation-runtime.test-support.js";
const [cut, root, encodedAuthority, encodedRecord] = process.argv.slice(2);
if (!cut || !root || !encodedAuthority) {
@ -140,7 +141,7 @@ await withUpdateCommandExecutor(
async (executor) => {
const fence = await executor.enter(liveRoot);
await preparePackageActivationJournal({
options: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
options: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
liveRoot,
stageRoot: fixture.params.stage.packageRoot,
launcherRoot: fixture.params.stage.layout.binDir,

View file

@ -40,7 +40,7 @@ function readPackageActivationContinuation(installKey: string) {
const released = readReleasedPackageActivationReceipt(installKey);
if (released) {
throw new Error(
`Package publication recovery is pending. With an external Node, run ${released.recoveryCommand}, then use that original helper to repair or retire; keep other package managers stopped.`,
`Package publication recovery is pending. With the recorded external runtime, run ${released.recoveryCommand}, then use that original helper to repair or retire; keep other package managers stopped.`,
);
}
assertPackageActivationLayout(anchor);
@ -66,7 +66,7 @@ function readPackageActivationContinuation(installKey: string) {
assertManagedUpdateLeaseDatabaseIdentity(record.descriptor.authority);
if (record.phase !== "publication-complete") {
throw new Error(
`Package publication is incomplete; its original continuation cannot run. With an external Node, run ${recoveryCommand(record)} status, then repair or retire; keep other package managers stopped.`,
`Package publication is incomplete; its original continuation cannot run. With the recorded external runtime, run ${recoveryCommand(record)} status, then repair or retire; keep other package managers stopped.`,
);
}
return record.descriptor.authority;
@ -89,7 +89,7 @@ export function assertNoPendingPackageActivation(
const anchor = resolvePackageActivationAnchor(installKey);
const record = openPackageActivationJournal(anchor).read();
throw new Error(
`Package publication recovery is pending. With an external Node, run ${recoveryCommand(record)} status, then repair or retire; keep other package managers stopped.`,
`Package publication recovery is pending. With the recorded external runtime, run ${recoveryCommand(record)} status, then repair or retire; keep other package managers stopped.`,
);
}
@ -101,24 +101,21 @@ export async function preparePackageActivation(
const options = { ...params.options, fence };
if (
process.platform === "win32" ||
process.versions.bun ||
params.installTarget.manager !== "npm" ||
params.installTarget.directNodeModulesRoot ||
!(await fsp.lstat(params.stageRoot)).isDirectory()
) {
return undefined;
}
const nodeRunner = resolveExecutablePath(options.nodeRunner, { useCache: false });
const nodeRunner = resolveExecutablePath(options.runtime.path, { useCache: false });
assertOriginal();
if (!nodeRunner) {
options.onUnavailable?.(
"Standalone package publication repair is unavailable: the selected Node executable could not be resolved.",
"Standalone package publication repair is unavailable: the selected runtime executable could not be resolved.",
);
return undefined;
}
// Probe and seal the same selected runtime before the probe clears its environment.
options.nodeRunner = fs.realpathSync(nodeRunner);
const capable = await supportsPostCoreExecutor(params.stageRoot, options.nodeRunner);
const capable = await supportsPostCoreExecutor(params.stageRoot, nodeRunner);
assertOriginal();
if (!capable) {
// Older targets keep their shipped update path, without a

View file

@ -18,6 +18,7 @@ import {
resolvePackageActivationJournalPath,
} from "./package-update-activation-journal.js";
import { createPackageActivationLifetimeFixture } from "./package-update-activation-lifetime.test-support.js";
import { packageActivationRuntimeForTest } from "./package-update-activation-runtime.test-support.js";
import { assertNoPendingPackageActivation } from "./package-update-activation.js";
import * as packageFilesystem from "./package-update-filesystem.js";
import { writePackageRoot } from "./package-update-steps.test-support.js";
@ -55,7 +56,7 @@ it.skipIf(process.platform === "win32").each(["owned", "replacement"] as const)(
let transaction: PackageUpdateTransaction | undefined;
const result = await swapStagedPackageInstall({
...f.params,
activation: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
activation: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
onTransaction: (issued) => {
transaction = issued;
},
@ -114,7 +115,7 @@ it.skipIf(process.platform === "win32").each(["owned", "replacement"] as const)(
let nextTransaction: PackageUpdateTransaction | undefined;
const next = await swapStagedPackageInstall({
...f.params,
activation: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
activation: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
onTransaction: (issued) => {
nextTransaction = issued;
},
@ -148,7 +149,7 @@ it.skipIf(process.platform === "win32")(
await expect(
swapStagedPackageInstall({
...f.params,
activation: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
activation: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
beforeActivate: async () => {
throw refusal;
},
@ -187,7 +188,11 @@ it.skipIf(process.platform === "win32")(
assertNoPendingPackageActivation(f.packageRoot);
const result = await swapStagedPackageInstall({
...f.params,
activation: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
activation: {
fence,
runtime: packageActivationRuntimeForTest(),
onPrepared: () => {},
},
beforeActivate: async () => {},
});
expect(result.status, result.step.stderrTail ?? undefined).toBe("committed");
@ -214,7 +219,7 @@ it.skipIf(process.platform === "win32")(
await expect(
swapStagedPackageInstall({
...f.params,
activation: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
activation: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
beforeActivate: async () => {
throw uncertainty;
},
@ -245,7 +250,7 @@ it.skipIf(process.platform === "win32")(
await expect(
swapStagedPackageInstall({
...f.params,
activation: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
activation: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
beforeActivate: async () => {
fs.renameSync(f.packageRoot, previous);
await writePackageRoot(f.packageRoot, "3.0.0");
@ -298,7 +303,7 @@ it.skipIf(process.platform === "win32")(
let transaction: PackageUpdateTransaction | undefined;
const result = await swapStagedPackageInstall({
...f.params,
activation: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
activation: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
onTransaction: (issued) => {
transaction = issued;
},

View file

@ -12,6 +12,7 @@ import {
resolvePackageActivationControl,
} from "./package-update-activation-journal.js";
import { createPackageActivationLifetimeFixture } from "./package-update-activation-lifetime.test-support.js";
import { packageActivationRuntimeForTest } from "./package-update-activation-runtime.test-support.js";
import {
preparePackageActivation,
runPackageActivationRecovery,
@ -84,7 +85,7 @@ it.skipIf(process.platform === "win32").each([
const fence = await executor.enter(f.packageRoot);
const reader = integrity.createPackageIntegrityReader();
const prepared = await preparePackageActivation({
options: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
options: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
installTarget: f.params.installTarget,
liveRoot: f.packageRoot,
stageRoot: f.params.stage.packageRoot,
@ -164,7 +165,7 @@ it.skipIf(process.platform === "win32").each(["anchor", "installation", "launche
let transaction: PackageUpdateTransaction | undefined;
const result = await swapStagedPackageInstall({
...f.params,
activation: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
activation: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
onTransaction: (issued) => {
transaction = issued;
},
@ -279,7 +280,7 @@ it.skipIf(process.platform === "win32").each([
const fence = await executor.enter(f.packageRoot);
const reader = integrity.createPackageIntegrityReader();
const prepared = await preparePackageActivation({
options: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
options: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
installTarget: f.params.installTarget,
liveRoot: f.packageRoot,
stageRoot: f.params.stage.packageRoot,
@ -385,7 +386,7 @@ it.skipIf(process.platform === "win32")(
};
});
preparing = preparePackageActivation({
options: { fence, nodeRunner: process.execPath, onPrepared },
options: { fence, runtime: packageActivationRuntimeForTest(), onPrepared },
installTarget: f.params.installTarget,
liveRoot: f.packageRoot,
stageRoot: f.params.stage.packageRoot,

View file

@ -6,9 +6,17 @@ import type { NpmGlobalPrefixLayout } from "./update-npm-prefix.js";
import type { UpdateRecoveryFence } from "./update-run-recovery-types.js";
import type { UpdateStepResult } from "./update-step-result.js";
export type PackageActivationRuntime = {
kind: "node" | "bun";
path: string;
identity: string;
/** Preflight snapshot filtered by the daemon runtime probe owner. */
env?: NodeJS.ProcessEnv;
};
export type PackageActivationOptions = {
fence: UpdateRecoveryFence;
nodeRunner: string;
runtime: PackageActivationRuntime;
onPrepared: (command: string) => void;
onUnavailable?: (message: string) => void;
};

View file

@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withUpdateCommandExecutor } from "../cli/update-cli/update-command-executor.js";
import { withTestDir } from "../test-helpers/temp-dir.js";
import { createPackageActivationLifetimeFixture } from "./package-update-activation-lifetime.test-support.js";
import { packageActivationRuntimeForTest } from "./package-update-activation-runtime.test-support.js";
import { swapStagedPackageInstall, type PackageUpdateTransaction } from "./package-update-swap.js";
import { createPackageSwapFixture } from "./package-update-swap.test-support.js";
import { prepareUpdateFailureReport } from "./update-failure-report-prepare.js";
@ -126,7 +127,7 @@ describe.skipIf(process.platform === "win32")("managed publication drift facts",
let transaction: PackageUpdateTransaction | undefined;
const result = await swapStagedPackageInstall({
...f.params,
activation: { fence, nodeRunner: process.execPath, onPrepared: () => {} },
activation: { fence, runtime: packageActivationRuntimeForTest(), onPrepared: () => {} },
onTransaction: (value) => {
transaction = value;
},

View file

@ -644,17 +644,17 @@ describe("openclaw test instance", () => {
}
});
it.each(["held-unrelated", "late-unrelated"])(
it.for(["held-unrelated", "late-unrelated"])(
"preserves the refusal when reacquiring the same port fails (%s)",
async (action) => {
async (action, { signal }) => {
const control = await createGatewayControl();
const { instance } = await createFakeGateway(action, 1_000, 1_500, control);
const { instance } = await createFakeGateway(action, 1_000, 1_500, control, { signal });
const exited = createDeferred();
control.observers.onLaunch = () => {
instance.child?.once("exit", () => exited.resolve());
};
const competitor = net.createServer((socket) => socket.destroy());
const startup = trackOperation(instance.startGateway());
const startup = startGatewayForPortLifecycle(instance, signal);
const outcome = startup.catch((error: unknown) => error);
try {
await Promise.race([control.reached, startup]);

View file

@ -12,6 +12,7 @@ import {
} from "../../src/infra/package-update-activation-journal.js";
import { preparePackageActivationJournal } from "../../src/infra/package-update-activation-prepare.js";
import { packageActivationRuntimeEntrypoint } from "../../src/infra/package-update-activation-runtime-assets.js";
import { packageActivationRuntimeForTest } from "../../src/infra/package-update-activation-runtime.test-support.js";
import { createPackageIntegrityReader } from "../../src/infra/package-update-integrity.js";
import { createPackageSwapFixture } from "../../src/infra/package-update-swap.test-support.js";
import { resolveRuntimeWorkerUrl } from "../../src/infra/runtime-worker-url.js";
@ -129,7 +130,7 @@ it.each(
let preparedPackage: Awaited<ReturnType<typeof preparePackageActivationJournal>> | undefined;
let prepareNext: (() => Promise<NonNullable<typeof preparedPackage>>) | undefined;
const runCommand = (command: string, action: string) =>
spawnSync("/bin/sh", ["-c", `exec ${command.replace(/ status$/u, ` ${action}`)}`], {
spawnSync("/bin/sh", ["-c", command.replace(/ status$/u, ` ${action}`)], {
encoding: "utf8",
timeout: 30_000,
killSignal: "SIGKILL",
@ -156,8 +157,13 @@ it.each(
expect(modules.includes(path.resolve(module)), module).toBe(false);
}
}
vi.mocked(resolveRuntimeWorkerUrl).mockReturnValue(
pathToFileURL(path.join(outDir, runtimeEntry)),
const runtimeWorker = await vi.importActual<
typeof import("../../src/infra/runtime-worker-url.js")
>("../../src/infra/runtime-worker-url.js");
vi.mocked(resolveRuntimeWorkerUrl).mockImplementation((entry) =>
entry.distWorkerPath === runtimeEntry
? pathToFileURL(path.join(outDir, runtimeEntry))
: runtimeWorker.resolveRuntimeWorkerUrl(entry),
);
let entry: string;
if (kind === "managed") {
@ -189,7 +195,7 @@ it.each(
preparePackageActivationJournal({
options: {
fence: await executor.enter(fixture.packageRoot),
nodeRunner: process.execPath,
runtime: packageActivationRuntimeForTest(),
onPrepared: (command) => {
const observed = runCommand(command, "status");
expect(observed.error).toBeUndefined();