mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
c4f5599a74
commit
a4a78b738e
48 changed files with 1147 additions and 432 deletions
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -614,7 +614,7 @@ export async function executeMutableUpdate(
|
|||
beforeActivate,
|
||||
...createPackageUpdateActivationOptions({
|
||||
run: opts.run,
|
||||
nodeRunner: params.packageUpdateNodeRunner,
|
||||
runtime: params.packageActivationRuntime,
|
||||
assertCurrent: assertExecutionCurrent,
|
||||
}),
|
||||
managedServiceEnv: preManagedServiceStop?.serviceEnv,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
346
src/cli/update-cli/update-command-runtime-preflight.ts
Normal file
346
src/cli/update-cli/update-command-runtime-preflight.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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, () =>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
95
src/infra/package-update-activation-sqlite.test.ts
Normal file
95
src/infra/package-update-activation-sqlite.test.ts
Normal 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({});
|
||||
});
|
||||
46
src/infra/package-update-activation-sqlite.ts
Normal file
46
src/infra/package-update-activation-sqlite.ts
Normal 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)}.` : ""),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue