feat: automatically update idle headless nodes (#151545)

* feat(node): update headless runtimes automatically when idle

Stage private packaged node runtimes and activate only after all owned work
and cleanup settle. Preserve pairing and launch options, keep automatic
activations at least 12 hours apart, and leave shared-state migration and
Gateway installation ownership with the normal updater.

Document defaults, opt-outs, runtime version discovery, and recovery.
Support recoverable Windows selector replacement across service restarts.

Refs #151462

* fix(node): complete auto-update integration and settings defaults

Capture the launcher in compiled test runtimes and trusted wrapper inventories, register the package update proof, and use the canonical Kysely read path. Preserve the inherited enabled state in the config UI and align caller tests with the node shutdown lifecycle.

* fix(node): preserve retained plugin work during automatic updates

Require an explicit idle result from plugin commands, preserve older-plugin work, and connect bundled lifetime owners to automatic node updates. Join canceled inference and failed terminal cleanup before command settlement; keep historical state repair with Doctor. Document compatibility and extend installed-package proof for retained legacy plugin work.

* test(browser): align idle-work fixtures with runtime exports

* test(browser): extract proxy request fixtures

* test(node): retain idle assertions across native cleanup
This commit is contained in:
Peter Steinberger 2026-09-18 06:20:27 -07:00 • committed by GitHub
parent cfec08480c
commit 3c4c111b0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
175 changed files with 8752 additions and 1425 deletions

View file

@ -76,6 +76,7 @@ COPY node-version.mjs ./
COPY node-sqlite.mjs ./
COPY node-runtime-update.mjs ./
COPY node-runtime-recovery.mjs ./
COPY node-host-launcher.mjs ./
COPY openclaw.mjs ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
@ -279,6 +280,7 @@ COPY --from=runtime-assets --chown=node:node /app/node-version.mjs .
COPY --from=runtime-assets --chown=node:node /app/node-sqlite.mjs .
COPY --from=runtime-assets --chown=node:node /app/node-runtime-update.mjs .
COPY --from=runtime-assets --chown=node:node /app/node-runtime-recovery.mjs .
COPY --from=runtime-assets --chown=node:node /app/node-host-launcher.mjs .
COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs .
COPY --from=runtime-assets --chown=node:node /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} ./${OPENCLAW_BUNDLED_PLUGIN_DIR}
COPY --from=runtime-assets --chown=node:node /app/skills ./skills

View file

@ -84,6 +84,8 @@ const repositoryScriptEntries = [
"scripts/e2e/lib/fleet-cache/prepare-podman-storage.mjs!",
"scripts/e2e/lib/fleet-cache/probe-podman-cell.mjs!",
"scripts/e2e/lib/fleet-cache/runtime-preflight.mjs!",
// test:e2e:node-auto-update runs the installed-package proof against a frozen tarball.
"scripts/e2e/lib/node-auto-update/scenario.mjs!",
"scripts/e2e/lib/npm-telegram-live/prepare-package.mts!",
"scripts/e2e/lib/onboard/assert-config.mjs!",
"scripts/e2e/lib/onboard/write-config.mjs!",

View file

@ -1,5 +1,5 @@
{
"core": 2444,
"core": 2446,
"channel": 3740,
"plugin": 4215
}

View file

@ -1,4 +1,4 @@
e5439a58c999507588ef9150bd94b7019d09ab52eec604c8412e92e07f0a4f1f config-baseline.json
58ecbfff559bdeb12c9c6d3459ccb009106e13d736383319074c84a7675df425 config-baseline.core.json
f24b2ae3976bc118d622686fcab79756dcb728420ecc9fb4be5a51122ad6c095 config-baseline.json
28a074cb16e4b576174363f1b34263d7b82cfc28911a994970e30edded5f3c46 config-baseline.core.json
51c84b118b136dfff84993e3cee0ff76ee06b614842b125d0fde110635fc10b8 config-baseline.channel.json
77b840d74238b0d797785d7575fd155629a2aa27213f33a3bd9eb6fa22537aed config-baseline.plugin.json

View file

@ -98,6 +98,7 @@ Options:
- `--tls-fingerprint <sha256>`: Expected TLS certificate fingerprint (sha256)
- `--node-id <id>`: Override the client instance ID stored in shared SQLite state (does not reset pairing)
- `--display-name <name>`: Override the node display name
- `--session-host`: Host worker sessions for this foreground process without changing the saved worker-hosting preference
- `--commands <ids>`: Persist an exact comma-separated command allowlist (repeatable); advertise only available matches and their required capabilities. Disables computer use, skills, plugin tools, MCP servers, and worker hosting. Omitting the flag preserves the saved list.
- `--all-commands`: Advertise the full default command surface and forget any saved `--commands` allowlist. Cannot be combined with `--commands`.
- `--share-installed-apps`: On macOS, advertise installed applications through `device.apps`
@ -226,6 +227,29 @@ logs the close detail and exits non-zero so launchd/systemd/Task Scheduler can
restart it with fresh config and credentials. Pairing-required pauses stay in
the foreground flow so the pending request can be approved.
## Automatic updates
Long-running packaged `node run` processes and installed node services check
hourly for updates by default. A new version is prepared in a separate node
runtime, leaving the global CLI package and a co-located Gateway in place.
Activation waits until commands, terminals, workers, plugin work, pending output,
and cleanup are idle. The node then restarts with its existing identity, pairing,
settings, and launch options. Automatic activations are at least 12 hours apart;
there is no deadline that interrupts busy work.
Disable this on the node machine with:
```bash
openclaw config set nodeHost.autoUpdate.enabled false
```
`update.checkOnStart: false` and `OPENCLAW_NO_AUTO_UPDATE=1` also disable node
automatic updates. The Gateway's `update.auto.enabled` preference is separate.
Source checkouts, native app nodes, private workers, `dev`, and
`extended-stable` installs do not auto-apply. Releases requiring database
migrations defer to the normal update workflow. See
[Headless node updates](/install/updating/automatic-updates#headless-node-updates).
## Pairing
The first connection creates a pending device pairing request (`role: node`) on the Gateway.

View file

@ -132,6 +132,11 @@ Setup: `openclaw dns setup --apply`.
- `checkOnStart`: check for updates through `https://telemetry.openclaw.ai/api/latest-version` when the Gateway starts and at most once every 24 hours afterward (default: `true`). The default request shares only the OpenClaw version and platform information in its `User-Agent`; anonymous feature statistics are included only when `telemetry.enabled` is `true`. Setting this to `false`, or setting `OPENCLAW_NO_AUTO_UPDATE=1`, prevents all automatic update requests, feature statistics, and update notices, even when `auto.enabled` is `true`. Stored extended-stable selections use the same read-only hint and 24-hour hint schedule.
- `auto.enabled`: enable background auto-update campaigns for stable and beta package installs and dev git installs when `checkOnStart` is also enabled (default: `false`). Extended-stable never applies automatically.
Headless nodes have a separate default-on `nodeHost.autoUpdate.enabled` policy
with hourly checks and idle-only activation. `update.checkOnStart: false` and
`OPENCLAW_NO_AUTO_UPDATE=1` disable that policy too. See
[Headless node updates](/install/updating/automatic-updates#headless-node-updates).
---
## ACP

View file

@ -19,6 +19,7 @@ keys and network-touching suites, see [Testing live](/help/testing-live).
- [What we protect](#what-we-protect) - the guarantees these lanes exist to defend.
- [Local proof during development](#local-proof-during-development) - the commands to run while you iterate.
- [Headless node auto-update proof](#headless-node-auto-update-proof) - installed-node activation and shared-Gateway safeguards.
- [Docker lanes](#docker-lanes) - lane reference: what each lane runs and when.
- [Package Acceptance](#package-acceptance) - lane reference: the acceptance matrix and its gates.
- [Release default](#release-default) - which lanes a release candidate must clear.
@ -87,6 +88,44 @@ Release npm preflight uses the same readable diff against the prior published
dist-tag and prints the 8-character acknowledgement digest required when that
release changes the Plugin SDK API.
## Headless node auto-update proof
`pnpm test:e2e:node-auto-update <built-openclaw.tgz> [new-artifact-directory]`
runs a real installed-package scenario on Linux. Run it in a task-owned Testbox
or Crabbox with Node, npm, and registry access. It needs no provider credentials
and is opt-in; the default `pnpm test:e2e` aggregate does not run it.
Build and pack the candidate on the test host, then pass that exact tarball:
```bash
pnpm build
node scripts/package-openclaw-for-docker.mjs --skip-build \
--output-dir /tmp/openclaw-node-update-package \
--output-name openclaw-node-update.tgz
pnpm test:e2e:node-auto-update \
/tmp/openclaw-node-update-package/openclaw-node-update.tgz \
/tmp/openclaw-node-update-proof
```
The proof starts isolated Gateway, paired-node, supervisor, and fixture-registry
processes. It verifies busy-work deferral, idle activation, preserved pairing
and launch options, activation cooldown, all three public opt-outs, and retention
of the working node when a candidate is malformed. A same-state Gateway/node
case confirms that the Gateway process, configuration, and global installation
stay unchanged while the node activates its private runtime. A separate cell
runs the published `openclaw@2026.9.4` updater against the candidate.
A legacy-plugin case returns from its command while a child keeps running,
then proves that a missing idle-work callback blocks activation both during
that work and after the child finishes.
Use a new artifact directory outside the source checkout for every run, or omit
it to create a fresh temporary directory. The scenario retains `observations.json`
and per-process logs there and stops its child processes on completion or failure.
Collect the proof before stopping the remote lease. This scenario proves Linux
behavior; it does not establish Windows or macOS activation coverage. See
[Node auto-updates](/cli/node#automatic-updates) for the operator contract.
## Docker lanes
The Docker lanes are the product-level proof. They install or update a real

View file

@ -2,16 +2,99 @@
summary: "The auto-updater, per-channel automatic behavior, and how update campaigns apply and report an update"
read_when:
- You want unattended updates on a managed Gateway service
- You want to control automatic updates of a headless node host
- You need to know when an automatic update applies and how to postpone it
- You are turning update checks or automatic updates off
title: "Automatic updates"
---
Enabling the auto-updater, what each channel does automatically, and how update campaigns run. Part of the [Updating](/install/updating) guide.
Automatic updates for headless nodes and managed Gateway services, including
when a new version can restart each process. Part of the [Updating](/install/updating) guide.
## Headless node updates
Long-running packaged headless nodes update automatically by default. This
includes foreground `openclaw node run` and installed node services. After its
first authenticated connection, the node checks for a newer release and repeats
the check hourly. It prepares a separate copy of its program
files and dependencies, and activates it only when the node is idle. The global
CLI package and any Gateway using that package are not replaced by a node update.
Idle means the node no longer owns active commands, background execution,
terminal sessions, worker sessions, plugin work, pending output, or cleanup.
The node stops accepting new commands before activation so work cannot start
between the idle check and restart. Busy work can postpone the update
indefinitely. Automatic activations are at least 12 hours apart; this limit never
forces a busy node to restart.
Plugins must explicitly report that their retained work is idle. An older plugin
without the idle-work callback postpones automatic activation, even after its
last command returns. Update that plugin to a compatible version, or finish its
work and use `openclaw update` followed by a node restart.
The replacement process reconnects with the same identity, pairing, settings,
and launch options. The separate runtime changes program files, not the node's
state directory. Activation requires the candidate to reconnect successfully;
if startup fails, the launcher falls back to the previous runtime.
If the node shares its state directory with a Gateway installed before node
automatic updates were introduced, update that Gateway once first. Older
Gateways reject a local node whose version differs from their own. Updated
Gateways accept a newer local node when its protocol is compatible, so later
node updates can leave the Gateway running at its existing version.
Automatic node updates require matching state and agent schema versions and an
exact match for the candidate's required database shapes. Matching numeric
versions alone is insufficient. A release that needs a migration or startup
repair is deferred with instructions to use the normal
[update workflow](/install/updating). The managed replacement consumes existing
state without running shared Doctor or startup repairs. The normal update
workflow owns those repairs, migration, and rollback, including coordination
with a Gateway sharing the state directory.
Native app nodes and private worker processes continue to use their existing
update owners. Source checkouts and `dev` installs also remain owner-managed.
Nodes follow `update.channel` for `stable` and `beta` releases; an
`extended-stable` pin never applies an update automatically.
To opt out, set this on the node machine:
```json5
{
nodeHost: {
autoUpdate: {
enabled: false,
},
},
}
```
The shared opt-outs `update.checkOnStart: false` and
`OPENCLAW_NO_AUTO_UPDATE=1` also disable node update checks and automatic
activation. `update.auto.enabled` controls Gateway updates; it does not control
this node-specific default.
To check the connected node's runtime version, run `openclaw nodes status --json`
from a CLI connected to its Gateway and inspect `nodes[].version`.
`openclaw --version` reports that CLI's installed version, which can differ
after a node update.
Look in the foreground node's stderr or its service logs for update preparation,
busy deferral, activation, and fallback messages. If an update is deferred for
schema migration or repair, run `openclaw update` on the node machine. Then
restart its service with `openclaw node restart`, or relaunch the foreground
`openclaw node run` command.
If the launcher reports a stale `node-runtime/activation.lock`, confirm that no
node update is running before removing the exact lock path from the message.
Then restart the node. Preserve the runtime selector and its recovery backup;
the launcher uses them to recover the previously selected runtime after an
interrupted activation.
## Auto-updater
Off by default. Enable it in `~/.openclaw/openclaw.json`:
Gateway automatic updates are off by default. Enable them in
`~/.openclaw/openclaw.json`:
```json5
{

View file

@ -76,6 +76,8 @@ them before using the link if that access is too broad. See
`node run` also accepts `--pair`, `--context-path` (Gateway WS context path), `--tls`, `--tls-fingerprint <sha256>`, and `--node-id` (override the legacy client instance ID; this does not reset pairing). On macOS, pass `--share-installed-apps` to advertise `device.apps`; sharing is off by default. Use `--no-share-installed-apps` to disable a previously saved opt-in.
Pass `--session-host` to enable worker hosting for this foreground process without changing the saved preference. Automatic restarts preserve this choice.
### Remote gateway via SSH tunnel (loopback bind)
If the Gateway binds to loopback (`gateway.bind=loopback`, default in local mode), remote node hosts cannot connect directly. Create an SSH tunnel and point the node host at the local end of the tunnel.
@ -142,6 +144,23 @@ openclaw node restart
`node install` also accepts `--context-path`, `--tls`, `--tls-fingerprint`, `--node-id` (legacy client instance ID only), `--share-installed-apps` / `--no-share-installed-apps`, `--runtime <node|bun>` (default: `node`), and `--force` to reinstall. Bun requires version 1.4+ with WAL-reset-safe `node:sqlite` and is an explicit opt-in; Node remains recommended. `node status`, `node stop`, and `node uninstall` are also available.
### Automatic node updates
Packaged headless nodes check for updates hourly by default, in both foreground
and service mode. They prepare a separate runtime, wait until all node work is
idle, then restart and reconnect with the same identity, pairing, and launch
options. A node update does not replace the global CLI package or a co-located
Gateway. Automatic activations are at least 12 hours apart, and busy work can
defer an update indefinitely.
Set `nodeHost.autoUpdate.enabled: false` on the node to opt out. The shared
`update.checkOnStart: false` and `OPENCLAW_NO_AUTO_UPDATE=1` opt-outs also apply.
Source checkouts, native app nodes, private workers, `dev`, and
`extended-stable` installs do not auto-apply. Releases requiring database
migrations defer to the normal update workflow. See
[Headless node updates](/install/updating/automatic-updates#headless-node-updates)
for the idle-work rules and configuration.
### Pair + name
On the Gateway host, approve the device request:

View file

@ -76,6 +76,19 @@ logger under subsystem `ai.openclaw`, category `node-host-worker`; see
[macOS logging](/platforms/mac/logging) for capture options. After fixing the cause,
restart the node host. Explicitly disabled hosting produces no such diagnostic.
## Node runtime version differs from the CLI
A packaged headless node can run a newer private runtime than the globally
installed CLI. Use `openclaw nodes status --json` to check the connected node's
version; `openclaw --version` reports the CLI version. For delayed updates,
opt-outs, fallback, and migration or repair deferrals, see
[Headless node updates](/install/updating/automatic-updates#headless-node-updates).
If an apparently idle node keeps deferring an update, check its installed
plugins. A plugin without an idle-work callback cannot confirm that its
background work has finished, so the node keeps running. Update the plugin, or
finish its work before updating and restarting the node manually.
## Foreground requirements
`camera.*` and `screen.*` are foreground-only on iOS/Android nodes.

View file

@ -110,3 +110,19 @@ underscores, or hyphens, and stay within 64 characters. MCP-backed node tools
can set `agentTool.mcp` metadata so catalog and tool-search surfaces can show
the remote MCP server/tool identity, but execution still goes through the
advertised node command.
Node-host commands must provide `hasActiveWork(): boolean` to allow automatic node
updates. Read already-owned state synchronously and return `false` only when
background processes, retained streams, and their cleanup have settled. Commands
whose work finishes within `handle(...)` can declare `hasActiveWork: () => false`;
the node host separately tracks in-flight invocations.
`createSessionCatalogNodeHostBindings` forwards its `hasActiveWork` option to
each generated command.
An absent hook, a thrown error, or any result other than `false` defers activation.
This preserves work owned by older plugins that predate the idle hook. The query
also runs for unavailable commands because availability can change while work is
still retained. Keep teardown in the command's existing lifecycle, such as
`onDisconnect`, and report idle only after that work settles. `onDisconnect` alone
does not establish idleness. Update older plugins to add the hook or use
`openclaw update` and an operator-controlled node restart.

View file

@ -51,6 +51,7 @@ function createPiSessionNodeHostBindings() {
terminalCommand: PI_TERMINAL_RESUME_COMMAND,
sessionIdPattern: PI_SESSION_ID_PATTERN,
executable: "pi",
hasActiveWork: () => false,
args: (threadId) => ["--session", threadId],
listAvailable: storeAvailable,
terminalAvailable: ({ config, env }) =>

View file

@ -93,6 +93,7 @@ function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[]
command: CLAUDE_SESSIONS_LIST_COMMAND,
cap: CLAUDE_SESSIONS_CAPABILITY,
dangerous: false,
hasActiveWork: () => false,
isAvailable: ({ env }) => claudeProjectsAvailable(env),
handle: async (paramsJSON) =>
await (await loadClaudeSessionNodeCommands()).listClaudeSessions(paramsJSON),
@ -101,6 +102,7 @@ function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[]
command: CLAUDE_SESSION_READ_COMMAND,
cap: CLAUDE_SESSIONS_CAPABILITY,
dangerous: false,
hasActiveWork: () => false,
isAvailable: ({ env }) => claudeProjectsAvailable(env),
handle: async (paramsJSON) =>
await (await loadClaudeSessionNodeCommands()).readClaudeSession(paramsJSON),
@ -110,6 +112,7 @@ function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[]
cap: CLAUDE_SESSIONS_CAPABILITY,
dangerous: false,
duplex: true,
hasActiveWork: () => false,
isAvailable: ({ env }) =>
claudeProjectsAvailable(env) && Boolean(resolveClaudeTerminalExecutable(env)),
handle: async (paramsJSON, io) =>
@ -120,6 +123,7 @@ function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[]
cap: CLAUDE_SESSIONS_CAPABILITY,
dangerous: false,
duplex: true,
hasActiveWork: () => false,
isAvailable: ({ env }) => Boolean(resolveClaudeTerminalExecutable(env)),
handle: async (paramsJSON, io) =>
await (await loadClaudeSessionNodeCommands()).startClaudeSession(paramsJSON, io),

View file

@ -2,6 +2,7 @@ import { expect, it, vi } from "vitest";
const cleanupMocks = vi.hoisted(() => ({
ensureBrowserProxyUploadCleanup: vi.fn(async () => undefined),
hasBrowserProxyUploadWork: vi.fn(() => false),
}));
vi.mock("./register.runtime.js", () => {
@ -10,6 +11,7 @@ vi.mock("./register.runtime.js", () => {
vi.mock("./src/browser-proxy-upload-cleanup.runtime.js", () => ({
ensureBrowserProxyUploadCleanup: cleanupMocks.ensureBrowserProxyUploadCleanup,
hasBrowserProxyUploadWork: cleanupMocks.hasBrowserProxyUploadWork,
}));
const { browserPluginNodeHostCommands } = await import("./plugin-registration.js");
@ -19,9 +21,12 @@ it("starts node-host upload cleanup without loading the broad browser runtime",
(command) => command.command === "browser.proxy.upload.v1",
);
expect(uploadCommand?.hasActiveWork?.()).toBe(false);
uploadCommand?.watchAvailability?.({ config: {}, env: {} }, vi.fn());
expect(uploadCommand?.hasActiveWork?.()).toBe(true);
await vi.waitFor(() => {
expect(cleanupMocks.ensureBrowserProxyUploadCleanup).toHaveBeenCalledOnce();
});
expect(uploadCommand?.hasActiveWork?.()).toBe(false);
});

View file

@ -6,12 +6,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerBrowserPlugin } from "./plugin-registration.js";
const runtimeMocks = vi.hoisted(() => ({
hasBrowserNodeHostWork: vi.fn(() => false),
handleGatewayExtensionUpgrade: vi.fn(async () => true),
handleBrowserScreencastUpgrade: vi.fn(async () => true),
stopBrowserControlService: vi.fn(async () => undefined),
}));
vi.mock("./register.runtime.js", () => ({
hasBrowserNodeHostWork: runtimeMocks.hasBrowserNodeHostWork,
stopBrowserControlService: runtimeMocks.stopBrowserControlService,
}));

View file

@ -5,7 +5,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime";
import type {
AnyAgentTool,
OpenClawPluginApi,
@ -49,12 +49,22 @@ import {
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
const logger = createSubsystemLogger("browser");
let hasBrowserNodeHostWork: (() => boolean) | undefined;
let hasBrowserProxyUploadWork: (() => boolean) | undefined;
const loadBrowserRegistrationRuntimeModule = createLazyRuntimeModule(
const loadBrowserRegistrationRuntimeModule = createLazyRuntimeSurface(
() => import("./register.runtime.js"),
(runtime) => {
hasBrowserNodeHostWork = runtime.hasBrowserNodeHostWork;
return runtime;
},
);
const loadBrowserUploadCleanupRuntimeModule = createLazyRuntimeModule(
const loadBrowserUploadCleanupRuntimeModule = createLazyRuntimeSurface(
() => import("./src/browser-proxy-upload-cleanup.runtime.js"),
(runtime) => {
hasBrowserProxyUploadWork = runtime.hasBrowserProxyUploadWork;
return runtime;
},
);
function deriveChatTypeFromSessionKey(
@ -218,6 +228,11 @@ function createBrowserProxyNodeHostCommand(command: string): OpenClawPluginNodeH
return {
command,
cap: "browser",
hasActiveWork: () =>
(loadBrowserRegistrationRuntimeModule.peek() !== undefined &&
hasBrowserNodeHostWork?.() !== false) ||
(loadBrowserUploadCleanupRuntimeModule.peek() !== undefined &&
hasBrowserProxyUploadWork?.() !== false),
isAvailable: ({ config }) =>
config.browser?.enabled !== false && config.nodeHost?.browserProxy?.enabled !== false,
handle: async (paramsJSON, _io, context) => {

View file

@ -4,7 +4,7 @@
*/
export { createBrowserTool } from "./src/browser-tool.js";
export { handleBrowserGatewayRequest } from "./src/gateway/browser-request.js";
export { runBrowserProxyCommand } from "./src/node-host/invoke-browser.js";
export { hasBrowserNodeHostWork, runBrowserProxyCommand } from "./src/node-host/invoke-browser.js";
export { createBrowserPluginService } from "./src/plugin-service.js";
export { stopBrowserControlService } from "./src/control-service.js";
export { collectBrowserSecurityAuditFindings } from "./src/security-audit.js";

View file

@ -24,6 +24,7 @@ vi.mock("./browser/runtime-lifecycle.js", () => ({
const {
ensureBrowserControlRuntime,
getBrowserControlState,
hasBrowserControlWork,
stopBrowserControlRuntime,
withBrowserControlStart,
} = await import("./browser-control-state.js");
@ -79,6 +80,7 @@ describe("browser control lifecycle", () => {
it("retains a failed stop owner for an exact retry", async () => {
await start("service");
expect(hasBrowserControlWork()).toBe(true);
runtimeMocks.stopBrowserRuntime.mockImplementationOnce(async (params) => {
markBrowserRuntimeStopping(params.current);
throw new Error("cleanup failed");
@ -86,8 +88,10 @@ describe("browser control lifecycle", () => {
await expect(stop("service")).rejects.toThrow("cleanup failed");
expect(getBrowserControlState()).toBeNull();
expect(hasBrowserControlWork()).toBe(true);
await expect(stop("service")).resolves.toBeTruthy();
expect(hasBrowserControlWork()).toBe(false);
await expect(start("service")).resolves.toBeTruthy();
await stop("service");
});
@ -118,6 +122,7 @@ describe("browser control lifecycle", () => {
});
it("orders a queued stop after an in-progress cold start", async () => {
expect(hasBrowserControlWork()).toBe(false);
let releaseStart!: () => void;
const startGate = new Promise<void>((resolve) => {
releaseStart = resolve;
@ -132,6 +137,8 @@ describe("browser control lifecycle", () => {
onWarn,
});
});
expect(getBrowserControlState()).toBeNull();
expect(hasBrowserControlWork()).toBe(true);
const stopping = stop("service");
releaseStart();
@ -139,5 +146,6 @@ describe("browser control lifecycle", () => {
await stopping;
expect(runtimeMocks.stopBrowserRuntime).toHaveBeenCalledOnce();
expect(getBrowserControlState()).toBeNull();
expect(hasBrowserControlWork()).toBe(false);
});
});

View file

@ -16,10 +16,14 @@ let state: BrowserServerState | null = null;
let owner: BrowserControlOwner | null = null;
let lifecycleTail = Promise.resolve();
let completedEffectiveStops = 0;
let pendingLifecycles = 0;
/** Serialize complete Browser runtime start/stop workflows. */
function enqueueBrowserControlLifecycle<T>(run: () => Promise<T>): Promise<T> {
const result = lifecycleTail.then(run, run);
pendingLifecycles += 1;
const result = lifecycleTail.then(run, run).finally(() => {
pendingLifecycles -= 1;
});
lifecycleTail = result.then(
() => {},
() => {},
@ -45,6 +49,11 @@ export function getBrowserControlState(): BrowserServerState | null {
return state && isBrowserRuntimeRunning(state) ? state : null;
}
export function hasBrowserControlWork(): boolean {
// Retained profiles own browser processes, relays, hooks, and tab cleanup between requests.
return state !== null || pendingLifecycles > 0;
}
/** Create a route context bound to the current shared browser runtime. */
export function createBrowserControlContext() {
return createBrowserRouteContext({

View file

@ -1 +1,4 @@
export { ensureBrowserProxyUploadCleanup } from "./browser-proxy-upload.js";
export {
ensureBrowserProxyUploadCleanup,
hasBrowserProxyUploadWork,
} from "./browser-proxy-upload.js";

View file

@ -0,0 +1,44 @@
import { useAutoCleanupTempDirTracker } from "openclaw/plugin-sdk/test-env";
import { afterEach, expect, it } from "vitest";
import { BROWSER_PROXY_UPLOAD_ENVELOPE } from "./browser-proxy-envelope.js";
import {
discardStagedBrowserProxyUpload,
ensureBrowserProxyUploadCleanup,
hasBrowserProxyUploadWork,
stageBrowserProxyUploadRequest,
} from "./browser-proxy-upload.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
it("keeps upload recovery, retained files, and their cleanup busy until settled", async () => {
const uploadDir = tempDirs.make("openclaw-browser-upload-idle-");
let staged: Awaited<ReturnType<typeof stageBrowserProxyUploadRequest>> | undefined;
try {
expect(hasBrowserProxyUploadWork()).toBe(false);
const recovery = ensureBrowserProxyUploadCleanup({ uploadDir });
expect(hasBrowserProxyUploadWork()).toBe(true);
await recovery;
expect(hasBrowserProxyUploadWork()).toBe(false);
staged = await stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: { ref: "e1" },
upload: {
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
files: [{ name: "report.txt", contentBase64: Buffer.from("report").toString("base64") }],
},
uploadDir,
});
expect(hasBrowserProxyUploadWork()).toBe(true);
const cleanup = discardStagedBrowserProxyUpload(staged);
expect(hasBrowserProxyUploadWork()).toBe(true);
await cleanup;
expect(hasBrowserProxyUploadWork()).toBe(false);
} finally {
if (staged) {
await discardStagedBrowserProxyUpload(staged);
}
}
});

View file

@ -36,6 +36,18 @@ const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
const recoveryPromises = new Map<string, Promise<void>>();
const recoveryRetryTimers = new Map<string, ReturnType<typeof setTimeout>>();
const stagingLocks = new Map<string, Promise<void>>();
let activeCleanup = 0;
let activeRecovery = 0;
export function hasBrowserProxyUploadWork(): boolean {
return (
activeCleanup > 0 ||
activeRecovery > 0 ||
cleanupTimers.size > 0 ||
recoveryRetryTimers.size > 0 ||
stagingLocks.size > 0
);
}
type PreparedBrowserProxyUploadRequest = {
body: unknown;
@ -196,6 +208,7 @@ function decodeUploadFile(file: BrowserProxyUploadFile, totalBytes: number): Buf
}
async function removeStagedUpload(directory: string): Promise<void> {
activeCleanup += 1;
const timer = cleanupTimers.get(directory);
if (timer) {
clearTimeout(timer);
@ -206,6 +219,8 @@ async function removeStagedUpload(directory: string): Promise<void> {
} catch (error) {
logger.warn(`browser proxy upload cleanup failed; retrying: ${String(error)}`);
scheduleCleanup(directory, BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS);
} finally {
activeCleanup -= 1;
}
}
@ -358,12 +373,15 @@ async function runRecovery(params: {
nowMs: number;
limits: StagedUploadLimits;
}): Promise<void> {
activeRecovery += 1;
try {
await recoverStagedUploads(params);
clearRecoveryRetry(params.uploadDir);
} catch (error) {
logger.warn(`browser proxy upload recovery failed; retrying: ${String(error)}`);
scheduleRecoveryRetry(params.uploadDir, params.retentionMs);
} finally {
activeRecovery -= 1;
}
}

View file

@ -0,0 +1,19 @@
export const stagedReportUpload = {
body: { paths: ["/tmp/openclaw/uploads/.proxy-upload-1/0/report.txt"] },
directory: "/tmp/openclaw/uploads/.proxy-upload-1",
};
type BrowserDispatchRequest = {
path?: string;
query?: unknown;
body?: unknown;
};
export function firstBrowserDispatchRequest(calls: unknown[][]): BrowserDispatchRequest {
const [call] = calls;
if (!call) {
throw new Error("expected browser dispatch call");
}
const [request] = call as [BrowserDispatchRequest, ...unknown[]];
return request;
}

View file

@ -11,15 +11,13 @@ import {
} from "../browser-proxy-envelope.js";
import type { BrowserServerState } from "../browser/server-context.js";
import { toErrorObject } from "../infra/errors.js";
import { firstBrowserDispatchRequest, stagedReportUpload } from "./invoke-browser.test-support.js";
const BROWSER_PROXY_MAX_FILES = 256;
const BROWSER_PROXY_MAX_TOTAL_FILE_BYTES = 16 * 1024 * 1024;
const stagedReportUpload = {
body: { paths: ["/tmp/openclaw/uploads/.proxy-upload-1/0/report.txt"] },
directory: "/tmp/openclaw/uploads/.proxy-upload-1",
};
const controlServiceMocks = vi.hoisted(() => ({
hasBrowserControlWork: vi.fn(() => false),
createBrowserControlContext: vi.fn(() => ({ control: true })),
getBrowserControlState: vi.fn<() => BrowserServerState | null>(() => null),
startBrowserControlServiceFromConfig: vi.fn<() => Promise<BrowserServerState | null>>(),
@ -70,6 +68,7 @@ const browserConfigMocks = vi.hoisted(() => ({
}));
const uploadMocks = vi.hoisted(() => ({
hasBrowserProxyUploadWork: vi.fn(() => false),
stageBrowserProxyUploadRequest: vi.fn(),
discardStagedBrowserProxyUpload: vi.fn(async () => {}),
ensureBrowserProxyUploadCleanup: vi.fn(async () => {}),
@ -200,24 +199,13 @@ vi.mock("../control-service.js", () => ({
startBrowserControlServiceFromConfig: controlServiceMocks.startBrowserControlServiceFromConfig,
}));
vi.mock("../browser-control-state.js", () => ({
hasBrowserControlWork: controlServiceMocks.hasBrowserControlWork,
}));
let runBrowserProxyCommand: typeof import("./invoke-browser.js").runBrowserProxyCommand;
let browserState: BrowserServerState;
type BrowserDispatchRequest = {
path?: string;
query?: unknown;
body?: unknown;
};
function firstBrowserDispatchRequest(): BrowserDispatchRequest {
const [call] = dispatcherMocks.dispatch.mock.calls;
if (!call) {
throw new Error("expected browser dispatch call");
}
const [request] = call as [BrowserDispatchRequest, ...unknown[]];
return request;
}
describe("runBrowserProxyCommand", () => {
beforeEach(async () => {
const { resolveBrowserConfig } =
@ -406,7 +394,9 @@ describe("runBrowserProxyCommand", () => {
upload,
signal: expect.any(AbortSignal),
});
expect(firstBrowserDispatchRequest().body).toEqual(staged.body);
expect(firstBrowserDispatchRequest(dispatcherMocks.dispatch.mock.calls).body).toEqual(
staged.body,
);
expect(uploadMocks.discardStagedBrowserProxyUpload).not.toHaveBeenCalled();
});
@ -953,7 +943,7 @@ describe("runBrowserProxyCommand", () => {
}),
);
const request = firstBrowserDispatchRequest();
const request = firstBrowserDispatchRequest(dispatcherMocks.dispatch.mock.calls);
expect(request.path).toBe("/snapshot");
});
@ -1049,7 +1039,7 @@ describe("runBrowserProxyCommand", () => {
}),
);
const request = firstBrowserDispatchRequest();
const request = firstBrowserDispatchRequest(dispatcherMocks.dispatch.mock.calls);
expect(request.path).toBe("/stop");
expect(request.query).toEqual({ profile: "openclaw" });
});

View file

@ -9,6 +9,7 @@ import {
asNullableRecord,
normalizeStringEntries,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { hasBrowserControlWork } from "../browser-control-state.js";
import { BROWSER_PROXY_COMMAND, BROWSER_PROXY_UPLOAD_COMMAND } from "../browser-node-commands.js";
import {
assertBrowserProxyFileCountWithinLimit,
@ -25,6 +26,7 @@ import {
import {
discardStagedBrowserProxyUpload,
ensureBrowserProxyUploadCleanup,
hasBrowserProxyUploadWork,
stageBrowserProxyUploadRequest,
} from "../browser-proxy-upload.js";
import { resolveCdpControlPolicy } from "../browser/cdp-reachability-policy.js";
@ -134,6 +136,10 @@ function resolveBrowserProxyConfig() {
let browserControlReady: Promise<void> | null = null;
let admittedBrowserControlState: ReturnType<typeof getBrowserControlState> = null;
export function hasBrowserNodeHostWork(): boolean {
return hasBrowserControlWork() || hasBrowserProxyUploadWork();
}
async function ensureBrowserControlService(): Promise<void> {
const current = getBrowserControlState();
// Admission survives config refresh only for this exact live runtime generation.

View file

@ -208,6 +208,8 @@ describe("codex plugin", () => {
};
const command = createCodexSessionCatalogNodeHostCommands(
{
hasActiveWork: () => false,
disconnect: async () => {},
forRequest: () => control,
forNode: async () => ({ control, sourceHomeId: "home", codexHome: "/synthetic" }),
homesForAgent: async () => [],

View file

@ -65,6 +65,25 @@ export const getSharedCodexAppServerClientState = defineCodexBuildState(
}),
);
export function hasActiveSharedCodexAppServerWork(): boolean {
const state = getSharedCodexAppServerClientState();
if (state.startup.pending.size > 0 || state.startup.controller.signal.aborted) {
return true;
}
for (const entry of state.clients.values()) {
if (entry.activeLeases > 0 || entry.pendingAcquires > 0) {
return true;
}
}
for (const client of state.liveClients) {
const entry = state.entriesByClient.get(client);
if (entry && (entry.activeLeases > 0 || entry.pendingAcquires > 0)) {
return true;
}
}
return false;
}
export function getCurrentSharedClientEntry(
client: CodexAppServerClient | undefined,
): SharedCodexAppServerClientEntry | undefined {

View file

@ -193,6 +193,8 @@ describe("fork boundaries from imported Codex history", () => {
{
bindingStore,
controlFactory: {
hasActiveWork: () => false,
disconnect: async () => {},
forRequest: () => control,
forNode: async () => {
throw new Error("Node source is outside this local fork fixture");

View file

@ -88,6 +88,8 @@ type ForkThreadStub = (params: CodexThreadForkParams) => Promise<unknown>;
function factoryForControl(control: CodexSessionCatalogControl): CodexSessionCatalogControlFactory {
return {
hasActiveWork: () => false,
disconnect: async () => {},
forRequest: () => control,
forNode: async () => {
throw new Error("Node source is outside this local fork fixture");

View file

@ -63,12 +63,14 @@ export function createCodexCliSessionNodeHostCommands(): OpenClawPluginNodeHostC
{
command: CODEX_CLI_SESSIONS_LIST_COMMAND,
cap: "codex-cli-sessions",
hasActiveWork: () => false,
handle: listLocalCodexCliSessions,
},
{
command: CODEX_CLI_SESSION_RESUME_COMMAND,
cap: "codex-cli-sessions",
dangerous: true,
hasActiveWork: () => activeResumeSessions.size > 0,
handle: resumeLocalCodexCliSession,
},
];

View file

@ -162,12 +162,14 @@ describe("Codex node native readiness", () => {
await receiptHeld.promise;
expect(cleanupStarted).not.toHaveBeenCalled();
expect(harness.release).not.toHaveBeenCalled();
expect(harness.command.hasActiveWork?.()).toBe(true);
await expect(access(harness.privateHome)).resolves.toBeUndefined();
receipt.resolve();
await harness.outcome;
expect(cleanupStarted).toHaveBeenCalledOnce();
expect(harness.release).toHaveBeenCalledOnce();
expect(harness.command.hasActiveWork?.()).toBe(false);
await expect(access(harness.privateHome)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
receipt.resolve();
@ -192,6 +194,7 @@ describe("Codex node native readiness", () => {
expect(harness.child.exitCode).toBeNull();
expect(harness.child.signalCode).toBeNull();
expect(harness.release).not.toHaveBeenCalled();
expect(harness.command.hasActiveWork?.()).toBe(true);
await expect(access(harness.privateHome)).resolves.toBeUndefined();
await expect(harness.command.onDisconnect?.()).rejects.toThrow("did not terminate");
@ -204,6 +207,7 @@ describe("Codex node native readiness", () => {
});
await harness.command.onDisconnect?.();
expect(harness.release).toHaveBeenCalledOnce();
expect(harness.command.hasActiveWork?.()).toBe(false);
} finally {
failedClose.mockRestore();
failedTreeKill.mockRestore();

View file

@ -448,6 +448,7 @@ describe("Codex node exec-server", () => {
frames.controller.abort(new Error("malformed-frame fixture closed"));
await expect(invocation).rejects.toThrow("malformed-frame fixture closed");
expect(workspace.release).toHaveBeenCalledOnce();
expect(command.hasActiveWork?.() ?? false).toBe(false);
});
it("uses prepared HOME with the actual pinned binary while keeping Codex state private", async ({

View file

@ -52,6 +52,7 @@ export function createCodexNodeExecServerCommand(): OpenClawPluginNodeHostComman
cap: CODEX_NODE_EXEC_SERVER_CAPABILITY,
dangerous: true,
duplex: true,
hasActiveWork: () => activeProcesses.size > 0,
onDisconnect: async () => {
await Promise.all([...activeProcesses].map(async (terminate) => await terminate()));
},

View file

@ -426,6 +426,8 @@ describe("Codex supervision actions", () => {
const { api, getProvider, registerSessionCatalog } = createGatewayApi(runtime);
const control = createEligibleControl();
const processFallbackControl = {
hasActiveWork: () => false,
disconnect: async () => {},
forRequest: () => control,
forNode: async () => {
throw new Error("Node source is outside this local archive fixture");

View file

@ -0,0 +1,196 @@
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
import type { CodexManagedThreadStore } from "./app-server/managed-thread-store.js";
import { assertCodexThreadForkParams } from "./app-server/protocol.js";
import type {
CodexAppServerRequestParams,
CodexAppServerRequestResult,
CodexThread,
CodexThreadForkParams,
CodexThreadForkResponse,
CodexThreadListParams,
CodexThreadListResponse,
CodexThreadItemsListParams,
CodexThreadItemsListResponse,
CodexThreadTurnsListParams,
CodexThreadTurnsListResponse,
} from "./app-server/protocol.js";
import type { CodexControlRequestObservation } from "./app-server/request-observation.js";
import { withTimeout } from "./app-server/timeout.js";
import { CodexCatalogLoadingError } from "./session-catalog-availability.js";
import { requireEligibleCodexThread } from "./session-catalog-eligibility.js";
import type { CodexCatalogIndex } from "./session-catalog-index.js";
import {
currentCodexCatalogListRequest,
withCodexCatalogListRequest,
type CodexCatalogListRequest,
} from "./session-catalog-list-request.js";
import { readControlCursor, readPageParams } from "./session-catalog-parsing.js";
import type { CodexCatalogSourceBackoff } from "./session-catalog-source-backoff.js";
import type { CodexSessionCatalogControl } from "./session-catalog-types.js";
export type CodexSessionCatalogRequestSnapshot = {
beginList: (request?: CodexCatalogListRequest) => ReturnType<CodexCatalogSourceBackoff["begin"]>;
index: () => Promise<CodexCatalogIndex>;
requestTimeoutMs: number;
listThreads(
params: CodexThreadListParams,
timeoutMs: number,
observation?: CodexControlRequestObservation,
): Promise<CodexThreadListResponse>;
listThreadTurns(params: CodexThreadTurnsListParams): Promise<CodexThreadTurnsListResponse>;
listThreadItems(params: CodexThreadItemsListParams): Promise<CodexThreadItemsListResponse>;
forkThread(
params: CodexThreadForkParams,
assertCurrent?: () => void,
): Promise<CodexThreadForkResponse>;
readThread(threadId: string, includeTurns: boolean, timeoutMs?: number): Promise<CodexThread>;
archiveThread(threadId: string, assertCurrent?: () => void): Promise<void>;
};
export type CodexCatalogRequestMethod =
| typeof CODEX_CONTROL_METHODS.archiveThread
| typeof CODEX_CONTROL_METHODS.forkThread
| typeof CODEX_CONTROL_METHODS.listThreads
| typeof CODEX_CONTROL_METHODS.listThreadTurns
| typeof CODEX_CONTROL_METHODS.listThreadItems
| typeof CODEX_CONTROL_METHODS.readThread;
type CodexCatalogRequest = <M extends CodexCatalogRequestMethod>(
method: M,
requestParams: CodexAppServerRequestParams<M>,
timeoutMs?: number,
assertCurrent?: () => void,
observation?: CodexControlRequestObservation,
) => Promise<CodexAppServerRequestResult<M>>;
export function createCodexCatalogRequestSnapshot(
requestTimeoutMs: number,
request: CodexCatalogRequest,
index: () => Promise<CodexCatalogIndex>,
beginList: CodexSessionCatalogRequestSnapshot["beginList"],
catalogRead = false,
): CodexSessionCatalogRequestSnapshot {
const read = <M extends CodexCatalogRequestMethod>(
method: M,
params: CodexAppServerRequestParams<M>,
timeoutMs?: number,
observation?: CodexControlRequestObservation,
): Promise<CodexAppServerRequestResult<M>> => {
// Index reads are charged by NativePages; hydration never borrows a caller's scope.
const scope = catalogRead ? undefined : currentCodexCatalogListRequest();
if (!scope) {
return request(method, params, timeoutMs, undefined, observation);
}
return scope.read(timeoutMs ?? requestTimeoutMs, async (remaining) => {
const attempt = beginList(scope);
if (!attempt.allowed) {
throw attempt.error;
}
return await request(method, params, remaining, undefined, observation);
});
};
return {
index,
beginList,
get requestTimeoutMs() {
return catalogRead
? requestTimeoutMs
: (currentCodexCatalogListRequest()?.remaining(requestTimeoutMs) ?? requestTimeoutMs);
},
listThreads: (params, timeoutMs, observation) =>
read(CODEX_CONTROL_METHODS.listThreads, params, timeoutMs, observation),
listThreadTurns: (params) => read(CODEX_CONTROL_METHODS.listThreadTurns, params),
listThreadItems: (params) => read(CODEX_CONTROL_METHODS.listThreadItems, params),
forkThread: (params, assertCurrent) =>
request(
CODEX_CONTROL_METHODS.forkThread,
assertCodexThreadForkParams(params),
undefined,
assertCurrent,
),
readThread: async (threadId, includeTurns, timeoutMs) =>
(await read(CODEX_CONTROL_METHODS.readThread, { threadId, includeTurns }, timeoutMs)).thread,
archiveThread: async (threadId, assertCurrent) => {
await request(CODEX_CONTROL_METHODS.archiveThread, { threadId }, undefined, assertCurrent);
},
};
}
export function createCodexSessionCatalogControlFromRequests(params: {
forkContext?: CodexSessionCatalogControl["forkContext"];
clientId?: string;
retireConnection?: () => void;
connectionFingerprint?: string;
createRequestSnapshot: () => CodexSessionCatalogRequestSnapshot;
localSessionsRoot?: string;
sourceHomeId?: string;
managedThreads?: CodexManagedThreadStore;
now: () => number;
withPinnedConnection: CodexSessionCatalogControl["withPinnedConnection"];
}): CodexSessionCatalogControl {
return {
forkContext: params.forkContext,
...(params.clientId ? { clientId: params.clientId } : {}),
...(params.connectionFingerprint
? { connectionFingerprint: params.connectionFingerprint }
: {}),
withPinnedConnection: params.withPinnedConnection,
async initialize() {
await (await params.createRequestSnapshot().index()).initialize();
},
requireEligibleThread: (threadId) =>
requireEligibleCodexThread({
threadId,
requests: params.createRequestSnapshot(),
localSessionsRoot: params.localSessionsRoot,
sourceHomeId: params.sourceHomeId,
managedThreads: params.managedThreads,
now: params.now,
}),
retireConnection: params.retireConnection,
async listPage(pageParams) {
readControlCursor(pageParams.cursor, "request");
const query = readPageParams(pageParams);
return await withCodexCatalogListRequest(async (request) => {
const requests = params.createRequestSnapshot();
const deadline = request.deadline(requests.requestTimeoutMs);
const index = await withTimeout(
requests.index(),
request.remaining(requests.requestTimeoutMs),
"Codex session catalog is still loading",
() => new CodexCatalogLoadingError(),
);
return await index.list(query, deadline);
});
},
async listDescendantPage(listParams) {
const requests = params.createRequestSnapshot();
const response = await requests.listThreads(listParams, requests.requestTimeoutMs);
return response;
},
async readThread(threadId, includeTurns = false) {
const thread = await params.createRequestSnapshot().readThread(threadId, includeTurns);
return thread;
},
async listTurnPage(listParams) {
const response = await params.createRequestSnapshot().listThreadTurns(listParams);
return response;
},
listItemPage: (listParams) => params.createRequestSnapshot().listThreadItems(listParams),
async forkThread(forkParams, assertCurrent) {
const requests = params.createRequestSnapshot();
const index = forkParams.ephemeral === true ? undefined : await requests.index();
const response = await requests.forkThread(forkParams, assertCurrent);
if (index && !index.get(response.thread.id)) {
await index.upsertThread(response.thread);
}
return response;
},
async archiveThread(threadId, assertCurrent) {
const requests = params.createRequestSnapshot();
const index = await requests.index();
await requests.archiveThread(threadId, assertCurrent);
index.archive(threadId);
},
};
}

View file

@ -9,40 +9,37 @@ import type { CodexAppServerStartOptions } from "./app-server/config-contracts.j
import type { resolveCodexSupervisionAppServerRuntimeOptions } from "./app-server/config-runtime.js";
import type { CodexManagedThreadStore } from "./app-server/managed-thread-store.js";
import { buildCodexAppServerConnectionFingerprint } from "./app-server/plugin-app-cache-key.js";
import { assertCodexThreadForkParams } from "./app-server/protocol.js";
import type {
CodexAppServerRequestParams,
CodexAppServerRequestResult,
CodexThread,
CodexThreadForkParams,
CodexThreadForkResponse,
CodexThreadListParams,
CodexThreadListResponse,
CodexThreadItemsListParams,
CodexThreadItemsListResponse,
CodexThreadTurnsListParams,
CodexThreadTurnsListResponse,
} from "./app-server/protocol.js";
import type { CodexControlRequestObservation } from "./app-server/request-observation.js";
import { withTimeout } from "./app-server/timeout.js";
import { CodexCatalogLoadingError } from "./session-catalog-availability.js";
import {
getSharedCodexAppServerClientState,
hasActiveSharedCodexAppServerWork,
} from "./app-server/shared-client-lifecycle.js";
import {
createCodexCatalogRequestSnapshot,
createCodexSessionCatalogControlFromRequests,
type CodexCatalogRequestMethod,
type CodexSessionCatalogRequestSnapshot,
} from "./session-catalog-control-requests.js";
import {
startCodexCatalogPageDiagnostics,
startCodexCatalogControlRequestDiagnostics,
type CodexCatalogPageDiagnostics,
} from "./session-catalog-diagnostics.js";
import { requireEligibleCodexThread } from "./session-catalog-eligibility.js";
import { codexCatalogResidentHomeKey } from "./session-catalog-events.js";
import { createCodexCatalogHomeResolver, type CodexCatalogHome } from "./session-catalog-homes.js";
import type { CodexCatalogState } from "./session-catalog-index-state.js";
import type { CodexCatalogIndex } from "./session-catalog-index.js";
import {
currentCodexCatalogListRequest,
withCodexCatalogListRequest,
type CodexCatalogListRequest,
} from "./session-catalog-list-request.js";
import type { CodexCatalogPreviewCache } from "./session-catalog-native-projection.js";
import { readControlCursor, readPageParams } from "./session-catalog-parsing.js";
import { CodexCatalogSourceBackoff } from "./session-catalog-source-backoff.js";
import type {
CodexSessionCatalogControl,
@ -61,173 +58,6 @@ type CodexCatalogControlSource = Pick<
"appServer" | "localSessionsRoot" | "sourceHomeId" | "assertCurrent"
> & { agentDir?: string };
type CodexSessionCatalogRequestSnapshot = {
beginList: (request?: CodexCatalogListRequest) => ReturnType<CodexCatalogSourceBackoff["begin"]>;
index: () => Promise<CodexCatalogIndex>;
requestTimeoutMs: number;
listThreads(
params: CodexThreadListParams,
timeoutMs: number,
observation?: CodexControlRequestObservation,
): Promise<CodexThreadListResponse>;
listThreadTurns(params: CodexThreadTurnsListParams): Promise<CodexThreadTurnsListResponse>;
listThreadItems(params: CodexThreadItemsListParams): Promise<CodexThreadItemsListResponse>;
forkThread(
params: CodexThreadForkParams,
assertCurrent?: () => void,
): Promise<CodexThreadForkResponse>;
readThread(threadId: string, includeTurns: boolean, timeoutMs?: number): Promise<CodexThread>;
archiveThread(threadId: string, assertCurrent?: () => void): Promise<void>;
};
type CodexCatalogRequestMethod =
| typeof CODEX_CONTROL_METHODS.archiveThread
| typeof CODEX_CONTROL_METHODS.forkThread
| typeof CODEX_CONTROL_METHODS.listThreads
| typeof CODEX_CONTROL_METHODS.listThreadTurns
| typeof CODEX_CONTROL_METHODS.listThreadItems
| typeof CODEX_CONTROL_METHODS.readThread;
type CodexCatalogRequest = <M extends CodexCatalogRequestMethod>(
method: M,
requestParams: CodexAppServerRequestParams<M>,
timeoutMs?: number,
assertCurrent?: () => void,
observation?: CodexControlRequestObservation,
) => Promise<CodexAppServerRequestResult<M>>;
function createCodexCatalogRequestSnapshot(
requestTimeoutMs: number,
request: CodexCatalogRequest,
index: () => Promise<CodexCatalogIndex>,
beginList: CodexSessionCatalogRequestSnapshot["beginList"],
catalogRead = false,
): CodexSessionCatalogRequestSnapshot {
const read = <M extends CodexCatalogRequestMethod>(
method: M,
params: CodexAppServerRequestParams<M>,
timeoutMs?: number,
observation?: CodexControlRequestObservation,
): Promise<CodexAppServerRequestResult<M>> => {
// Index reads are charged by NativePages; hydration never borrows a caller's scope.
const scope = catalogRead ? undefined : currentCodexCatalogListRequest();
if (!scope) {
return request(method, params, timeoutMs, undefined, observation);
}
return scope.read(timeoutMs ?? requestTimeoutMs, async (remaining) => {
const attempt = beginList(scope);
if (!attempt.allowed) {
throw attempt.error;
}
return await request(method, params, remaining, undefined, observation);
});
};
return {
index,
beginList,
get requestTimeoutMs() {
return catalogRead
? requestTimeoutMs
: (currentCodexCatalogListRequest()?.remaining(requestTimeoutMs) ?? requestTimeoutMs);
},
listThreads: (params, timeoutMs, observation) =>
read(CODEX_CONTROL_METHODS.listThreads, params, timeoutMs, observation),
listThreadTurns: (params) => read(CODEX_CONTROL_METHODS.listThreadTurns, params),
listThreadItems: (params) => read(CODEX_CONTROL_METHODS.listThreadItems, params),
forkThread: (params, assertCurrent) =>
request(
CODEX_CONTROL_METHODS.forkThread,
assertCodexThreadForkParams(params),
undefined,
assertCurrent,
),
readThread: async (threadId, includeTurns, timeoutMs) =>
(await read(CODEX_CONTROL_METHODS.readThread, { threadId, includeTurns }, timeoutMs)).thread,
archiveThread: async (threadId, assertCurrent) => {
await request(CODEX_CONTROL_METHODS.archiveThread, { threadId }, undefined, assertCurrent);
},
};
}
function createCodexSessionCatalogControlFromRequests(params: {
forkContext?: CodexSessionCatalogControl["forkContext"];
clientId?: string;
retireConnection?: () => void;
connectionFingerprint?: string;
createRequestSnapshot: () => CodexSessionCatalogRequestSnapshot;
localSessionsRoot?: string;
sourceHomeId?: string;
managedThreads?: CodexManagedThreadStore;
now: () => number;
withPinnedConnection: CodexSessionCatalogControl["withPinnedConnection"];
}): CodexSessionCatalogControl {
return {
forkContext: params.forkContext,
...(params.clientId ? { clientId: params.clientId } : {}),
...(params.connectionFingerprint
? { connectionFingerprint: params.connectionFingerprint }
: {}),
withPinnedConnection: params.withPinnedConnection,
async initialize() {
await (await params.createRequestSnapshot().index()).initialize();
},
requireEligibleThread: (threadId) =>
requireEligibleCodexThread({
threadId,
requests: params.createRequestSnapshot(),
localSessionsRoot: params.localSessionsRoot,
sourceHomeId: params.sourceHomeId,
managedThreads: params.managedThreads,
now: params.now,
}),
retireConnection: params.retireConnection,
async listPage(pageParams) {
readControlCursor(pageParams.cursor, "request");
const query = readPageParams(pageParams);
return await withCodexCatalogListRequest(async (request) => {
const requests = params.createRequestSnapshot();
const deadline = request.deadline(requests.requestTimeoutMs);
const index = await withTimeout(
requests.index(),
request.remaining(requests.requestTimeoutMs),
"Codex session catalog is still loading",
() => new CodexCatalogLoadingError(),
);
return await index.list(query, deadline);
});
},
async listDescendantPage(listParams) {
const requests = params.createRequestSnapshot();
const response = await requests.listThreads(listParams, requests.requestTimeoutMs);
return response;
},
async readThread(threadId, includeTurns = false) {
const thread = await params.createRequestSnapshot().readThread(threadId, includeTurns);
return thread;
},
async listTurnPage(listParams) {
const response = await params.createRequestSnapshot().listThreadTurns(listParams);
return response;
},
listItemPage: (listParams) => params.createRequestSnapshot().listThreadItems(listParams),
async forkThread(forkParams, assertCurrent) {
const requests = params.createRequestSnapshot();
const index = forkParams.ephemeral === true ? undefined : await requests.index();
const response = await requests.forkThread(forkParams, assertCurrent);
if (index && !index.get(response.thread.id)) {
await index.upsertThread(response.thread);
}
return response;
},
async archiveThread(threadId, assertCurrent) {
const requests = params.createRequestSnapshot();
const index = await requests.index();
await requests.archiveThread(threadId, assertCurrent);
index.archive(threadId);
},
};
}
/** Builds the passive catalog over the Codex plugin's canonical shared client. */
export function createCodexSessionCatalogControl(params: {
config?: OpenClawConfig;
@ -257,11 +87,36 @@ export function createCodexSessionCatalogControl(params: {
const indexes = new Map<string, CodexCatalogIndex>();
const retiringState = new Map<string, Promise<void>>();
const directHomes = new Map<string, Promise<string>>();
const residentRequests = new Set<Promise<CodexCatalogIndex>>();
let generation = params.getRuntimeConfig();
let residentEpoch = 0;
let starting = 0;
let closed = false;
let runBackground = (run: () => Promise<void>) => run();
let retiring: Promise<void> = Promise.resolve();
const residentFor = async (
let retiring: Promise<void> | undefined;
const retireIndexes = (): Promise<void> => {
residentEpoch++;
const closing: Promise<void>[] = [];
for (const [homeId, index] of indexes) {
const writes = index.retire().finally(() => {
if (retiringState.get(homeId) === writes) {
retiringState.delete(homeId);
}
});
retiringState.set(homeId, writes);
closing.push(writes, index.close());
}
indexes.clear();
directHomes.clear();
const drain = Promise.allSettled([retiring, ...closing]).then(() => {
if (retiring === drain) {
retiring = undefined;
}
});
retiring = drain;
return drain;
};
const resolveResident = async (
agentId: string | undefined,
source?: CodexCatalogControlSource,
): Promise<CodexCatalogIndex> => {
@ -271,20 +126,9 @@ export function createCodexSessionCatalogControl(params: {
const config = params.getRuntimeConfig();
if (generation !== config) {
generation = config;
const closing: Promise<void>[] = [];
for (const [homeId, index] of indexes) {
const writes = index.retire().finally(() => {
if (retiringState.get(homeId) === writes) {
retiringState.delete(homeId);
}
});
retiringState.set(homeId, writes);
closing.push(writes, index.close());
}
retiring = Promise.allSettled([retiring, ...closing]).then(() => undefined);
indexes.clear();
directHomes.clear();
void retireIndexes();
}
const epoch = residentEpoch;
const runtime =
source?.appServer ?? params.resolveRuntimeOptions({ pluginConfig: getPluginConfig() });
const requestOptions = resolveRequestOptions(runtime.start, agentId, source);
@ -302,7 +146,7 @@ export function createCodexSessionCatalogControl(params: {
// Only already-admitted writes can affect the replacement's snapshot.
// Retired native reads remain owned by stop(), without delaying this list.
await retiringState.get(homeId);
if (closed || generation !== config) {
if (closed || generation !== config || residentEpoch !== epoch) {
throw new Error("Codex catalog configuration changed");
}
let index = indexes.get(homeId);
@ -315,7 +159,7 @@ export function createCodexSessionCatalogControl(params: {
import("./session-catalog-projection.js"),
]);
source?.assertCurrent();
if (closed || generation !== config) {
if (closed || generation !== config || residentEpoch !== epoch) {
throw new Error("Codex catalog configuration changed");
}
index = indexes.get(homeId);
@ -418,7 +262,7 @@ export function createCodexSessionCatalogControl(params: {
state: params.openResidentState?.(homeId),
assertCurrent: () => {
source?.assertCurrent();
if (closed || params.getRuntimeConfig() !== config) {
if (closed || params.getRuntimeConfig() !== config || residentEpoch !== epoch) {
throw new Error("Codex catalog configuration changed");
}
},
@ -448,6 +292,16 @@ export function createCodexSessionCatalogControl(params: {
}
return index;
};
const residentFor = (
agentId: string | undefined,
source?: CodexCatalogControlSource,
): Promise<CodexCatalogIndex> => {
const request = resolveResident(agentId, source);
residentRequests.add(request);
const release = () => residentRequests.delete(request);
void request.then(release, release);
return request;
};
const resolveRequestOptions = (
startOptions: CodexAppServerStartOptions,
agentId: string | undefined,
@ -638,30 +492,54 @@ export function createCodexSessionCatalogControl(params: {
return source ? forRequest(agentId, source) : undefined;
};
return {
hasActiveWork: () =>
starting > 0 ||
residentRequests.size > 0 ||
retiring !== undefined ||
hasActiveSharedCodexAppServerWork() ||
[...indexes.values()].some((index) => index.hasActiveWork()),
async disconnect() {
await retireIndexes();
await Promise.allSettled(residentRequests);
// Node disconnect owns these transports; Gateway service retirement does not.
const clients = getSharedCodexAppServerClientState();
if (clients.liveClients.size > 0 || clients.startup.pending.size > 0) {
const { clearSharedCodexAppServerClientAndWait } =
await import("./app-server/shared-client.js");
await clearSharedCodexAppServerClientAndWait();
}
},
async start() {
const serviceScope = AsyncLocalStorage.snapshot();
runBackground = (run) => serviceScope(run);
for (const agentId of listAgentIds(params.getRuntimeConfig() ?? params.config ?? {})) {
for (const source of await homeResolver.forAgent(agentId)) {
// Implicit process HOME is admitted by the Gateway's request policy.
// Explicit homes can hydrate at activation without bypassing that policy.
if (source.usesProcessHomeFallback) {
continue;
const epoch = residentEpoch;
starting++;
try {
const serviceScope = AsyncLocalStorage.snapshot();
runBackground = (run) => serviceScope(run);
for (const agentId of listAgentIds(params.getRuntimeConfig() ?? params.config ?? {})) {
for (const source of await homeResolver.forAgent(agentId)) {
if (closed || residentEpoch !== epoch) {
return;
}
// Implicit process HOME is admitted by the Gateway's request policy.
// Explicit homes can hydrate at activation without bypassing that policy.
if (source.usesProcessHomeFallback) {
continue;
}
void forRequest(agentId, source)
.initialize()
.catch((error: unknown) =>
embeddedAgentLog.warn("Codex catalog hydration failed", { error }),
);
}
void forRequest(agentId, source)
.initialize()
.catch((error: unknown) =>
embeddedAgentLog.warn("Codex catalog hydration failed", { error }),
);
}
} finally {
starting--;
}
},
async stop() {
closed = true;
await Promise.allSettled([...indexes.values()].map((index) => index.close()));
await retiring;
indexes.clear();
directHomes.clear();
await retireIndexes();
await Promise.allSettled(residentRequests);
},
forRequest,
forUpstream,

View file

@ -15,6 +15,10 @@ export class CodexCatalogCurrency {
constructor(private readonly options: CurrencyOptions) {}
hasActiveWork(): boolean {
return this.initial !== undefined || this.running !== undefined;
}
start(): void {
if (this.closed || this.timer) {
return;
@ -37,15 +41,19 @@ export class CodexCatalogCurrency {
if (this.options.local) {
// Restored snapshots serve immediately; the initial delta scan runs separately.
this.initial = setTimeout(() => {
this.initial = undefined;
void this.options.reconcileFiles().catch((error: unknown) => this.options.report(error));
}, 0);
this.initial.unref();
}
}
close(): void {
close(): Promise<void> | undefined {
this.closed = true;
clearInterval(this.timer);
this.timer = undefined;
clearTimeout(this.initial);
this.initial = undefined;
return this.running;
}
}

View file

@ -45,6 +45,10 @@ export class CodexCatalogIndexEvents {
constructor(private readonly owner: CodexCatalogIndexEventOwner) {}
hasActiveWork(): boolean {
return this.pending.size > 0 || this.upserting.size > 0;
}
handle(event: CodexServerNotification, readThread: ReadThread, source: CodexCatalogSource): void {
if (this.closed || !isRecord(event.params)) {
return;

View file

@ -7,6 +7,10 @@ export class CodexCatalogObservations {
private readonly mutations = new Map<string, number>();
private readonly observations = new Map<symbol, number>();
hasActiveWork(): boolean {
return this.observations.size > 0;
}
mark(threadId: string): void {
this.revision++;
if (this.observations.size) {

View file

@ -179,6 +179,10 @@ export class CodexCatalogPersistence {
private readonly report: (error: unknown) => void,
) {}
hasActiveWork(): boolean {
return this.writing !== undefined || this.pending.size > 0;
}
async readSnapshot() {
await this.drain();
return await readCodexCatalogSnapshot(this.state);

View file

@ -44,8 +44,10 @@ import {
import {
scanCodexCatalogRollouts,
codexCatalogRolloutLogicalPath,
indexCodexCatalogRowsByRollout,
isCodexCatalogRolloutPathCovered,
readCodexCatalogRollout,
resolveCodexCatalogRolloutFingerprint,
} from "./session-catalog-rollouts.js";
import { CodexCatalogSettingsIndex } from "./session-catalog-settings.js";
import { setCodexCatalogSource } from "./session-catalog-source.js";
@ -419,17 +421,12 @@ export class CodexCatalogIndex {
if (!isCurrent(row.threadId)) {
continue;
}
const logicalPath = row.rolloutPath && codexCatalogRolloutLogicalPath(row.rolloutPath);
const previous = this.rows.get(row.threadId);
const fingerprint = logicalPath
? (files.get(logicalPath) ??
files.get(`${logicalPath}.zst`) ??
(useStateDbOnly &&
previous?.rolloutPath &&
codexCatalogRolloutLogicalPath(previous.rolloutPath) === logicalPath
? previous.fingerprint
: undefined))
: undefined;
const fingerprint = resolveCodexCatalogRolloutFingerprint(
row.rolloutPath,
useStateDbOnly ? previous : undefined,
files,
);
this.observations.mark(row.threadId);
this.put({
...row,
@ -482,11 +479,7 @@ export class CodexCatalogIndex {
return;
}
this.assertCurrent();
const byPath = new Map(
[...this.rows.values()].flatMap((row) =>
row.rolloutPath ? [[codexCatalogRolloutLogicalPath(row.rolloutPath), row] as const] : [],
),
);
const byPath = indexCodexCatalogRowsByRollout(this.rows.values());
const { files, present } = await scanCodexCatalogRollouts(root, new Set(byPath.keys()));
this.assertCurrent();
const observed = new Map(files);
@ -640,13 +633,7 @@ export class CodexCatalogIndex {
}
if (row) {
const previous = this.rows.get(thread.id);
const fingerprint =
previous?.rolloutPath &&
row.rolloutPath &&
codexCatalogRolloutLogicalPath(previous.rolloutPath) ===
codexCatalogRolloutLogicalPath(row.rolloutPath)
? previous.fingerprint
: undefined;
const fingerprint = resolveCodexCatalogRolloutFingerprint(row.rolloutPath, previous);
this.observations.mark(thread.id);
this.put({
...row,
@ -680,6 +667,20 @@ export class CodexCatalogIndex {
return this.rows.get(threadId);
}
hasActiveWork(): boolean {
return Boolean(
this.initializing ||
(!this.restored && this.restoring) ||
this.background ||
this.reconciling ||
this.reconcilingNative ||
this.currency.hasActiveWork() ||
this.observations.hasActiveWork() ||
this.events.hasActiveWork() ||
this.persistence.hasActiveWork(),
);
}
async list(
params: CodexSessionCatalogPageParams,
deadline = performance.now() + (this.options.requestTimeoutMs ?? 60_000),
@ -717,8 +718,10 @@ export class CodexCatalogIndex {
this.liveStatus.invalidate();
this.liveSettings.invalidate();
this.unsubscribe();
this.currency.close();
// close() joins the running scan after retirement has fenced its publications.
void this.currency.close();
clearImmediate(this.background);
this.background = undefined;
return this.persistence.retire();
}
@ -729,6 +732,7 @@ export class CodexCatalogIndex {
this.restoring,
this.reconciling,
this.reconcilingNative,
this.currency.close(),
writes,
this.events.close(),
]);

View file

@ -64,6 +64,8 @@ export function createCodexSessionCatalogNodeHostCommands(
command: CODEX_APP_SERVER_THREADS_LIST_COMMAND,
cap: CODEX_APP_SERVER_THREADS_CAPABILITY,
dangerous: false,
hasActiveWork: controlFactory.hasActiveWork,
onDisconnect: controlFactory.disconnect,
handle: async (paramsJSON) => {
const request = await bindRequest(paramsJSON);
const pageParams = readPageParams(request.params);
@ -103,6 +105,8 @@ export function createCodexSessionCatalogNodeHostCommands(
command: CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND,
cap: CODEX_APP_SERVER_THREADS_CAPABILITY,
dangerous: false,
hasActiveWork: controlFactory.hasActiveWork,
onDisconnect: controlFactory.disconnect,
handle: async (paramsJSON) => {
const request = await bindRequest(paramsJSON);
const action = readNodeTranscriptParams(request.params);
@ -130,6 +134,8 @@ export function createCodexSessionCatalogNodeHostCommands(
command: CODEX_CATALOG_TRANSCRIPT_READ_COMMAND,
cap: CODEX_APP_SERVER_THREADS_CAPABILITY,
dangerous: false,
hasActiveWork: controlFactory.hasActiveWork,
onDisconnect: controlFactory.disconnect,
handle: async (paramsJSON) => {
const request = await bindRequest(paramsJSON);
const action = readNodeTranscriptParams(request.params);

View file

@ -90,6 +90,8 @@ describe("Codex supervision catalog", () => {
});
const command = createCodexSessionCatalogNodeHostCommands(
{
hasActiveWork: () => false,
disconnect: async () => {},
forRequest: () => control,
forNode: async () => ({ control, sourceHomeId: "home-main", codexHome: "/node/.codex" }),
homesForAgent: async () => [],

View file

@ -21,6 +21,8 @@ import { createClientHarness } from "./app-server/test-support.js";
import { CODEX_APP_SERVER_VERSION } from "./app-server/version.js";
import { createCodexSessionCatalogControl } from "./session-catalog-control.js";
import type { CodexCatalogState, StoredCodexCatalogEntry } from "./session-catalog-index-state.js";
import { createCodexSessionCatalogNodeHostCommands } from "./session-catalog-listing.js";
import { CODEX_APP_SERVER_THREADS_LIST_COMMAND } from "./session-catalog-parsing.js";
type ListFrame = { id: number; params: CodexThreadListParams };
type CatalogResources = {
@ -47,7 +49,15 @@ function observeHydration(pending: Promise<void>) {
async function createCatalogHarness(agentDir: string, resources: CatalogResources) {
const { transports } = resources;
const frames: Array<ListFrame & { transport: ReturnType<typeof createClientHarness> }> = [];
const frameWaiters = new Map<
number,
ReturnType<typeof createDeferred<(typeof frames)[number]>>
>();
let closed = false;
vi.spyOn(CodexAppServerClient, "start").mockImplementation(async () => {
if (closed) {
throw new Error("Catalog fixture is closed");
}
const transport = createClientHarness({
onWrite: (line, send) => {
const message = JSON.parse(line) as ListFrame & { method: string };
@ -56,7 +66,10 @@ async function createCatalogHarness(agentDir: string, resources: CatalogResource
} else if (message.method === "model/list") {
send({ id: message.id, result: { data: [] } });
} else if (message.method === "thread/list") {
frames.push({ ...message, transport });
const frame = { ...message, transport };
const index = frames.push(frame) - 1;
frameWaiters.get(index)?.resolve(frame);
frameWaiters.delete(index);
} else if (message.method !== "initialized") {
throw new Error(`Unexpected catalog fixture request: ${message.method}`);
}
@ -125,14 +138,24 @@ async function createCatalogHarness(agentDir: string, resources: CatalogResource
now += 32_001;
},
async frame(index: number) {
return await vi.waitFor(
() => {
const frame = frames[index];
assert(frame, `Expected catalog request ${index}`);
return frame;
},
{ interval: 1 },
);
const frame = frames[index];
if (frame) {
return frame;
}
assert(!closed, "Catalog fixture is closed");
let waiter = frameWaiters.get(index);
if (!waiter) {
waiter = createDeferred<(typeof frames)[number]>();
frameWaiters.set(index, waiter);
}
return await waiter.promise;
},
close() {
closed = true;
for (const [index, waiter] of frameWaiters) {
waiter.reject(new Error(`Catalog fixture closed before request ${index}`));
}
frameWaiters.clear();
},
reply(frame: (typeof frames)[number], threadId: string) {
frame.transport.send({ id: frame.id, result: page(threadId) });
@ -191,16 +214,22 @@ describe("resident catalog hydration request lifetime", () => {
});
afterEach(async () => {
vi.useRealTimers();
if (resources.companion) {
releaseLeasedSharedCodexAppServerClient(resources.companion);
h.close();
const stopped = Promise.all(resources.factories.map((factory) => factory.stop()));
void stopped.catch(() => undefined);
try {
if (resources.companion) {
releaseLeasedSharedCodexAppServerClient(resources.companion);
}
const closed = await Promise.allSettled(
resources.transports.map(({ client }) => client.closeAndWait()),
);
await stopped;
expect(closed.every((result) => result.status === "fulfilled")).toBe(true);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
const closed = await Promise.allSettled(
resources.transports.map(({ client }) => client.closeAndWait()),
);
await Promise.all(resources.factories.map((factory) => factory.stop()));
vi.restoreAllMocks();
expect(closed.every((result) => result.status === "fulfilled")).toBe(true);
});
it("splits a successful control wait at the existing client request boundary", async () => {
@ -426,6 +455,63 @@ describe("resident catalog hydration request lifetime", () => {
}
});
it("keeps node updates deferred through catalog persistence and disconnect before reconnecting", async () => {
releaseLeasedSharedCodexAppServerClient(h.companion);
resources.companion = undefined;
const writeStarted = createDeferred<void>();
const writeAllowed = createDeferred<void>();
const values = new Map<string, StoredCodexCatalogEntry>();
const state: CodexCatalogState = {
entries: async () => [...values].map(([key, value]) => ({ key, value, createdAt: 0 })),
register: async (key, value) => {
if (value.kind === "row" && value.row.threadId === "retained-thread") {
writeStarted.resolve();
await writeAllowed.promise;
}
values.set(key, structuredClone(value));
},
delete: async (key) => values.delete(key),
};
const factory = h.newFactory(REQUEST_TIMEOUT_MS, state);
const command = createCodexSessionCatalogNodeHostCommands(factory).find(
(candidate) => candidate.command === CODEX_APP_SERVER_THREADS_LIST_COMMAND,
);
assert(command, "Expected the registered Codex node catalog command");
expect(command.hasActiveWork?.()).toBe(false);
const paramsJSON = JSON.stringify({ agentId: "main", limit: 1 });
const listed = command.handle(paramsJSON);
void listed.catch(() => undefined);
try {
h.reply(await h.frame(0), "retained-thread");
await writeStarted.promise;
expect(JSON.parse(await listed)).toMatchObject({
sessions: [{ threadId: "retained-thread" }],
});
expect(command.hasActiveWork?.()).toBe(true);
const disconnected = vi.fn();
const disconnecting = Promise.resolve(command.onDisconnect?.()).then(disconnected);
await nextTurn();
expect(disconnected).not.toHaveBeenCalled();
expect(command.hasActiveWork?.()).toBe(true);
writeAllowed.resolve();
await disconnecting;
expect(command.hasActiveWork?.()).toBe(false);
const reconnected = command.handle(paramsJSON);
void reconnected.catch(() => undefined);
h.reply(await h.frame(1), "reconnected-thread");
expect(JSON.parse(await reconnected)).toMatchObject({
sessions: [{ threadId: "reconnected-thread" }],
});
await command.onDisconnect?.();
expect(command.hasActiveWork?.()).toBe(false);
} finally {
writeAllowed.resolve();
await factory.stop();
}
});
it.each([false, true])("bounds same-home retirement waits (expired: %s)", async (expired) => {
const writeStarted = createDeferred<void>();
const writeAllowed = createDeferred<void>();

View file

@ -6,7 +6,10 @@ import { root as openSafeRoot } from "openclaw/plugin-sdk/file-access-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { sanitizeTerminalText } from "openclaw/plugin-sdk/text-chunking";
import type { CodexSessionSource, CodexThread } from "./app-server/protocol.js";
import type { CodexCatalogRolloutFingerprint } from "./session-catalog-index-row.js";
import type {
CodexCatalogIndexRow,
CodexCatalogRolloutFingerprint,
} from "./session-catalog-index-row.js";
import { CODEX_CATALOG_MAX_ROWS, detachCodexCatalogString } from "./session-catalog-limits.js";
import {
boundedCatalogString,
@ -96,6 +99,34 @@ export function codexCatalogRolloutLogicalPath(rolloutPath: string): string {
return rolloutPath.replace(/\.zst$/u, "");
}
export function resolveCodexCatalogRolloutFingerprint(
rolloutPath: string | undefined,
previous: Pick<CodexCatalogIndexRow, "rolloutPath" | "fingerprint"> | undefined,
files?: ReadonlyMap<string, CodexCatalogRolloutFingerprint>,
): CodexCatalogRolloutFingerprint | undefined {
const logicalPath = rolloutPath && codexCatalogRolloutLogicalPath(rolloutPath);
if (!logicalPath) {
return undefined;
}
return (
files?.get(logicalPath) ??
files?.get(`${logicalPath}.zst`) ??
(previous?.rolloutPath && codexCatalogRolloutLogicalPath(previous.rolloutPath) === logicalPath
? previous.fingerprint
: undefined)
);
}
export function indexCodexCatalogRowsByRollout(
rows: Iterable<CodexCatalogIndexRow>,
): Map<string, CodexCatalogIndexRow> {
return new Map(
[...rows].flatMap((row) =>
row.rolloutPath ? [[codexCatalogRolloutLogicalPath(row.rolloutPath), row] as const] : [],
),
);
}
/** Absence is meaningful only inside the layout visited by the currency scan. */
export function isCodexCatalogRolloutPathCovered(
sessionsRoot: string,

View file

@ -31,6 +31,7 @@ export function createCodexTerminalStartNodeHostCommand(): OpenClawPluginNodeHos
cap: CODEX_APP_SERVER_THREADS_CAPABILITY,
dangerous: false,
duplex: true,
hasActiveWork: () => false,
isAvailable: ({ env }) =>
Boolean(resolveNodeHostExecutable("codex", { env, strategy: "direct" })),
handle: async (paramsJSON, io) => {
@ -128,6 +129,7 @@ export function createCodexTerminalNodeHostCommand(
cap: CODEX_APP_SERVER_THREADS_CAPABILITY,
dangerous: false,
duplex: true,
hasActiveWork: () => false,
isAvailable: ({ env }) =>
Boolean(
resolveNodeHostExecutable("codex", {

View file

@ -93,6 +93,9 @@ export type CodexSessionCatalogControl = {
};
export type CodexSessionCatalogControlFactory = {
hasActiveWork(this: void): boolean;
/** Drain node-owned state and transports while permitting the next connection. */
disconnect(this: void): Promise<void>;
forRequest(agentId: string, source?: CodexCatalogHome): CodexSessionCatalogControl;
/** Native default, with the shipped agent selector retained for explicitly configured sources. */
forNode(agentId?: string): Promise<{

View file

@ -176,6 +176,8 @@ function asControlFactory(
}
const forRequest = "forRequest" in control ? control.forRequest : () => control;
return {
hasActiveWork: () => false,
disconnect: async () => {},
forRequest,
forNode: async () => ({
control: forRequest("main"),

View file

@ -78,6 +78,8 @@ function createActivityChecker(params: {
api,
bindingStore,
control: {
hasActiveWork: () => false,
disconnect: async () => {},
forRequest: () => params.control,
forNode: async () => {
throw new Error("Node source is outside this local activity fixture");

View file

@ -42,6 +42,7 @@ function createLazyTool(
const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
{
command: "file.fetch",
hasActiveWork: () => false,
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {
@ -53,6 +54,7 @@ const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
},
{
command: "dir.list",
hasActiveWork: () => false,
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {
@ -64,6 +66,7 @@ const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
},
{
command: "dir.fetch",
hasActiveWork: () => false,
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {
@ -75,6 +78,7 @@ const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
},
{
command: "file.write",
hasActiveWork: () => false,
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {

View file

@ -360,12 +360,16 @@ export default definePluginEntry({
{ name: "google_meet" },
);
let nodeHost: Awaited<ReturnType<typeof loadGoogleMeetNodeHostModule>> | undefined;
api.registerNodeHostCommand({
command: GOOGLE_MEET_NODE_COMMAND,
cap: "google-meet",
dangerous: true,
handle: async (paramsJSON) =>
await (await loadGoogleMeetNodeHostModule()).handleGoogleMeetNodeHostCommand(paramsJSON),
hasActiveWork: () => nodeHost?.handleGoogleMeetNodeHostCommand.hasActiveWork() ?? false,
handle: async (paramsJSON) => {
nodeHost ??= await loadGoogleMeetNodeHostModule();
return await nodeHost.handleGoogleMeetNodeHostCommand(paramsJSON);
},
});
api.registerNodeInvokePolicy(createLazyGoogleMeetNodeInvokePolicy(config));

View file

@ -37,6 +37,7 @@ describe("google-meet lazy imports", () => {
let helperImports = 0;
let runtimeImports = 0;
let nodeHostImports = 0;
let nodeHostBusy = false;
let nodePolicyImports = 0;
let cliImports = 0;
let gatewayRuntimeImports = 0;
@ -74,7 +75,13 @@ describe("google-meet lazy imports", () => {
vi.doMock("./src/node-host.js", () => {
nodeHostImports += 1;
return {
handleGoogleMeetNodeHostCommand: async () => JSON.stringify({ ok: true }),
handleGoogleMeetNodeHostCommand: Object.assign(
async () => {
nodeHostBusy = true;
return JSON.stringify({ ok: true });
},
{ hasActiveWork: () => nodeHostBusy },
),
};
});
vi.doMock("./src/node-invoke-policy.js", () => {
@ -142,6 +149,7 @@ describe("google-meet lazy imports", () => {
}),
);
expect(nodeCommands[0]?.hasActiveWork?.()).toBe(false);
expect({
helperImports,
runtimeImports,
@ -202,6 +210,9 @@ describe("google-meet lazy imports", () => {
await nodeCommands[0]?.handle();
await nodeCommands[0]?.handle();
expect(nodeCommands[0]?.hasActiveWork?.()).toBe(true);
nodeHostBusy = false;
expect(nodeCommands[0]?.hasActiveWork?.()).toBe(false);
await nodePolicies[0]?.handle({} as never);
await nodePolicies[0]?.handle({} as never);
await cliRegistrars[0]?.({ program: {} } as never);

View file

@ -23,7 +23,7 @@ function normalizeMeetKey(value?: string): string | undefined {
}
}
const googleMeetNodeHost = MeetingPlatformAdapter.createNodeHostHandler({
export const handleGoogleMeetNodeHostCommand = MeetingPlatformAdapter.createNodeHostHandler({
commandName: GOOGLE_MEET_NODE_COMMAND,
displayName: "Google Meet",
browserLabel: "Meet",
@ -50,7 +50,3 @@ const googleMeetNodeHost = MeetingPlatformAdapter.createNodeHostHandler({
],
},
});
export async function handleGoogleMeetNodeHostCommand(paramsJSON?: string | null): Promise<string> {
return await googleMeetNodeHost(paramsJSON);
}

View file

@ -218,6 +218,7 @@ export function createLinuxNodeCommands(
return [
{
command: "system.notify",
hasActiveWork: () => false,
isAvailable: isAvailable("notify", "notify-send"),
handle: async (paramsJSON) => {
const notifySend = resolveTool(
@ -247,6 +248,7 @@ export function createLinuxNodeCommands(
},
{
command: "camera.list",
hasActiveWork: () => false,
cap: "camera",
isAvailable: isAvailable("camera", "ffmpeg"),
handle: async () => {
@ -256,6 +258,7 @@ export function createLinuxNodeCommands(
},
{
command: "camera.snap",
hasActiveWork: () => false,
cap: "camera",
dangerous: true,
isAvailable: isAvailable("camera", "ffmpeg"),
@ -316,6 +319,7 @@ export function createLinuxNodeCommands(
},
{
command: "camera.clip",
hasActiveWork: () => false,
cap: "camera",
dangerous: true,
isAvailable: isAvailable("camera", "ffmpeg"),

View file

@ -101,6 +101,7 @@ export function createLinuxLocationCommand(
deps.resolveExecutable("where-am-i", env, GEOCLUE_DEMO_PATHS);
return {
command: "location.get",
hasActiveWork: () => false,
cap: "location",
isAvailable: (context) =>
deps.platform === "linux" &&

View file

@ -46,6 +46,7 @@ function readNumberParam(params: unknown, key: string): number {
const logbookNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
{
command: "logbook.snapshot",
hasActiveWork: () => false,
cap: "screen",
dangerous: false,
handle: async (paramsJSON) => {

View file

@ -33,6 +33,7 @@ function createLazyNodeHostCommand(
return {
command,
cap: OLLAMA_NODE_INFERENCE_CAPABILITY,
hasActiveWork: () => false,
handle: async (paramsJSON, io, context) => {
const runtimeCommand = await loadRuntimeCommand();
return await runtimeCommand.handle(paramsJSON, io, context);

View file

@ -313,12 +313,14 @@ export function createOllamaNodeHostCommands(options?: {
{
command: OLLAMA_MODELS_COMMAND,
cap: OLLAMA_NODE_INFERENCE_CAPABILITY,
hasActiveWork: () => false,
handle: async (_paramsJSON, _io, context) =>
JSON.stringify(await discoverOllamaNodeModels(baseUrl, context?.signal)),
},
{
command: OLLAMA_CHAT_COMMAND,
cap: OLLAMA_NODE_INFERENCE_CAPABILITY,
hasActiveWork: () => false,
handle: async (paramsJSON, _io, context) => {
const params = readNodeCommandParams(paramsJSON);
const model = readStringParam(params, "model", { required: true });

View file

@ -323,13 +323,17 @@ export async function enrichOllamaModelsWithContext(
for (let index = 0; index < models.length; index += concurrency) {
throwIfOllamaRequestAborted(opts?.signal);
const batch = models.slice(index, index + concurrency);
const batchResults = await Promise.all(
batch.map(async (model) => {
const showInfo = await queryOllamaModelShowInfoCached(apiBase, model, opts);
return mergeOllamaModelShowInfo(model, showInfo);
}),
);
enriched.push(...batchResults);
const probes = batch.map(async (model) => {
const showInfo = await queryOllamaModelShowInfoCached(apiBase, model, opts);
return mergeOllamaModelShowInfo(model, showInfo);
});
try {
enriched.push(...(await Promise.all(probes)));
} catch (error) {
// A canceled probe must join sibling HTTP cleanup before the node becomes idle.
await Promise.allSettled(probes);
throw error;
}
}
return enriched;
}

View file

@ -1,9 +1,16 @@
import { once } from "node:events";
import { createServer } from "node:http";
import type { Socket } from "node:net";
import { setImmediate as nextTurn } from "node:timers/promises";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import type { WizardPrompter } from "openclaw/plugin-sdk/setup";
import { jsonResponse } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchOllamaModels, readOllamaModelShowInfo } from "./provider-models.js";
import {
enrichOllamaModelsWithContext,
fetchOllamaModels,
readOllamaModelShowInfo,
} from "./provider-models.js";
import { pullOllamaModel } from "./setup-pull.js";
import { checkOllamaCloudAuth } from "./setup.runtime.js";
@ -198,6 +205,56 @@ describe("Ollama setup response cleanup", () => {
await expectReleaseWithoutWaitingForCapture({ body, run, status });
});
it("joins sibling model-probe cleanup before rejecting with the original cancellation", async () => {
const controller = new AbortController();
const cancellation = new Error("model discovery canceled");
const firstCleanup = createDeferred<void>();
const siblingCleanup = createDeferred<void>();
const firstRelease = vi.fn(() => firstCleanup.promise);
const siblingRelease = vi.fn(() => siblingCleanup.promise);
for (const release of [firstRelease, siblingRelease]) {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: jsonResponse({ capabilities: ["completion"] }),
finalUrl: "http://127.0.0.1:11434/api/show",
release,
});
}
let settled = false;
let failure: unknown;
const completed = enrichOllamaModelsWithContext(
"http://127.0.0.1:11434",
[{ name: "first-model" }, { name: "sibling-model" }],
{ signal: controller.signal },
).then(
() => {
settled = true;
},
(error: unknown) => {
failure = error;
settled = true;
},
);
try {
await vi.waitFor(() => {
expect(firstRelease).toHaveBeenCalledOnce();
expect(siblingRelease).toHaveBeenCalledOnce();
});
controller.abort(cancellation);
firstCleanup.reject(new Error("first request closed"));
await nextTurn();
expect(settled).toBe(false);
siblingCleanup.reject(new Error("sibling request closed"));
await completed;
expect(settled).toBe(true);
expect(failure).toBe(cancellation);
} finally {
firstCleanup.resolve();
siblingCleanup.resolve();
await completed;
}
});
it.each([
{
name: "successful auth probe",

View file

@ -211,6 +211,7 @@ function createOpenCodeNodeHostBindings(api: OpenClawPluginApi) {
terminalCommand: OPENCODE_TERMINAL_RESUME_COMMAND,
sessionIdPattern: SESSION_ID_PATTERN,
executable: "opencode",
hasActiveWork: () => false,
args: (threadId) => ["--session", threadId],
listAvailable: available,
terminalAvailable: available,

View file

@ -70,6 +70,7 @@ export function createSessionShareNodeCommands(
return [
{
command: SESSION_SHARE_LIST_COMMAND,
hasActiveWork: () => false,
cap: "openclaw-sessions",
dangerous: false,
isAvailable: ({ config }) => sessionShareGroups(config).length > 0,
@ -147,6 +148,7 @@ export function createSessionShareNodeCommands(
},
{
command: SESSION_SHARE_READ_COMMAND,
hasActiveWork: () => false,
cap: "openclaw-sessions",
dangerous: false,
isAvailable: ({ config }) => sessionShareGroups(config).length > 0,

586
node-host-launcher.mjs Normal file
View file

@ -0,0 +1,586 @@
// The node supervisor must select its runtime before importing config or SQLite.
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
closeSync,
existsSync,
fstatSync,
lstatSync,
mkdirSync,
openSync,
readFileSync,
realpathSync,
renameSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const CHILD_ARGUMENT = "--openclaw-node-host-child";
const MANAGED_CHILD_ARGUMENT = "--openclaw-node-host-managed-child";
const CHILD_MARKER = Symbol.for("openclaw.node-host.launcher-child");
const MANAGED_STATE_MARKER = Symbol.for("openclaw.node-host.managed-state-path");
const RESTART_INTERVAL_MS = 12 * 60 * 60 * 1_000;
const READY_TIMEOUT_MS = 5 * 60 * 1_000;
const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level", "--container"]);
const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]);
const normalize = (value) => {
const trimmed = value?.trim();
return trimmed && trimmed !== "undefined" && trimmed !== "null" ? trimmed : undefined;
};
function parseInvocation(args, env) {
const words = [];
const rootArgs = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--") {
break;
}
if (["--help", "-h", "--version", "-V", "-v"].includes(arg)) {
return null;
}
if (arg === "--ephemeral") {
return null;
}
const [flag, ...parts] = arg.split("=");
if (flag === "--container" || normalize(env.OPENCLAW_CONTAINER)) {
return null;
}
if (ROOT_VALUE_FLAGS.has(flag)) {
const rawValue = parts.length ? parts.join("=") : args[++index];
const value = rawValue?.trim();
if (!value || value.startsWith("-")) {
return null;
}
rootArgs.push(arg, ...(parts.length ? [] : [rawValue]));
continue;
}
if (ROOT_BOOLEAN_FLAGS.has(arg)) {
rootArgs.push(arg);
continue;
}
if (words.length === 1 && words[0] === "node") {
if (flag === "--commands") {
if (!parts.length) {
index += 1;
}
continue;
}
if (arg === "--all-commands") {
continue;
}
}
words.push(arg);
}
const nodeRun = words[0] === "node" && words[1] === "run";
const connect = words[0] === "connect" && !words.includes("--service");
return nodeRun || connect ? { rootArgs } : null;
}
function readPackage(packageRoot) {
const manifest = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8"));
if (manifest.name !== "openclaw" || typeof manifest.version !== "string") {
throw new Error("The selected node runtime is not an OpenClaw package.");
}
return manifest;
}
function resolveManagedRuntime(runtimeRoot, runtimeDirectory, expectedVersion) {
const releases = realpathSync(path.join(runtimeDirectory, "releases"));
const resolved = realpathSync(runtimeRoot);
if (path.dirname(resolved) !== releases) {
throw new Error("The selected node runtime is outside the managed releases directory.");
}
const packageRoot = [
path.join(resolved, "lib", "node_modules", "openclaw"),
path.join(resolved, "node_modules", "openclaw"),
].find((candidate) => existsSync(path.join(candidate, "package.json")));
if (!packageRoot || !realpathSync(packageRoot).startsWith(`${resolved}${path.sep}`)) {
throw new Error("The selected node runtime has no private OpenClaw package.");
}
const manifest = readPackage(packageRoot);
if (expectedVersion && manifest.version !== expectedVersion) {
throw new Error("The selected node runtime version changed before activation.");
}
for (const filename of ["openclaw.mjs", "node-host-launcher.mjs"]) {
if (!existsSync(path.join(packageRoot, filename))) {
throw new Error(`The selected node runtime is missing ${filename}.`);
}
}
if (
!existsSync(path.join(packageRoot, "dist", "entry.js")) &&
!existsSync(path.join(packageRoot, "dist", "entry.mjs"))
) {
throw new Error("The selected node runtime is missing its built entry point.");
}
return {
runtimeRoot: resolved,
packageRoot,
manifest,
entryPath: path.join(packageRoot, "openclaw.mjs"),
};
}
function resolveCurrentRuntime(runtimeDirectory) {
// A Windows junction swap can leave the previous selector until publication completes.
// Retry current after the backup in case a concurrent publisher finished between reads.
for (const name of ["current", "current.previous", "current"]) {
const selector = path.join(runtimeDirectory, name);
try {
const activatedAt = lstatSync(selector).mtimeMs;
return { ...resolveManagedRuntime(selector, runtimeDirectory), activatedAt };
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
}
return undefined;
}
function assertCompatibleSchemas(previous, candidate) {
const before = previous.openclaw?.schemaVersions;
const after = candidate.openclaw?.schemaVersions;
if (
!before ||
!after ||
!Number.isInteger(before.state) ||
!Number.isInteger(before.agent) ||
before.state !== after.state ||
before.agent !== after.agent
) {
throw new Error("Automatic node activation cannot change database schema versions.");
}
}
function claimActivation(runtimeDirectory) {
mkdirSync(runtimeDirectory, { recursive: true });
const lock = path.join(runtimeDirectory, "activation.lock");
const owner = `${process.pid}:${randomUUID()}`;
let descriptor;
try {
descriptor = openSync(lock, "wx", 0o600);
} catch (error) {
if (error.code !== "EEXIST") {
throw error;
}
throw new Error(
`A node activation lock already exists at ${lock}. If no node update is running, remove that stale lock and retry.`,
{ cause: error },
);
}
const identity = fstatSync(descriptor);
try {
writeFileSync(descriptor, owner);
} finally {
closeSync(descriptor);
}
return () => {
try {
const observed = lstatSync(lock);
if (
observed.dev === identity.dev &&
observed.ino === identity.ino &&
readFileSync(lock, "utf8") === owner
) {
unlinkSync(lock);
}
} catch (error) {
if (error.code !== "ENOENT") {
process.stderr.write(
`openclaw: could not release node activation lock: ${error.message}\n`,
);
}
}
};
}
function assertRestartInterval(runtimeDirectory, lastAcceptedRestartAt) {
if (
lastAcceptedRestartAt !== undefined &&
performance.now() - lastAcceptedRestartAt < RESTART_INTERVAL_MS
) {
throw new Error("An automatic node restart was attempted within the last 12 hours.");
}
const current = resolveCurrentRuntime(runtimeDirectory);
if (current && Date.now() - current.activatedAt < RESTART_INTERVAL_MS) {
throw new Error("A node runtime was activated within the last 12 hours.");
}
}
function activateRuntime(currentPath, runtimeRoot) {
const temporary = `${currentPath}.${process.pid}.next`;
const previous = `${currentPath}.previous`;
const removePrevious = () => {
try {
unlinkSync(previous);
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
};
try {
symlinkSync(runtimeRoot, temporary, process.platform === "win32" ? "junction" : "dir");
try {
renameSync(temporary, currentPath);
} catch (error) {
if (
!["EPERM", "EACCES", "EEXIST", "ENOTEMPTY"].includes(error.code) ||
!lstatSync(currentPath, { throwIfNoEntry: false })?.isSymbolicLink()
) {
throw error;
}
// Windows cannot replace a directory junction. Keep its timestamp and target recoverable.
removePrevious();
renameSync(currentPath, previous);
try {
renameSync(temporary, currentPath);
} catch (publishError) {
try {
renameSync(previous, currentPath);
} catch (restoreError) {
throw new AggregateError(
[publishError, restoreError],
`${publishError.message}; could not restore the previous selector at ${previous}: ${restoreError.message}`,
{ cause: restoreError },
);
}
throw publishError;
}
}
try {
removePrevious();
} catch (error) {
// The new selector is committed; a later activation can clean up the backup.
process.stderr.write(
`openclaw: could not remove the previous node selector: ${error.message}\n`,
);
}
} finally {
try {
unlinkSync(temporary);
} catch (error) {
if (error.code !== "ENOENT") {
process.stderr.write(
`openclaw: could not remove the staged node selector at ${temporary}: ${error.message}\n`,
);
}
}
}
}
export const isNodeHostLauncherChild = () => process[CHILD_MARKER] === true;
export async function runNodeHostLauncher({ entryPath, packageRoot }) {
const managedChild = process.argv[2] === MANAGED_CHILD_ARGUMENT;
if (process.argv[2] === CHILD_ARGUMENT || managedChild) {
if (!process.send || !process.connected) {
throw new Error("The private node launcher argument requires a supervisor connection.");
}
const managedStatePath = managedChild ? process.argv[3] : undefined;
if (managedChild && (!managedStatePath || !path.isAbsolute(managedStatePath))) {
throw new Error(
"The private managed node launcher argument requires an absolute state database path.",
);
}
process.argv.splice(2, managedChild ? 2 : 1);
process[CHILD_MARKER] = true;
if (managedStatePath) {
process[MANAGED_STATE_MARKER] = managedStatePath;
}
process.once("disconnect", () => process.kill(process.pid, "SIGTERM"));
process.channel?.unref();
return false;
}
if (isNodeHostLauncherChild()) {
return false;
}
const invocation = parseInvocation(process.argv.slice(2), process.env);
if (
!invocation ||
existsSync(path.join(packageRoot, "src", "entry.ts")) ||
existsSync(path.join(packageRoot, ".git"))
) {
return false;
}
const {
resolveNodeHostLauncherStateDir,
compareOpenClawReleaseVersions,
resolveOpenClawStateSqlitePath,
} = await import(
pathToFileURL(path.join(packageRoot, "dist", "node-host-launcher-bootstrap.js")).href
);
const stateDir = resolveNodeHostLauncherStateDir(process.argv, process.env);
if (!stateDir) {
return false;
}
const statePath = resolveOpenClawStateSqlitePath({
...process.env,
OPENCLAW_STATE_DIR: stateDir,
});
const compareVersions = (left, right) => {
const compared = compareOpenClawReleaseVersions(left, right);
if (compared === null) {
throw new Error("The node runtime package has an invalid OpenClaw release version.");
}
return compared;
};
const runtimeDirectory = path.join(stateDir, "node-runtime");
const currentPath = path.join(runtimeDirectory, "current");
let active = { entryPath, packageRoot, manifest: readPackage(packageRoot) };
const assertCurrentVersionAdvances = (next) => {
if (compareVersions(next.manifest.version, active.manifest.version) <= 0) {
throw new Error("The selected node runtime must be newer than the running version.");
}
const current = resolveCurrentRuntime(runtimeDirectory);
if (
current &&
compareVersions(current.manifest.version, active.manifest.version) > 0 &&
compareVersions(next.manifest.version, current.manifest.version) <= 0
) {
throw new Error("Another node already selected this runtime version or a newer one.");
}
};
try {
const managed = resolveCurrentRuntime(runtimeDirectory);
// A normal operator update must be able to advance beyond a private runtime.
if (managed && compareVersions(managed.manifest.version, active.manifest.version) >= 0) {
active = managed;
}
} catch (error) {
if (error.code !== "ENOENT") {
process.stderr.write(
`openclaw: ignoring an invalid managed node runtime: ${error.message}\n`,
);
}
}
let restartArgs = process.argv.slice(2);
let childExecArgv = process.execArgv;
let childEnv = process.env;
let pending;
let lastAcceptedRestartAt;
let releaseActivation;
let stoppingSignal;
let child;
let shutdownTimer;
const signals =
process.platform === "win32"
? ["SIGTERM", "SIGINT", "SIGBREAK"]
: ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"];
const listeners = new Map();
for (const signal of signals) {
const listener = () => {
stoppingSignal ??= signal;
try {
child?.kill(signal);
} catch {
// A child can finish between signal delivery and forwarding.
}
shutdownTimer ??= setTimeout(() => child?.kill("SIGKILL"), 30_000);
shutdownTimer.unref();
};
process.on(signal, listener);
listeners.set(signal, listener);
}
try {
while (!stoppingSignal) {
let candidate;
if (pending) {
try {
const next = resolveManagedRuntime(
pending.runtimeRoot,
runtimeDirectory,
pending.manifest.version,
);
assertCompatibleSchemas(active.manifest, next.manifest);
assertCurrentVersionAdvances(next);
candidate = next;
} catch (error) {
process.stderr.write(
`openclaw: staged node runtime changed before restart: ${error.message}\n`,
);
pending = undefined;
releaseActivation?.();
releaseActivation = undefined;
}
}
const selected = candidate ?? active;
const childArguments = selected.runtimeRoot
? [MANAGED_CHILD_ARGUMENT, statePath]
: [CHILD_ARGUMENT];
let ready = false;
let candidateFailed = false;
let bootstrap;
let readyTimer;
let readyKillTimer;
const result = await new Promise((resolve, reject) => {
child = spawn(
process.execPath,
[...childExecArgv, selected.entryPath, ...childArguments, ...restartArgs],
{
env: childEnv,
stdio: ["inherit", "inherit", "inherit", "ipc"],
},
);
if (candidate) {
readyTimer = setTimeout(() => {
candidateFailed = true;
process.stderr.write(
"openclaw: updated node did not reconnect within five minutes; restoring the previous runtime.\n",
);
child.kill("SIGTERM");
readyKillTimer = setTimeout(() => child.kill("SIGKILL"), 30_000);
readyKillTimer.unref();
}, READY_TIMEOUT_MS);
readyTimer.unref();
}
child.on("message", (message) => {
if (!message || typeof message !== "object") {
return;
}
if (
message.type === "openclaw.node.bootstrap" &&
!ready &&
!candidateFailed &&
!stoppingSignal
) {
if (
Array.isArray(message.execArgv) &&
message.execArgv.every((arg) => typeof arg === "string") &&
message.env &&
typeof message.env === "object" &&
Object.values(message.env).every(
(value) => typeof value === "string" || value === undefined,
)
) {
bootstrap = { execArgv: message.execArgv, env: message.env };
child.send({ type: "openclaw.node.bootstrap-result", ok: true }, () => {});
}
return;
}
if (message.type === "openclaw.node.restart-args") {
if (
Array.isArray(message.argv) &&
message.argv.every((arg) => typeof arg === "string") &&
message.argv[0] === "node" &&
message.argv[1] === "run" &&
parseInvocation(message.argv, process.env)
) {
restartArgs = [...invocation.rootArgs, ...message.argv];
}
return;
}
if (
message.type === "openclaw.node.ready" &&
message.version === selected.manifest.version &&
!ready &&
!candidateFailed &&
!stoppingSignal
) {
clearTimeout(readyTimer);
if (candidate) {
try {
activateRuntime(currentPath, candidate.runtimeRoot);
process.stderr.write(
`openclaw: node auto-update activated ${candidate.manifest.version}\n`,
);
} catch (error) {
process.stderr.write(
`openclaw: could not record the updated node runtime: ${error.message}. The connected candidate will keep running, but its selection was not persisted; the next launch may use the previous runtime.\n`,
);
}
// Authenticated readiness already admits work; persistence failure cannot revoke it.
active = candidate;
pending = undefined;
releaseActivation?.();
releaseActivation = undefined;
}
ready = true;
return;
}
if (message.type !== "openclaw.node.restart") {
return;
}
let claimed;
try {
if (!ready || pending || stoppingSignal) {
throw new Error("The node launcher is not ready for an update restart.");
}
if (typeof message.runtimeRoot !== "string" || typeof message.version !== "string") {
throw new Error("The node restart request is missing its runtime identity.");
}
claimed = claimActivation(runtimeDirectory);
assertRestartInterval(runtimeDirectory, lastAcceptedRestartAt);
const next = resolveManagedRuntime(
message.runtimeRoot,
runtimeDirectory,
message.version,
);
assertCompatibleSchemas(active.manifest, next.manifest);
assertCurrentVersionAdvances(next);
pending = next;
releaseActivation = claimed;
lastAcceptedRestartAt = performance.now();
child.send({ type: "openclaw.node.restart-result", ok: true }, () => {});
} catch (error) {
claimed?.();
child.send(
{ type: "openclaw.node.restart-result", ok: false, error: error.message },
() => {},
);
}
});
child.on(
"error",
/** @param {Error} error */
(error) => {
if (child.pid === undefined) {
reject(error);
}
},
);
child.once("exit", (code, signal) => resolve({ code, signal }));
});
clearTimeout(readyTimer);
clearTimeout(readyKillTimer);
if (stoppingSignal) {
process.exitCode = result.code ?? 1;
break;
}
if (bootstrap && result.code === 0 && !result.signal) {
childExecArgv = bootstrap.execArgv;
childEnv = bootstrap.env;
continue;
}
if (candidate && !ready) {
process.stderr.write(
"openclaw: updated node failed to reconnect; restarting the previous runtime.\n",
);
pending = undefined;
releaseActivation?.();
releaseActivation = undefined;
continue;
}
if (pending && result.code === 0 && !result.signal) {
continue;
}
process.exitCode = result.code ?? 1;
stoppingSignal = result.signal;
break;
}
} finally {
clearTimeout(shutdownTimer);
releaseActivation?.();
for (const [signal, listener] of listeners) {
process.off(signal, listener);
}
}
if (stoppingSignal && process.platform !== "win32") {
process.kill(process.pid, stoppingSignal);
}
return true;
}

View file

@ -6,6 +6,7 @@ import module from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { isNodeHostLauncherChild, runNodeHostLauncher } from "./node-host-launcher.mjs";
import {
consumeLauncherRootOptionToken,
isForegroundGmailRunInvocation,
@ -624,6 +625,15 @@ const tryOutputPrecomputedCommandHelp = () => {
// Resolve Node before loading pending package lifecycle code or any built runtime modules.
const waitingForNodeUpdateRespawn = await ensureSupportedRuntimeVersion();
if (
!waitingForNodeUpdateRespawn &&
(await runNodeHostLauncher({
entryPath: fileURLToPath(import.meta.url),
packageRoot: fileURLToPath(new URL("./", import.meta.url)),
}))
) {
process.exit(process.exitCode ?? 0);
}
const currentNodeRuntimeFailure = process.versions.bun
? null
: nodeRuntimeFailure(process.versions.node, await detectCurrentSqliteCapabilities());
@ -660,7 +670,8 @@ if (!waitingForNodeUpdateRespawn) {
// so a timeout cannot strand a compile-cache respawn child.
const waitingForCompileCacheRespawn =
waitingForNodeUpdateRespawn ||
(!isForegroundGmailRunInvocation(process.argv) &&
(!isNodeHostLauncherChild() &&
!isForegroundGmailRunInvocation(process.argv) &&
!(process.platform !== "win32" && isNativeHookRelayInvocation(process.argv)) &&
(respawnWithoutCompileCacheIfNeeded() || respawnWithPackagedCompileCacheIfNeeded()));

View file

@ -41,6 +41,7 @@
"node-version.mjs",
"node-runtime-update.mjs",
"node-runtime-recovery.mjs",
"node-host-launcher.mjs",
"openclaw.mjs",
"pnpm-workspace.yaml",
"README.md",
@ -2035,6 +2036,7 @@
"test:e2e:agent-plugin-gateway": "node --import ./scripts/tsx.mjs scripts/agent-plugin-gateway-e2e.ts",
"test:e2e:browser-extension": "node --import ./scripts/tsx.mjs scripts/build-all.mts qaRuntime && node --import ./scripts/tsx.mjs scripts/run-with-env.mts PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers -- node --import ./scripts/tsx.mjs scripts/ensure-playwright-chromium.mts --require-playwright-chromium && node --import ./scripts/tsx.mjs scripts/run-with-env.mts PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers OPENCLAW_BROWSER_EXTENSION_E2E=1 OPENCLAW_E2E_WORKERS=1 -- node scripts/run-vitest.mjs extensions/browser/chrome-extension/bootstrap.chromium.test.ts",
"test:e2e:gateway": "node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts",
"test:e2e:node-auto-update": "node scripts/e2e/lib/node-auto-update/scenario.mjs",
"test:e2e:openshell": "node --import ./scripts/tsx.mjs scripts/run-with-env.mts OPENCLAW_E2E_OPENSHELL=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/openshell/src/backend.e2e.test.ts",
"test:extension": "node --import ./scripts/tsx.mjs scripts/test-extension.mts",
"test:extensions": "node --import ./scripts/tsx.mjs scripts/test-projects.mts extensions",

View file

@ -22,6 +22,7 @@ const targets = [
"test",
"skills",
"config",
"node-host-launcher.mjs",
"node-runtime-update.mjs",
"node-runtime-recovery.mjs",
"node-sqlite.mjs",

View file

@ -19,6 +19,7 @@ COPY node-version.mjs ./
COPY node-sqlite.mjs ./
COPY node-runtime-update.mjs ./
COPY node-runtime-recovery.mjs ./
COPY node-host-launcher.mjs ./
COPY ui/package.json ./ui/package.json
COPY packages ./packages
COPY extensions ./extensions

View file

@ -0,0 +1,41 @@
// Versioned copies of the actual candidate keep its executable code and schema contract.
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
export function packNodeUpdateFixture({ tarball, root, repository, env, version, malformed }) {
const directory = path.join(root, `variant-${version}`);
fs.mkdirSync(directory);
execFileSync("tar", ["-xzf", tarball, "-C", directory]);
const packageRoot = path.join(directory, "package");
for (const relative of ["package.json", "dist/build-info.json"]) {
const filename = path.join(packageRoot, relative);
const value = JSON.parse(fs.readFileSync(filename, "utf8"));
value.version = version;
fs.writeFileSync(filename, `${JSON.stringify(value, null, 2)}\n`);
}
if (malformed) {
fs.unlinkSync(path.join(packageRoot, "node-host-launcher.mjs"));
}
execFileSync(
process.execPath,
[
"--import",
"./scripts/tsx.mjs",
"--input-type=module",
"-e",
"import { writePackageDistInventoryForPublish } from './scripts/lib/package-dist-inventory.ts'; await writePackageDistInventoryForPublish(process.argv[1]);",
packageRoot,
],
{ cwd: repository, env, stdio: "pipe" },
);
const output = path.join(root, `openclaw-${version}.tgz`);
execFileSync("tar", ["-czf", output, "-C", directory, "package"]);
return {
output,
version,
malformed,
sha256: createHash("sha256").update(fs.readFileSync(output)).digest("hex"),
};
}

View file

@ -0,0 +1,127 @@
// A fixed workload exercises the public node-command lifecycle without a shell surface.
import fs from "node:fs";
import path from "node:path";
export function createNodeUpdateProofPlugin(root, { legacy = false } = {}) {
const id = "node-update-proof";
const command = "proof.update.work";
const directory = path.join(root, id);
// Plugin modules run from captured generations; synchronization stays in the proof owner.
const workerPath = path.join(directory, "worker.mjs");
const busyPath = path.join(root, "busy.started");
const releasePath = path.join(root, "busy.release");
fs.mkdirSync(directory);
const writeJson = (name, value) => {
fs.writeFileSync(path.join(directory, name), `${JSON.stringify(value, null, 2)}\n`);
};
writeJson("package.json", {
name: "@openclaw-test/node-update-proof",
version: "1.0.0",
type: "module",
openclaw: { extensions: ["./index.mjs"] },
});
writeJson("openclaw.plugin.json", {
id,
name: "Node update proof",
categories: ["developer-tools"],
activation: { onStartup: true },
configSchema: { type: "object", additionalProperties: false, properties: {} },
});
fs.writeFileSync(
workerPath,
`
import fs from "node:fs";
const action = process.argv[2];
if (action === "ping") {
console.log(JSON.stringify({ marker: "NODE_UPDATE_PING_OK", pid: process.pid }));
} else if (action === "hold") {
fs.writeFileSync(${JSON.stringify(busyPath)}, String(process.pid));
const timer = setInterval(() => {
if (fs.existsSync(${JSON.stringify(releasePath)})) {
clearInterval(timer);
console.log(JSON.stringify({ marker: "NODE_UPDATE_HOLD_COMPLETED", pid: process.pid }));
}
}, 100);
} else {
throw new Error("Unknown fixed workload action");
}
`,
);
fs.writeFileSync(
path.join(directory, "index.mjs"),
`
import { spawn } from "node:child_process";
const active = new Set();
export default {
id: ${JSON.stringify(id)},
register(api) {
api.registerNodeHostCommand({
command: ${JSON.stringify(command)}, cap: "proof-update",
${legacy ? "" : "hasActiveWork: () => active.size > 0,"}
async onDisconnect() {
for (const run of active) run.child.kill("SIGTERM");
await Promise.allSettled([...active].map((run) => run.done));
},
async handle(paramsJSON, _io, context) {
const params = JSON.parse(paramsJSON ?? "{}");
if (!params || typeof params !== "object" || Object.keys(params).length !== 1 ||
(params.action !== "hold" && params.action !== "ping")) {
throw new Error("Expected exactly one fixed action: hold or ping");
}
api.logger.info("[node-update-proof] handler action=" + params.action);
const authorize = context?.prepareExecAuthorization?.("session-full");
if (!authorize) throw new Error("Node execution authorization is unavailable");
authorize();
api.logger.info("[node-update-proof] authorized action=" + params.action);
const child = spawn(process.execPath, [${JSON.stringify(workerPath)}, params.action], {
env: {}, stdio: ["ignore", "pipe", "pipe"],
});
api.logger.info("[node-update-proof] child action=" + params.action + " pid=" + child.pid);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
const done = new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => {
api.logger.info("[node-update-proof] child-exit action=" + params.action + " code=" + code + " signal=" + signal);
if (code === 0) resolve(stdout.trim());
else reject(new Error("Fixed workload failed: " + (signal ?? code) + " " + stderr));
});
});
const run = { child, done };
active.add(run);
const abort = () => child.kill("SIGTERM");
context.signal?.addEventListener("abort", abort, { once: true });
if (context.signal?.aborted) abort();
const cleanup = () => {
context.signal?.removeEventListener("abort", abort);
active.delete(run);
};
void done.then(cleanup, cleanup);
if (${legacy} && params.action === "hold") {
return JSON.stringify({
marker: "NODE_UPDATE_HOLD_STARTED", pid: child.pid,
hostPid: process.pid, hostArgv: [...process.argv],
});
}
const workload = JSON.parse(await done);
return JSON.stringify({ ...workload, hostPid: process.pid, hostArgv: [...process.argv] });
},
});
},
};
`,
);
return {
command,
busyPath,
releasePath,
plugins: {
enabled: true,
allow: [id],
load: { paths: [directory] },
entries: { [id]: { enabled: true } },
},
};
}

View file

@ -0,0 +1,925 @@
#!/usr/bin/env node
// Installed-package proof: real Gateway, paired node, npm, and supervisor processes.
import assert from "node:assert/strict";
import { spawn, execFileSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { once } from "node:events";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { packNodeUpdateFixture } from "./package-fixtures.mjs";
import { createNodeUpdateProofPlugin } from "./proof-plugin.mjs";
const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
const [inputTarball, requestedRoot] = process.argv.slice(2);
assert(inputTarball, "usage: scenario.mjs <built-openclaw.tgz> [new-artifact-directory]");
assert.equal(process.platform, "linux", "this Crabbox proof targets Linux");
const tarball = fs.realpathSync(inputTarball);
const root = requestedRoot
? path.resolve(requestedRoot)
: fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-auto-update-proof-"));
if (requestedRoot) {
fs.mkdirSync(root);
}
assert(
!root.startsWith(`${repository}${path.sep}`),
"keep proof state outside the synced checkout",
);
const children = new Set();
const servers = new Set();
const observations = [];
const token = `node-auto-update-proof-${randomUUID()}`;
const upstream =
process.env.NPM_CONFIG_REGISTRY ??
process.env.npm_config_registry ??
"https://registry.npmjs.org";
const cleanEnv = Object.fromEntries(
["PATH", "TMPDIR", "LANG", "LC_ALL", "USER", "LOGNAME", "SHELL"].flatMap((key) =>
process.env[key] === undefined ? [] : [[key, process.env[key]]],
),
);
Object.assign(cleanEnv, {
CI: "1",
NO_COLOR: "1",
OPENCLAW_DISABLE_BONJOUR: "1",
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_PROVIDERS: "1",
OPENCLAW_NO_ONBOARD: "1",
npm_config_cache: path.join(root, "npm-cache"),
npm_config_audit: "false",
npm_config_fund: "false",
NPM_CONFIG_REGISTRY: upstream,
});
function record(event, facts = {}) {
const row = { at: new Date().toISOString(), event, ...facts };
observations.push(row);
fs.writeFileSync(
path.join(root, "observations.json"),
`${JSON.stringify(observations, null, 2)}\n`,
);
console.log(JSON.stringify(row));
}
function start(label, executable, args, env, captureOutput = false) {
const logPath = path.join(root, `${label}.log`);
const output = fs.openSync(logPath, "a");
const child = spawn(executable, args, {
env,
stdio: ["ignore", captureOutput ? "pipe" : output, output],
detached: true,
});
fs.closeSync(output);
let stdout = "";
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString();
fs.appendFileSync(logPath, chunk);
});
children.add(child);
record("phase-start", { label, pid: child.pid });
child.once("exit", (code, signal) => {
children.delete(child);
record("phase-exit", { label, code, signal });
});
child.once("error", (error) => record("process-error", { label, message: error.message }));
return { child, logPath, stdout: () => stdout };
}
async function stop(processInfo) {
if (
!processInfo ||
processInfo.child.exitCode !== null ||
processInfo.child.signalCode !== null
) {
return;
}
const child = processInfo.child;
const exited = once(child, "exit");
process.kill(-child.pid, "SIGTERM");
const deadline = setTimeout(() => {
try {
process.kill(-child.pid, "SIGKILL");
} catch (error) {
if (error.code !== "ESRCH") {
throw error;
}
}
}, 15_000);
try {
await exited;
} finally {
clearTimeout(deadline);
}
}
async function command(label, executable, args, env, timeoutMs = 120_000) {
const running = start(label, executable, args, env, true);
const deadline = setTimeout(() => void stop(running), timeoutMs);
try {
const [code, signal] = await once(running.child, "exit");
const output = fs.readFileSync(running.logPath, "utf8");
assert.equal(code, 0, `${label} failed (${signal ?? code}): ${output.slice(-12_000)}`);
return running.stdout();
} finally {
clearTimeout(deadline);
}
}
/** @param {() => Error | undefined} [failure] */
async function waitFor(label, observe, timeoutMs = 120_000, failure = () => undefined) {
const until = Date.now() + timeoutMs;
let lastError;
while (Date.now() < until) {
const fatal = failure();
if (fatal) {
throw fatal;
}
try {
const result = await observe();
if (result) {
return result;
}
} catch (error) {
lastError = error;
}
await delay(500);
}
throw new Error(`Timed out waiting for ${label}${lastError ? `: ${lastError.message}` : ""}`);
}
function readNodeUpdateFailure(logPath) {
const log = fs.readFileSync(logPath, "utf8");
const failure = log.match(
/^(?:\w*Error:|node auto-update stopped:|\[openclaw\] Reason:|openclaw: (?:staged node runtime changed|updated node (?:did not reconnect|failed to reconnect)|could not record the updated node runtime)).*$/mu,
);
return failure
? new Error(`Node auto-update failed:\n${log.slice(failure.index).trim()}`)
: undefined;
}
function jsonOutput(output) {
const startIndex = output.search(/^[\x5b{]/m);
assert(startIndex >= 0, `missing JSON output: ${output.slice(-2_000)}`);
return JSON.parse(output.slice(startIndex));
}
function fileHash(file) {
return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
function processChildren(pid) {
return fs
.readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8")
.trim()
.split(/\s+/)
.filter(Boolean)
.map(Number);
}
async function listen(server) {
servers.add(server);
server.listen(0, "127.0.0.1");
await once(server, "listening");
return server.address().port;
}
let selectedVersion;
let metadataReleased = false;
let metadataRequests = 0;
const registryRequests = [];
const servedMetadata = [];
let cliEntry;
let gatewayEnv;
let gatewayPort;
let proofPlugin;
async function cli(label, args, env = gatewayEnv, timeoutMs = 120_000) {
return jsonOutput(
await command(label, process.execPath, [cliEntry, ...args, "--json"], env, timeoutMs),
);
}
async function nodes() {
return (await cli(`nodes-${Date.now()}`, ["nodes", "status"])).nodes;
}
async function nodeRow(name) {
return (await nodes()).find((row) => row.displayName === name && row.connected && row.paired);
}
async function invoke(nodeId, action = "ping") {
return await cli(
`workload-${action}-${Date.now()}`,
[
"nodes",
"invoke",
"--node",
nodeId,
"--command",
proofPlugin.command,
"--params",
JSON.stringify({ action }),
"--invoke-timeout",
action === "hold" ? "900000" : "15000",
],
gatewayEnv,
action === "hold" ? 930_000 : 45_000,
);
}
function writeConfig(name, value) {
const home = path.join(root, name);
const state = path.join(home, ".openclaw");
fs.mkdirSync(state, { recursive: true });
const config = path.join(state, "openclaw.json");
fs.writeFileSync(config, `${JSON.stringify(value, null, 2)}\n`);
return {
...cleanEnv,
OPENCLAW_DEBUG: "1",
HOME: home,
OPENCLAW_STATE_DIR: state,
OPENCLAW_CONFIG_PATH: config,
};
}
async function startNode(name, registryUrl, options = {}) {
const plugin = options.plugin ?? proofPlugin;
const env = options.sharedEnv
? { ...options.sharedEnv }
: writeConfig(name, {
nodeHost: {
autoUpdate: { enabled: options.enabled !== false },
browserProxy: { enabled: false },
skills: { enabled: false },
},
plugins: plugin.plugins,
tools: { exec: { mode: "full" } },
update: { channel: "stable", checkOnStart: options.checkOnStart !== false },
});
delete env.OPENCLAW_NO_AUTO_UPDATE;
Object.assign(env, { OPENCLAW_GATEWAY_TOKEN: token, NPM_CONFIG_REGISTRY: registryUrl });
if (options.noAutoEnv) {
env.OPENCLAW_NO_AUTO_UPDATE = "1";
}
const commands = [plugin.command, "system.which"].toSorted((left, right) =>
left.localeCompare(right),
);
const args = [
"node",
"run",
"--host",
"127.0.0.1",
"--port",
String(gatewayPort),
"--display-name",
name,
"--node-id",
`${name}-instance`,
"--commands",
commands.join(","),
"--no-tls",
"--no-share-installed-apps",
];
const running = start(name, process.execPath, [cliEntry, ...args], env);
const row = await waitFor(`${name} paired with its approved command manifest`, async () => {
assert.equal(
running.child.exitCode,
null,
`${name} exited: ${fs.readFileSync(running.logPath, "utf8")}`,
);
const pending = await cli(`${name}-pending-${Date.now()}`, ["nodes", "pending"]);
const request = pending.find((entry) => entry.displayName === name);
if (request) {
await cli(`${name}-approve`, ["nodes", "approve", request.requestId]);
}
const connected = await nodeRow(name);
assert(connected, `${name} is not paired and connected yet`);
assert.deepEqual(
connected.commands.toSorted((left, right) => left.localeCompare(right)),
commands,
);
return connected;
});
const identity = await cli(`${name}-identity`, ["node", "identity"], env);
return { ...running, env, name, row, identity, commands, args };
}
async function startGateway(name, sharedNodeState = false) {
const portProbe = http.createServer();
gatewayPort = await listen(portProbe);
await new Promise((resolve) => {
portProbe.close(resolve);
});
gatewayEnv = writeConfig(name, {
gateway: {
mode: "local",
port: gatewayPort,
bind: "loopback",
auth: { mode: "token", token },
controlUi: { enabled: false },
nodes: {
pairing: { autoApproveCidrs: ["127.0.0.1/32"], sshVerify: false },
commands: { allow: [proofPlugin.command] },
},
},
agents: {
defaults: {
workspace: path.join(root, `${name}-workspace`),
model: { primary: "openai/gpt-5.6-sol" },
},
},
tools: { exec: { mode: "full" } },
plugins: proofPlugin.plugins,
browser: { enabled: false },
nodeHost: {
autoUpdate: { enabled: true },
browserProxy: { enabled: false },
skills: { enabled: false },
},
update: { channel: "stable", checkOnStart: sharedNodeState },
});
gatewayEnv.OPENCLAW_GATEWAY_TOKEN = token;
gatewayEnv.OPENCLAW_NO_AUTO_UPDATE = "1";
const gateway = start(
name,
process.execPath,
[cliEntry, "gateway", "run", "--port", String(gatewayPort)],
gatewayEnv,
);
await waitFor(
`${name} health`,
async () => (await fetch(`http://127.0.0.1:${gatewayPort}/healthz`)).ok,
);
return gateway;
}
try {
record("proof-started", { root, sourceTarball: tarball, sourceSha256: fileHash(tarball) });
proofPlugin = createNodeUpdateProofPlugin(root);
const manifest = JSON.parse(
execFileSync("tar", ["-xOf", tarball, "package/package.json"], { encoding: "utf8" }),
);
const [year, month] = manifest.version.split(".").map(Number);
const nextYear = month === 12 ? year + 1 : year;
const nextMonth = month === 12 ? 1 : month + 1;
const versions = [1, 2, 3].map((day) => `${nextYear}.${nextMonth}.${day}`);
const variants = versions.map((version, index) => {
const fixture = packNodeUpdateFixture({
tarball,
root,
repository,
env: cleanEnv,
version,
malformed: index === 2,
});
record("fixture-packed", fixture);
return fixture.output;
});
selectedVersion = versions[0];
const portFile = path.join(root, "registry.port");
const registry = start(
"registry",
process.execPath,
[
path.join(repository, "scripts/e2e/lib/plugins/npm-registry-server.mjs"),
portFile,
...versions.flatMap((version, index) => ["openclaw", version, variants[index]]),
],
{ ...cleanEnv, OPENCLAW_NPM_REGISTRY_UPSTREAM: upstream },
);
await waitFor("fixture registry", () => fs.existsSync(portFile));
const registryUrl = `http://127.0.0.1:${fs.readFileSync(portFile, "utf8").trim()}`;
const proxy = http.createServer((request, response) => {
void (async () => {
const isCoreMetadata = request.url === "/openclaw";
registryRequests.push({ at: Date.now(), url: request.url });
if (isCoreMetadata) {
metadataRequests += 1;
await waitFor("release metadata barrier", () => metadataReleased, 180_000);
}
const fetched = await fetch(`${registryUrl}${request.url}`, {
headers: { host: request.headers.host },
signal: AbortSignal.timeout(180_000),
});
if (isCoreMetadata) {
const value = await fetched.json();
value["dist-tags"] = { latest: selectedVersion };
servedMetadata.push({ at: Date.now(), version: selectedVersion });
response.writeHead(fetched.status, { "content-type": "application/json" });
response.end(JSON.stringify(value));
} else {
response.writeHead(fetched.status, {
"content-type": fetched.headers.get("content-type") ?? "application/octet-stream",
});
await pipeline(Readable.fromWeb(fetched.body), response);
}
})().catch((/** @type {unknown} */ error) => {
const failure = error instanceof Error ? error : new Error(String(error));
if (response.headersSent) {
response.destroy(failure);
} else {
response.writeHead(500);
response.end(failure.message);
}
});
});
const proxyUrl = `http://127.0.0.1:${await listen(proxy)}`;
const installEnv = writeConfig("installer", {});
const prefix = path.join(root, "global-prefix");
await command(
"install",
"npm",
["install", "--global", "--prefix", prefix, tarball, "--no-audit", "--no-fund"],
installEnv,
900_000,
);
cliEntry = path.join(prefix, "lib/node_modules/openclaw/openclaw.mjs");
const globalManifest = path.join(prefix, "lib/node_modules/openclaw/package.json");
const globalHash = fileHash(globalManifest);
const gateway = await startGateway("gateway");
const positive = await startNode("node-positive", proxyUrl);
const stockCommand = await cli(
"node-stock-command-preflight",
[
"nodes",
"invoke",
"--node",
positive.row.nodeId,
"--command",
"system.which",
"--params",
JSON.stringify({ bins: ["node"] }),
"--invoke-timeout",
"15000",
],
gatewayEnv,
45_000,
);
assert.equal(stockCommand.ok, true);
assert.equal(typeof stockCommand.payload?.bins?.node, "string");
assert(JSON.stringify(await invoke(positive.row.nodeId)).includes("NODE_UPDATE_PING_OK"));
record("node-workload-preflight-passed", {
stockCommand: "system.which",
fixedChildExecuted: true,
});
const { busyPath, releasePath } = proofPlugin;
/** @type {Error | undefined} */
let workloadFailure;
let workloadCompleted = false;
const busy = invoke(positive.row.nodeId, "hold")
.then((result) => {
workloadCompleted = true;
return result;
})
.catch((/** @type {unknown} */ error) => {
workloadFailure = error instanceof Error ? error : new Error(String(error));
record("workload-rejected", { message: workloadFailure.message });
});
const requireHolding = () =>
workloadFailure ??
(workloadCompleted ? new Error("Fixed workload completed before release") : undefined) ??
readNodeUpdateFailure(positive.logPath);
await waitFor(
"real fixed node child workload",
() => fs.existsSync(busyPath),
120_000,
requireHolding,
);
metadataReleased = true;
record("busy-command-running", {
nodeId: positive.row.nodeId,
version: positive.row.version,
pid: Number(fs.readFileSync(busyPath, "utf8")),
});
await waitFor(
"prepared update defers while busy",
() =>
/ready; waiting for active work to finish/i.test(fs.readFileSync(positive.logPath, "utf8")),
900_000,
requireHolding,
);
assert.equal((await nodeRow(positive.name)).version, manifest.version);
assert(!fs.existsSync(path.join(positive.env.OPENCLAW_STATE_DIR, "node-runtime/current")));
record("busy-update-deferred", { metadataRequests, originalVersion: manifest.version });
selectedVersion = versions[1];
fs.writeFileSync(releasePath, "release\n");
const completed = await busy;
if (workloadFailure) {
throw workloadFailure;
}
assert(JSON.stringify(completed).includes("NODE_UPDATE_HOLD_COMPLETED"));
const updated = await waitFor(
"automatic version activation",
async () => {
const row = await nodeRow(positive.name);
return row?.version === versions[0] ? row : null;
},
180_000,
() => readNodeUpdateFailure(positive.logPath),
);
assert.equal(updated.nodeId, positive.row.nodeId);
assert.deepEqual(
updated.commands.toSorted((left, right) => left.localeCompare(right)),
positive.commands,
);
assert.deepEqual(
await cli("identity-after-update", ["node", "identity"], positive.env),
positive.identity,
);
const healthy = await invoke(updated.nodeId);
assert(JSON.stringify(healthy).includes("NODE_UPDATE_PING_OK"));
const { hostPid, hostArgv: updatedArguments } = healthy.payload;
assert(Number.isSafeInteger(hostPid) && hostPid > 0);
assert(Array.isArray(updatedArguments));
assert(
processChildren(positive.child.pid).includes(hostPid),
"updated runtime is not owned by the original supervisor",
);
assert(
updatedArguments.some(
(arg) =>
typeof arg === "string" && arg.includes(`${versions[0]}-`) && arg.endsWith("/openclaw.mjs"),
),
"supervisor did not run the private package entrypoint",
);
for (const flag of ["--host", "--port", "--node-id", "--display-name", "--commands"]) {
assert.equal(
updatedArguments[updatedArguments.indexOf(flag) + 1],
positive.args[positive.args.indexOf(flag) + 1],
`restart changed ${flag}`,
);
}
assert(updatedArguments.includes("--no-tls"));
assert(updatedArguments.includes("--no-share-installed-apps"));
record("automatic-update-reconnected", {
before: manifest.version,
after: updated.version,
sameNodeId: true,
sameIdentity: true,
sameCommands: true,
sameLaunchOptions: true,
runtimePid: hostPid,
sameSupervisor: true,
});
await waitFor("updated child discovers a second release", () =>
servedMetadata.some((entry) => entry.version === versions[1]),
);
await delay(2_000);
assert.equal((await nodeRow(positive.name)).version, versions[0]);
const currentPath = path.join(positive.env.OPENCLAW_STATE_DIR, "node-runtime/current");
assert(fs.readlinkSync(currentPath).includes(`${versions[0]}-`));
assert(
!fs
.readdirSync(path.join(positive.env.OPENCLAW_STATE_DIR, "node-runtime/releases"))
.some((entry) => entry.startsWith(`${versions[1]}-`)),
);
assert.equal(fileHash(globalManifest), globalHash);
assert.equal(gateway.child.exitCode, null);
assert((await fetch(`http://127.0.0.1:${gatewayPort}/healthz`)).ok);
record("cooldown-and-global-isolation", {
retainedVersion: versions[0],
gatewayPid: gateway.child.pid,
globalVersion: manifest.version,
});
await stop(positive);
const legacyRoot = path.join(root, "legacy-plugin");
fs.mkdirSync(legacyRoot);
const legacyPlugin = createNodeUpdateProofPlugin(legacyRoot, { legacy: true });
selectedVersion = versions[0];
metadataReleased = false;
const legacy = await startNode("node-legacy-plugin", proxyUrl, { plugin: legacyPlugin });
const retained = await invoke(legacy.row.nodeId, "hold");
assert.equal(retained.ok, true);
assert.equal(retained.payload.marker, "NODE_UPDATE_HOLD_STARTED");
const { pid: retainedPid, hostPid: legacyHostPid, hostArgv: legacyArguments } = retained.payload;
assert(Number.isSafeInteger(retainedPid) && retainedPid > 0);
assert(Number.isSafeInteger(legacyHostPid) && legacyHostPid > 0);
const requireLegacyRuntime = () => {
if (!fs.existsSync(`/proc/${legacyHostPid}`)) {
return new Error("Legacy plugin runtime restarted without an idle declaration");
}
return readNodeUpdateFailure(legacy.logPath);
};
const requireLegacyHolding = () =>
requireLegacyRuntime() ??
(!fs.existsSync(`/proc/${retainedPid}`)
? new Error("Legacy plugin child exited before release")
: undefined);
await waitFor(
"legacy command returns with a retained child",
() =>
fs.existsSync(legacyPlugin.busyPath) &&
Number(fs.readFileSync(legacyPlugin.busyPath, "utf8")) === retainedPid,
120_000,
requireLegacyHolding,
);
assert(processChildren(legacy.child.pid).includes(legacyHostPid));
assert(processChildren(legacyHostPid).includes(retainedPid));
metadataReleased = true;
await waitFor(
"legacy plugin defers the prepared update after its command returns",
() => /ready; waiting for active work to finish/i.test(fs.readFileSync(legacy.logPath, "utf8")),
900_000,
requireLegacyHolding,
);
const assertLegacyRetained = async () => {
assert.equal(legacy.child.exitCode, null);
assert.equal(legacy.child.signalCode, null);
assert(processChildren(legacy.child.pid).includes(legacyHostPid));
const row = await nodeRow(legacy.name);
assert.equal(row.nodeId, legacy.row.nodeId);
assert.equal(row.version, manifest.version);
const ping = await invoke(legacy.row.nodeId);
assert.equal(ping.payload.marker, "NODE_UPDATE_PING_OK");
assert.equal(ping.payload.hostPid, legacyHostPid);
assert.deepEqual(ping.payload.hostArgv, legacyArguments);
assert(!fs.existsSync(path.join(legacy.env.OPENCLAW_STATE_DIR, "node-runtime/current")));
};
await assertLegacyRetained();
assert(processChildren(legacyHostPid).includes(retainedPid));
assert.deepEqual(
await cli("legacy-identity-after-deferral", ["node", "identity"], legacy.env),
legacy.identity,
);
record("legacy-plugin-retained-work-deferred", {
nodeId: legacy.row.nodeId,
retainedChildPid: retainedPid,
runtimePid: legacyHostPid,
supervisorPid: legacy.child.pid,
connectedVersion: manifest.version,
sameIdentity: true,
commandReturned: true,
});
fs.writeFileSync(legacyPlugin.releasePath, "release\n");
await waitFor(
"legacy retained child completes and is reaped",
() =>
!fs.existsSync(`/proc/${retainedPid}`) &&
fs.readFileSync(legacy.logPath, "utf8").includes("child-exit action=hold code=0 signal=null"),
120_000,
requireLegacyRuntime,
);
// Cross the next idle retry with no command or child work left to mask the missing hook.
await delay(35_000);
await assertLegacyRetained();
record("legacy-plugin-missing-idle-hook-stays-deferred", {
connectedVersion: manifest.version,
runtimePid: legacyHostPid,
childReaped: true,
});
await stop(legacy);
assert(!fs.existsSync(`/proc/${legacyHostPid}`));
assert(!fs.existsSync(`/proc/${retainedPid}`));
for (const { name, options } of [
{ name: "node-optout", options: { enabled: false } },
{ name: "node-startup-optout", options: { checkOnStart: false } },
{ name: "node-env-optout", options: { noAutoEnv: true } },
]) {
const requestsBefore = metadataRequests;
const optedOut = await startNode(name, proxyUrl, options);
assert(JSON.stringify(await invoke(optedOut.row.nodeId)).includes("NODE_UPDATE_PING_OK"));
await delay(2_000);
assert.equal(metadataRequests, requestsBefore, `${name} queried release metadata`);
assert.equal((await nodeRow(name)).version, manifest.version);
record("optout-respected", { name, registryRequests: 0, connectedVersion: manifest.version });
await stop(optedOut);
}
selectedVersion = versions[2];
const negative = await startNode("node-malformed-candidate", proxyUrl);
await waitFor(
"malformed candidate rejection",
() =>
/missing.*node-host-launcher|ENOENT.*node-host-launcher/i.test(
fs.readFileSync(negative.logPath, "utf8"),
),
900_000,
);
assert.equal((await nodeRow(negative.name)).version, manifest.version);
assert(JSON.stringify(await invoke(negative.row.nodeId)).includes("NODE_UPDATE_PING_OK"));
assert(!fs.existsSync(path.join(negative.env.OPENCLAW_STATE_DIR, "node-runtime/current")));
assert.equal(fileHash(globalManifest), globalHash);
record("malformed-candidate-kept-old-node", {
connectedVersion: manifest.version,
commandSucceeded: true,
});
await stop(negative);
const primaryGateway = { env: gatewayEnv, port: gatewayPort };
selectedVersion = versions[0];
metadataReleased = false;
const sharedGateway = await startGateway("gateway-shared-state", true);
const sharedNode = await startNode("node-shared-state", proxyUrl, { sharedEnv: gatewayEnv });
assert.equal(sharedNode.row.gatewayLocal, true);
assert.equal(sharedNode.row.version, manifest.version);
const sharedGatewayBefore = await cli("shared-gateway-info-before", [
"gateway",
"call",
"system.info",
]);
const sharedPresenceBefore = await cli("shared-presence-before", [
"gateway",
"call",
"system-presence",
]);
const sharedGatewayVersion = sharedPresenceBefore.find(
(entry) => entry.mode === "gateway" && entry.reason === "self",
)?.version;
assert.equal(sharedGatewayVersion, manifest.version);
const sharedConfigHash = fileHash(gatewayEnv.OPENCLAW_CONFIG_PATH);
assert(JSON.stringify(await invoke(sharedNode.row.nodeId)).includes("NODE_UPDATE_PING_OK"));
metadataReleased = true;
const sharedUpdated = await waitFor(
"shared-state node activates independently",
async () => {
const row = await nodeRow(sharedNode.name);
return row?.version === versions[0] ? row : null;
},
900_000,
() => readNodeUpdateFailure(sharedNode.logPath),
);
assert.equal(sharedUpdated.nodeId, sharedNode.row.nodeId);
assert.equal(sharedUpdated.gatewayLocal, true);
assert.deepEqual(
await cli("shared-identity-after", ["node", "identity"], sharedNode.env),
sharedNode.identity,
);
assert(JSON.stringify(await invoke(sharedUpdated.nodeId)).includes("NODE_UPDATE_PING_OK"));
const sharedGatewayAfter = await cli("shared-gateway-info-after", [
"gateway",
"call",
"system.info",
]);
const sharedPresenceAfter = await cli("shared-presence-after", [
"gateway",
"call",
"system-presence",
]);
assert.equal(sharedGatewayAfter.pid, sharedGatewayBefore.pid);
assert.equal(sharedGatewayAfter.processInstanceId, sharedGatewayBefore.processInstanceId);
assert.equal(
sharedPresenceAfter.find((entry) => entry.mode === "gateway" && entry.reason === "self")
?.version,
manifest.version,
);
assert.equal(sharedGateway.child.exitCode, null);
assert.equal(fileHash(gatewayEnv.OPENCLAW_CONFIG_PATH), sharedConfigHash);
assert.equal(fileHash(globalManifest), globalHash);
record("shared-state-node-updated-independently", {
gatewayVersion: manifest.version,
gatewayPid: sharedGatewayAfter.pid,
nodeVersion: sharedUpdated.version,
sameNodeId: true,
gatewayLocal: true,
sameGatewayProcess: true,
sharedConfigUnchanged: true,
});
await stop(sharedNode);
await stop(sharedGateway);
gatewayEnv = primaryGateway.env;
gatewayPort = primaryGateway.port;
const publishedVersion = "2026.9.4";
const publishedPrefix = path.join(root, "published-driver-prefix");
const publishedPackage = path.join(publishedPrefix, "lib/node_modules/openclaw");
const publishedPortProbe = http.createServer();
const publishedGatewayPort = await listen(publishedPortProbe);
await new Promise((resolve) => {
publishedPortProbe.close(resolve);
});
const publishedEnv = writeConfig("published-driver", {
gateway: { mode: "local", port: publishedGatewayPort, auth: { mode: "token", token } },
agents: { defaults: { model: { primary: "openai/gpt-5.6-sol" } } },
plugins: { enabled: false },
update: { checkOnStart: false },
});
Object.assign(publishedEnv, {
NPM_CONFIG_REGISTRY: "https://registry.npmjs.org",
NPM_CONFIG_PREFIX: publishedPrefix,
PATH: `${publishedPrefix}/bin:${cleanEnv.PATH}`,
OPENCLAW_ALLOW_ROOT: "1",
});
const publishedIdentity = jsonOutput(
await command(
"published-driver-registry-identity",
"npm",
["view", `openclaw@${publishedVersion}`, "version", "dist", "--json"],
publishedEnv,
),
);
assert.equal(publishedIdentity.version, publishedVersion);
assert(publishedIdentity.dist.integrity);
await command(
"published-driver-install",
"npm",
[
"install",
"--global",
"--prefix",
publishedPrefix,
`openclaw@${publishedVersion}`,
"--no-audit",
"--no-fund",
],
publishedEnv,
900_000,
);
const publishedEntry = path.join(publishedPackage, "openclaw.mjs");
assert(
(
await command(
"published-driver-version-before",
process.execPath,
[publishedEntry, "--version"],
publishedEnv,
)
).includes(publishedVersion),
);
publishedEnv.NPM_CONFIG_REGISTRY = proxyUrl;
selectedVersion = versions[0];
const updatedByPublishedDriver = jsonOutput(
await command(
"published-driver-update",
process.execPath,
[publishedEntry, "update", "--tag", versions[0], "--yes", "--json", "--no-restart"],
publishedEnv,
900_000,
),
);
assert.equal(updatedByPublishedDriver.status, "ok");
assert.equal(updatedByPublishedDriver.root, publishedPackage);
assert.equal(
JSON.parse(fs.readFileSync(path.join(publishedPackage, "package.json"), "utf8")).version,
versions[0],
);
assert.equal(
fileHash(path.join(publishedPackage, "node-host-launcher.mjs")),
fileHash(path.join(root, `variant-${versions[0]}`, "package/node-host-launcher.mjs")),
);
assert(
(
await command(
"published-driver-version-after",
process.execPath,
[publishedEntry, "--version"],
publishedEnv,
)
).includes(versions[0]),
);
assert.equal(fileHash(globalManifest), globalHash);
assert.equal(gateway.child.exitCode, null);
record("published-driver-installed-candidate", {
before: publishedVersion,
after: versions[0],
publishedTarball: publishedIdentity.dist.tarball,
publishedIntegrity: publishedIdentity.dist.integrity,
launcherVerified: true,
isolatedPrefix: publishedPrefix,
});
await stop(gateway);
await stop(registry);
record("proof-passed", {
checks: [
"busy-deferral",
"legacy-plugin-retained-work-deferral",
"legacy-plugin-missing-idle-hook-deferral",
"idle-activation",
"pairing-preserved",
"launch-surface-preserved",
"12-hour-cooldown",
"three-optouts",
"malformed-candidate",
"global-and-gateway-isolation",
"same-state-gateway-and-node",
"published-driver-to-candidate",
],
});
} catch (error) {
record("proof-failed", { message: error.message, stack: error.stack });
process.exitCode = 1;
} finally {
metadataReleased = true;
for (const child of children) {
await stop({ child });
}
for (const server of servers) {
server.closeAllConnections();
if (server.listening) {
await new Promise((resolve) => {
server.close(resolve);
});
}
}
fs.writeFileSync(
path.join(root, "registry-requests.json"),
`${JSON.stringify({ requests: registryRequests, metadata: servedMetadata }, null, 2)}\n`,
);
record("cleanup-complete", { remainingChildren: children.size, artifacts: root });
}

View file

@ -21,7 +21,10 @@ import {
legacyFinalizerBuildSources,
vitestWorkerBuildEntries,
} from "./vitest-worker-build-entries.mts";
import { vitestWorkerDeclarationEntries } from "./vitest-worker-declarations.mts";
import {
vitestWorkerDeclarationEntries,
vitestWorkerRuntimeAssets,
} from "./vitest-worker-declarations.mts";
const root = fileURLToPath(new URL("../../", import.meta.url));
const require = createRequire(import.meta.url);
@ -191,6 +194,16 @@ async function compileVitestWorkerArtifacts(directory: string): Promise<void> {
for (const name of Object.keys(entry)) {
fs.accessSync(path.join(directory, "dist", `${name}.js`));
}
for (const asset of vitestWorkerRuntimeAssets) {
const source = path.join(root, asset);
const destination = path.join(directory, asset);
const contents = fs.readFileSync(source);
const hash = hashVitestWorkerArtifact(contents);
inputs[source] ??= hash;
fs.writeFileSync(destination, contents, { flag: "wx" });
// Output paths stay relative to dist, including package-root runtime assets.
outputs[path.relative(outDir, destination).replaceAll("\\", "/")] = hash;
}
// Version consumers need the built source identity without making this
// disposable generation a competing OpenClaw installation root.
const buildInfo = `${JSON.stringify(resolveBuildInfo({ rootDir: root }), null, 2)}\n`;

View file

@ -2,6 +2,9 @@
export const nativeSchtasksIntegrationEnabled =
process.platform === "win32" && process.env.CI_WINDOWS_SCHTASKS_INTEGRATION === "1";
// The CLI loads this package-root supervisor by URL instead of bundling it.
export const vitestWorkerRuntimeAssets = ["node-host-launcher.mjs"];
export const runtimeProcessDeclarationEntries = {
"extensions/memory-core/manager-cpu-entrypoints":
"extensions/memory-core/src/memory/manager-cpu-entrypoints.ts",

View file

@ -512,6 +512,7 @@ src/infra/bun-sqlite-library.ts
src/infra/clawhub-integrity.ts
src/infra/clawhub-spec.ts
src/infra/command-carriers.ts
src/infra/config-dir.ts
src/infra/container-env-file.ts
src/infra/crypto-digest.ts
src/infra/dedupe.ts
@ -533,6 +534,7 @@ src/infra/diagnostics-timeline.ts
src/infra/directory-durability.ts
src/infra/disk-space.ts
src/infra/dispatch-wrapper-resolution.ts
src/infra/dotenv-global-core.ts
src/infra/dotenv-global.ts
src/infra/dotenv.ts
src/infra/ed25519-signature.ts
@ -1053,6 +1055,7 @@ src/state/openclaw-state-db-cache.ts
src/state/openclaw-state-db-contract.ts
src/state/openclaw-state-db-delivery-queue-backfill.ts
src/state/openclaw-state-db-doctor-schema.ts
src/state/openclaw-state-db-existing-schema.ts
src/state/openclaw-state-db-existing-write.ts
src/state/openclaw-state-db-fast-path.ts
src/state/openclaw-state-db-handle.ts
@ -1066,8 +1069,10 @@ src/state/openclaw-state-db-readonly.ts
src/state/openclaw-state-db-repair.ts
src/state/openclaw-state-db-runtime-failure.ts
src/state/openclaw-state-db-schema-additive.ts
src/state/openclaw-state-db-schema-discovery.ts
src/state/openclaw-state-db-schema-helpers.ts
src/state/openclaw-state-db-schema-migration-required.ts
src/state/openclaw-state-db-schema-policy.ts
src/state/openclaw-state-db-schema-repair.ts
src/state/openclaw-state-db-schema-runtime.ts
src/state/openclaw-state-db-schema-v12-foldin.ts

View file

@ -219,6 +219,20 @@ describe("registerNodeCli", () => {
expect(daemonMocks.runNodeHost.mock.calls[0]?.[0]).not.toHaveProperty("forceWorkerRuns");
});
it("hosts worker sessions for this foreground process with --session-host", async () => {
await createProgram().parseAsync(["node", "run", "--session-host"], { from: "user" });
expect(daemonMocks.runNodeHost).toHaveBeenCalledWith(
expect.objectContaining({ forceWorkerRuns: true }),
);
expect(daemonMocks.runNodeHost.mock.calls[0]?.[0]).not.toHaveProperty("ephemeral");
expect(daemonMocks.runNodeDaemonInstall).not.toHaveBeenCalled();
daemonMocks.runNodeHost.mockClear();
await createProgram().parseAsync(["node", "run"], { from: "user" });
expect(daemonMocks.runNodeHost.mock.calls[0]?.[0]).not.toHaveProperty("forceWorkerRuns");
});
it("falls back to configured node run port when --port is omitted", async () => {
daemonMocks.loadNodeHostConfig.mockResolvedValue({
version: 1,

View file

@ -56,6 +56,7 @@ export function registerNodeCli(program: Command) {
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override the generated node instance id")
.option("--display-name <name>", "Override node display name")
.option("--session-host", "Host worker sessions for this foreground process")
.addOption(new Option("--ephemeral").hideHelp())
.option("--share-installed-apps", "Share installed macOS applications with the Gateway")
.option("--no-share-installed-apps", "Disable installed application sharing")
@ -93,7 +94,8 @@ export function registerNodeCli(program: Command) {
gatewayCandidates,
gatewayBootstrapToken: pair?.bootstrapToken,
preferGatewayBootstrapToken: pair !== undefined,
...(opts.ephemeral === true ? { forceWorkerRuns: true, ephemeral: true } : {}),
...(opts.ephemeral === true || opts.sessionHost === true ? { forceWorkerRuns: true } : {}),
...(opts.ephemeral === true ? { ephemeral: true } : {}),
nodeId: opts.nodeId,
displayName: opts.displayName,
installedAppsSharing: opts.shareInstalledApps,

View file

@ -67,24 +67,53 @@ describe("one-shot CLI exit", () => {
expect(exit).toHaveBeenCalledExactlyOnceWith(7);
});
it("leaves a deferred exit owned by the injected runtime without reporting it again", async () => {
const failure = new ExitError(7);
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const onError = vi.fn();
it.each([
{ outcome: "deferred exit", commandExit: 7, cleanupFails: false },
{ outcome: "deferred exit with failed cleanup", commandExit: 7, cleanupFails: true },
{ outcome: "cleanup failure after success", commandExit: undefined, cleanupFails: true },
{
outcome: "deferred exit with cleanup reporter rethrow",
commandExit: 7,
cleanupFails: true,
reporterRethrows: true,
},
])(
"leaves $outcome owned by the injected runtime",
async ({ commandExit, cleanupFails, reporterRethrows }) => {
const commandFailure = commandExit === undefined ? undefined : new ExitError(commandExit);
const cleanupFailure = new Error("state cleanup failed");
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const onError = vi.fn((error: unknown) => {
if (reporterRethrows) {
throw error;
}
});
await expect(
runCliWithExitFinalization({
run: async () => {
throw failure;
},
onError,
runtime,
}),
).rejects.toBe(failure);
await expect(
runCliWithExitFinalization({
run: async () => {
if (commandFailure) {
throw commandFailure;
}
},
finalize: async () => {
if (cleanupFails) {
throw cleanupFailure;
}
},
onError,
runtime,
}),
).rejects.toBe(commandFailure ?? cleanupFailure);
expect(onError).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
});
if (cleanupFails) {
expect(onError).toHaveBeenCalledExactlyOnceWith(cleanupFailure);
} else {
expect(onError).not.toHaveBeenCalled();
}
expect(runtime.exit).not.toHaveBeenCalled();
},
);
it.each([
["NODE_USE_SYSTEM_CA", { NODE_USE_SYSTEM_CA: "1" }, []],
@ -160,6 +189,47 @@ describe("one-shot CLI exit", () => {
await waitForExit(0);
});
it.each(["requested", "system CA"] as const)(
"waits for caller-owned state cleanup before a %s exit",
async (mode) => {
const closed = createDeferred();
const finalizing = createDeferred();
const previousExitCode = process.exitCode;
const { exit, waitForExit } = spyOnExit();
process.exitCode = undefined;
const running = runCliWithExitFinalization({
run: async () => {
if (mode === "requested") {
requestExitAfterOneShotOutput(defaultRuntime, 0);
}
},
finalize: async () => {
finalizing.resolve();
await closed.promise;
},
onError: ignoreError,
env: mode === "system CA" ? { NODE_USE_SYSTEM_CA: "1" } : {},
execArgv: [],
platform: "darwin",
markers: {},
});
try {
await finalizing.promise;
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(exit).not.toHaveBeenCalled();
closed.resolve();
await running;
await waitForExit(0);
} finally {
closed.resolve();
await running;
process.exitCode = previousExitCode;
}
},
);
it("reports failures and replaces a pending successful exit before draining", async () => {
const previousExitCode = process.exitCode;
const order: string[] = [];

View file

@ -91,6 +91,8 @@ function requestExitAfterSystemCaCliCompletion(
export async function runCliWithExitFinalization(params: {
run: () => Promise<void>;
onError: (error: unknown) => void | Promise<void>;
/** Join caller-owned state after command cleanup and before scheduling process exit. */
finalize?: () => Promise<void>;
runtime?: RuntimeEnv;
env?: NodeJS.ProcessEnv;
execArgv?: readonly string[];
@ -98,6 +100,7 @@ export async function runCliWithExitFinalization(params: {
markers?: VitestWorkerMarkers;
}): Promise<void> {
const runtime = params.runtime ?? defaultRuntime;
let finalizationFailure: { error: unknown } | undefined;
try {
await params.run();
} catch (error) {
@ -116,11 +119,32 @@ export async function runCliWithExitFinalization(params: {
execArgv: params.execArgv,
platform: params.platform,
});
if (automaticExit && !isVitestWorker(params.env ?? process.env, params.markers)) {
if (
params.finalize ||
(automaticExit && !isVitestWorker(params.env ?? process.env, params.markers))
) {
await waitForPendingCliDisposers();
}
if (params.finalize) {
try {
await params.finalize();
} catch (error) {
try {
await params.onError(error);
} catch (reportError) {
finalizationFailure = { error: reportError };
}
if (!requestExitAfterOneShotOutput(runtime, 1)) {
finalizationFailure ??= { error };
}
}
}
flushExitAfterOneShotOutput(runtime, params.env, params.markers);
}
// A cleanup failure must not replace an embedded runtime's original exit.
if (finalizationFailure) {
throw finalizationFailure.error;
}
}
/** Unwind an already-reported CLI outcome before shared cleanup and output draining. */

View file

@ -0,0 +1,132 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { ExitError } from "../../runtime.js";
import { withExistingOpenClawStateSchema } from "../../state/openclaw-state-db-schema-policy.js";
import { ensureConfigReady, testApi } from "./config-guard.js";
const mocks = vi.hoisted(() => ({
readConfig: vi.fn(),
prepareDoctor: vi.fn(),
setRuntimeConfig: vi.fn(),
offerRecovery: vi.fn(),
}));
vi.mock("../../config/config.js", () => ({
readConfigFileSnapshot: mocks.readConfig,
setRuntimeConfigSnapshot: mocks.setRuntimeConfig,
}));
vi.mock("../../commands/doctor-config-preflight.js", () => ({
runDoctorConfigPreflight: mocks.prepareDoctor,
}));
vi.mock("../invalid-config-recovery.js", () => ({
offerInvalidConfigRecovery: mocks.offerRecovery,
}));
const temporary = useAutoCleanupTempDirTracker(afterEach);
let statePath: string;
const config = { plugins: { entries: { fixture: { enabled: true } } } };
function snapshot(valid = true) {
return {
path: path.join(path.dirname(path.dirname(statePath)), "openclaw.json"),
exists: true,
valid,
raw: JSON.stringify(config),
parsed: config,
config,
runtimeConfig: config,
sourceConfig: config,
issues: valid
? []
: [{ path: "plugins.entries.fixture.config", message: "invalid plugin value" }],
warnings: [],
legacyIssues: [],
};
}
function runtime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new ExitError(code);
}),
};
}
beforeEach(() => {
vi.resetAllMocks();
testApi.resetConfigGuardStateForTests();
const stateDir = temporary.make("managed-node-config-guard-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_NIX_MODE", undefined);
vi.stubEnv("OPENCLAW_CONFIG_READONLY", undefined);
statePath = path.join(stateDir, "state", "openclaw.sqlite");
mocks.readConfig.mockImplementation(async () => snapshot());
mocks.prepareDoctor.mockImplementation(async () => ({
snapshot: snapshot(),
baseConfig: config,
}));
});
afterEach(() => vi.unstubAllEnvs());
describe("managed node startup config", () => {
it.each([["node", "run"], ["connect"]])(
"validates %j without Doctor convergence or config health writes",
async (...commandPath) => {
const host = runtime();
await withExistingOpenClawStateSchema({ path: statePath }, async () => {
await ensureConfigReady({ runtime: host, commandPath });
});
expect(mocks.prepareDoctor).not.toHaveBeenCalled();
expect(mocks.readConfig).toHaveBeenCalledWith({ observe: false });
expect(mocks.setRuntimeConfig).toHaveBeenCalledWith(config, config);
expect(host.exit).not.toHaveBeenCalled();
},
);
it.each([["node", "run"], ["connect"]])(
"retains ordinary %j migration ownership outside the managed scope",
async (...commandPath) => {
await ensureConfigReady({ runtime: runtime(), commandPath });
expect(mocks.prepareDoctor).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
requireStateMigrationCheckpoint: true,
});
},
);
it("rejects invalid plugin configuration without offering to repair shared state", async () => {
mocks.readConfig.mockResolvedValue(snapshot(false));
const host = runtime();
await expect(
withExistingOpenClawStateSchema({ path: statePath }, async () => {
await ensureConfigReady({ runtime: host, commandPath: ["node", "run"] });
}),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(mocks.readConfig).toHaveBeenCalledWith({ observe: false });
expect(mocks.prepareDoctor).not.toHaveBeenCalled();
expect(mocks.offerRecovery).not.toHaveBeenCalled();
expect(mocks.setRuntimeConfig).not.toHaveBeenCalled();
expect(host.error.mock.calls.join("\n")).toContain("invalid plugin value");
});
it("rejects changed state selectors before config or migration work", async () => {
const otherDir = temporary.make("other-managed-node-state-");
await expect(
withExistingOpenClawStateSchema({ path: statePath }, async () => {
vi.stubEnv("OPENCLAW_STATE_DIR", otherDir);
await ensureConfigReady({ runtime: runtime(), commandPath: ["node", "run"] });
}),
).rejects.toThrow(/state.*(?:bound|changed)/i);
expect(mocks.readConfig).not.toHaveBeenCalled();
expect(mocks.prepareDoctor).not.toHaveBeenCalled();
});
});

View file

@ -21,6 +21,11 @@ import {
getPluginMetadataSnapshotCache,
} from "../../plugins/plugin-cache.js";
import { ExitError, type RuntimeEnv } from "../../runtime.js";
import {
getExistingOpenClawStateSchemaPath,
isExistingOpenClawStateSchema,
} from "../../state/openclaw-state-db-schema-policy.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import type { InvalidConfigRecoveryDeps } from "../invalid-config-recovery.js";
const ALLOWED_INVALID_COMMANDS = new Set(["audit", "doctor", "logs", "health", "help", "status"]);
@ -233,11 +238,24 @@ export async function ensureConfigReady(
const commandPath = params.commandPath ?? [];
const commandName = commandPath[0];
const subcommandName = commandPath[1];
const existingStatePath = getExistingOpenClawStateSchemaPath();
const isManagedNodeRuntime =
existingStatePath !== undefined &&
((commandName === "node" && subcommandName === "run") || commandName === "connect");
if (existingStatePath !== undefined) {
if (!isManagedNodeRuntime) {
throw new Error("The managed node runtime cannot run shared-state maintenance commands.");
}
if (!isExistingOpenClawStateSchema(resolveOpenClawStateSqlitePath())) {
throw new Error("The managed node runtime state directory changed after launcher admission.");
}
}
const isRestartController =
(commandName === "gateway" || commandName === "daemon") && subcommandName === "restart";
let preflightResult: DoctorConfigPreflightResult | null = null;
const shouldConsiderStateMigration =
!params.validateConfigOnly &&
!isManagedNodeRuntime &&
commandName !== "config" &&
commandName !== "health" &&
commandName !== "logs" &&
@ -316,7 +334,8 @@ export async function ensureConfigReady(
? ({ observe: false, pluginValidation: "core-only" } as const)
: commandName === "logs"
? ({ observe: false, pluginValidation: "core-only" } as const)
: commandName === "status" ||
: isManagedNodeRuntime ||
commandName === "status" ||
(commandName === "gateway" && subcommandName === "call") ||
isRestartController
? ({ observe: false } as const)
@ -406,7 +425,8 @@ export async function ensureConfigReady(
const isReadOnlyConfig = resolveIsConfigReadOnly();
const isGatewayStartup = isGatewayStartupCommand(commandPath);
const mustBlockInvalid = !allowInvalid || (isGatewayStartup && params.allowInvalid !== true);
const shouldOfferRecovery = mustBlockInvalid && !params.suppressDoctorStdout && !isReadOnlyConfig;
const shouldOfferRecovery =
mustBlockInvalid && !params.suppressDoctorStdout && !isReadOnlyConfig && !isManagedNodeRuntime;
if (isPluginPackagingFailure || isReadOnlyConfig || !shouldOfferRecovery) {
const fixHint = isPluginPackagingFailure
? formatPluginPackagingRuntimeOutputRecoveryHint()

View file

@ -141,6 +141,7 @@ export async function writeRepairCandidate(candidate: string, configChange: bool
await fs.symlink(path.join(process.cwd(), "dist"), path.join(candidate, "dist"), "dir");
for (const file of [
"openclaw.mjs",
"node-host-launcher.mjs",
"node-version.mjs",
"node-sqlite.mjs",
"node-runtime-update.mjs",

View file

@ -108,6 +108,7 @@ export function createSourceRuntime(root: string): string {
);
}
for (const filename of [
"node-host-launcher.mjs",
"node-version.mjs",
"node-sqlite.mjs",
"node-runtime-update.mjs",

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("node automatic-update config", () => {
it.each([undefined, true, false])("preserves enabled=%s", (enabled) => {
const result = validateConfigObject({ nodeHost: { autoUpdate: { enabled } } });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.config.nodeHost?.autoUpdate?.enabled).toBe(enabled);
}
});
it.each(["false", 0, null])("rejects non-boolean enabled=%j", (enabled) => {
const result = validateConfigObject({ nodeHost: { autoUpdate: { enabled } } });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues.some((issue) => issue.path === "nodeHost.autoUpdate.enabled")).toBe(
true,
);
}
});
});

View file

@ -80,7 +80,7 @@ export const CORE_FIELD_HELP: Record<string, string> = {
"update.channel":
'Update channel for git + npm installs ("stable", "extended-stable", "beta", or "dev"). Extended-stable is package-only: installation is foreground-only, with optional read-only startup hints.',
"update.checkOnStart":
"Checks the OpenClaw update endpoint when the gateway starts, including read-only extended-stable hints (default: true). Set false to disable update checks and anonymous update pings.",
"Checks for updates when the Gateway starts, including read-only extended-stable hints (default: true). Set false to disable automatic Gateway and headless-node update checks, applies, and anonymous update pings.",
"update.auto.enabled":
"Enable background auto-update for stable and beta package installs; extended-stable never auto-applies (default: false).",
telemetry:

View file

@ -130,6 +130,8 @@ export const TARGET_KEYS = [
"approvals.exec.targets[].accountId",
"approvals.exec.targets[].threadId",
"nodeHost",
"nodeHost.autoUpdate",
"nodeHost.autoUpdate.enabled",
"nodeHost.agentRuns",
"nodeHost.agentRuns.claude",
"nodeHost.agentRuns.claude.enabled",

View file

@ -286,6 +286,10 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
"Node command names to block even if present in node claims or default allowlist (exact command-name matching only, e.g. `system.run`; does not inspect shell text inside that command).",
nodeHost:
"Node host controls for features exposed from this gateway node to other nodes or clients. Keep defaults unless you intentionally proxy local capabilities across your node network.",
"nodeHost.autoUpdate":
"Controls automatic updates of the separate runtime for packaged headless node hosts. Checks hourly and waits for all node work to finish before restarting; automatic restarts are at least 12 hours apart.",
"nodeHost.autoUpdate.enabled":
"Enable automatic stable or beta updates for long-running packaged headless nodes (default: true). Set false to opt out. Also disabled by update.checkOnStart=false or OPENCLAW_NO_AUTO_UPDATE=1; source checkouts, native apps, private workers, dev, and extended-stable do not auto-apply.",
"nodeHost.agentRuns":
"Opt in to approval-gated native agent turns on this headless node host. Disabled by default.",
"nodeHost.agentRuns.claude":

View file

@ -100,6 +100,7 @@ const SECTION_DOCS_URLS = {
const FIELD_PLACEHOLDERS: Record<string, string> = {
"gateway.cliAgents.enabled": "Default (enabled)",
"nodeHost.autoUpdate.enabled": "Default (enabled)",
"tools.loopDetection.enabled": "Default (post-compaction protection only)",
"gateway.publicOrigin": "https://gateway.example.com",
"gateway.remote.url": "ws://host:18789",

View file

@ -4,6 +4,7 @@ import { META_FIELD_LABELS } from "./schema.meta.js";
import { NODE_CAPABILITY_FIELD_LABELS } from "./schema.node-capabilities.js";
import { CLOUD_WORKER_FIELD_LABELS } from "./zod-schema.cloud-workers.js";
import { DESKTOP_FIELD_LABELS } from "./zod-schema.desktop.js";
import { NODE_HOST_FIELD_LABELS } from "./zod-schema.node-host.js";
import { TELEMETRY_FIELD_LABELS } from "./zod-schema.telemetry.js";
export const FIELD_LABELS: Record<string, string> = {
@ -423,22 +424,7 @@ export const FIELD_LABELS: Record<string, string> = {
"gateway.nodes.pairing.sshVerify": "Gateway Node Pairing SSH Verification",
...NODE_CAPABILITY_FIELD_LABELS,
"gateway.nodes.commands.deny": "Gateway Node Denylist",
nodeHost: "Node Host",
"nodeHost.agentRuns": "Node Agent Runs",
"nodeHost.agentRuns.claude": "Node Claude Agent Runs",
"nodeHost.agentRuns.claude.enabled": "Node Claude Agent Runs Enabled",
"nodeHost.workerRuns": "Node Worker Runs",
"nodeHost.workerRuns.enabled": "Node Worker Runs Enabled",
"nodeHost.workerRuns.capacity": "Node Worker Run Capacity",
"nodeHost.workerRuns.isolation": "Node Worker Run Isolation",
"nodeHost.workerRuns.containerImage": "Node Worker Run Container Image",
"nodeHost.browserProxy": "Node Browser Proxy",
"nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled",
"nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles",
"nodeHost.mcp": "Node Host MCP",
"nodeHost.mcp.servers": "Node Host MCP Servers",
"nodeHost.skills": "Node Host Skills",
"nodeHost.skills.enabled": "Node Host Skills Enabled",
...NODE_HOST_FIELD_LABELS,
attachments: "Attachments",
"attachments.ttlHours": "Attachment Retention TTL (hours)",
bindings: "Bindings",

View file

@ -8,6 +8,11 @@ export type NodeHostBrowserProxyConfig = {
};
export type NodeHostConfig = {
/** Automatic updates for long-running packaged headless node hosts. */
autoUpdate?: {
/** Check hourly and activate only while idle (default: true). */
enabled?: boolean;
};
/** Sensitive native agent execution exposed by the headless node host. */
agentRuns?: {
claude?: {

View file

@ -1,6 +1,27 @@
import { z } from "zod";
import { NODE_WORKER_CAPACITY_MAX } from "../infra/node-runner-inventory.js";
export const NODE_HOST_FIELD_LABELS: Record<string, string> = {
nodeHost: "Node Host",
"nodeHost.autoUpdate": "Node Automatic Updates",
"nodeHost.autoUpdate.enabled": "Node Automatic Updates Enabled",
"nodeHost.agentRuns": "Node Agent Runs",
"nodeHost.agentRuns.claude": "Node Claude Agent Runs",
"nodeHost.agentRuns.claude.enabled": "Node Claude Agent Runs Enabled",
"nodeHost.workerRuns": "Node Worker Runs",
"nodeHost.workerRuns.enabled": "Node Worker Runs Enabled",
"nodeHost.workerRuns.capacity": "Node Worker Run Capacity",
"nodeHost.workerRuns.isolation": "Node Worker Run Isolation",
"nodeHost.workerRuns.containerImage": "Node Worker Run Container Image",
"nodeHost.browserProxy": "Node Browser Proxy",
"nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled",
"nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles",
"nodeHost.mcp": "Node Host MCP",
"nodeHost.mcp.servers": "Node Host MCP Servers",
"nodeHost.skills": "Node Host Skills",
"nodeHost.skills.enabled": "Node Host Skills Enabled",
};
export const BrowserSnapshotDefaultsSchema = z
.object({
/** Default snapshot mode (applies when mode is not provided). */

View file

@ -344,6 +344,11 @@ export const McpConfigSchema = z
export const NodeHostSchema = z
.strictObject({
autoUpdate: z
.strictObject({
enabled: z.boolean().optional(),
})
.optional(),
agentRuns: NodeHostAgentRunsSchema,
workerRuns: NodeHostWorkerRunsSchema,
browserProxy: z

View file

@ -32,6 +32,11 @@ import { normalizeEnv } from "./infra/env.js";
import { isMainModule } from "./infra/is-main.js";
import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js";
import { installProcessWarningFilter } from "./infra/warning-filter.js";
import {
getManagedNodeHostStatePath,
isNodeHostLauncherChild,
requestNodeHostLauncherBootstrap,
} from "./node-host/launcher-client.js";
import { defaultRuntime } from "./runtime.js";
// Recovery must not select executables from workspace/global dotenv values.
@ -141,19 +146,27 @@ if (
await configureGatewayStartupTraceConsoleFormatting(gatewayEntryStartupTrace);
}
await assertSupportedRuntime(undefined, undefined, process.argv, false, inheritedRuntimeEnv);
const { runNodeHostLauncher } = await import(
new URL("../node-host-launcher.mjs", import.meta.url).href
);
if (await runNodeHostLauncher({ entryPath: entryFile, packageRoot: installRoot })) {
process.exit(process.exitCode ?? 0);
}
gatewayEntryStartupTrace.mark("bootstrap");
const waitingForCompileCacheRespawn = await respawnWithoutOpenClawCompileCacheIfNeeded({
currentFile: entryFile,
installRoot,
env: startupEnv,
prepareWriteError: async () => {
// The child environment was already snapshotted. Load dotenv only to format
// the parent trace; command-specific dotenv ordering remains child-owned.
const writeError = await prepareCliDiagnosticBlockWriter();
return (message) => writeError(message);
},
});
const waitingForCompileCacheRespawn =
!isNodeHostLauncherChild() &&
(await respawnWithoutOpenClawCompileCacheIfNeeded({
currentFile: entryFile,
installRoot,
env: startupEnv,
prepareWriteError: async () => {
// The child environment was already snapshotted. Load dotenv only to format
// the parent trace; command-specific dotenv ordering remains child-owned.
const writeError = await prepareCliDiagnosticBlockWriter();
return (message) => writeError(message);
},
}));
if (!waitingForCompileCacheRespawn) {
enableOpenClawCompileCache({
installRoot,
@ -173,6 +186,13 @@ if (
if (!plan) {
return false;
}
if (isNodeHostLauncherChild()) {
await requestNodeHostLauncherBootstrap({
execArgv: plan.argv.slice(0, plan.argv.length - process.argv.length + 1),
env: plan.env,
});
process.exit(0);
}
// The child environment was already snapshotted. Load dotenv only to format
// the parent trace; command-specific dotenv ordering remains child-owned.
@ -211,7 +231,23 @@ if (
gatewayEntryStartupTrace.mark("argv");
if (!tryHandleRootVersionFastPath(process.argv)) {
await withCliProcessScope(() => runMainOrRootHelp(process.argv));
const run = (finalize?: () => Promise<void>) =>
withCliProcessScope(() => runMainOrRootHelp(process.argv, { finalize }));
const managedNodeStatePath = getManagedNodeHostStatePath();
if (managedNodeStatePath) {
const { withExistingOpenClawStateSchema } =
await import("./state/openclaw-state-db-schema-policy.js");
await withExistingOpenClawStateSchema({ path: managedNodeStatePath }, async () => {
const { openOpenClawStateDatabase, closeOpenClawStateDatabaseByPathAsync } =
await import("./state/openclaw-state-db.js");
openOpenClawStateDatabase({ path: managedNodeStatePath });
await run(async () => {
await closeOpenClawStateDatabaseByPathAsync(managedNodeStatePath);
});
});
} else {
await run();
}
}
}
}
@ -294,6 +330,7 @@ export async function runMainOrRootHelp(
// mode so the envelope is written here. Only failures before runCli are startup failures.
let commandStarted = false;
await runCliWithExitFinalization({
finalize: deps.finalize,
run: async () => {
if (isNativeHookRelayArgv(argv) && !argv.includes("--help") && !argv.includes("-h")) {
const { runNativeHookRelayCliFromArgv } = await import("./cli/native-hook-relay-cli.js");
@ -347,4 +384,5 @@ export async function runMainOrRootHelp(
type RunMainOrRootHelpDeps = {
loadRunCli?: () => Promise<Pick<typeof import("./cli/run-main.js"), "runCli">>;
finalize?: () => Promise<void>;
};

View file

@ -17,6 +17,7 @@ import {
import { requestDevicePairing } from "../infra/device-pairing.js";
import { configureNodeHost } from "../node-host/config.js";
import type { NodeListNode } from "../shared/node-list-types.js";
import { withEnvAsync } from "../test-utils/env.js";
import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { resolveRuntimeServiceVersion } from "../version.js";
@ -92,22 +93,30 @@ describe("node host version mismatch guard", () => {
await server?.close();
});
test.each([gatewayVersion, "dev", "1.0.0"])(
"same-install node with accepted version %s connects",
async (clientVersion) => {
let helloProtocol: number | undefined;
const client = await connectNode({
clientVersion,
onHelloOk: (hello) => {
helloProtocol = hello.protocol;
},
});
try {
expect(helloProtocol).toBe(PROTOCOL_VERSION);
} finally {
await client.stopAndWait({ timeoutMs: 2_000 });
}
},
test.each([
[gatewayVersion, gatewayVersion],
[gatewayVersion, "dev"],
[gatewayVersion, "1.0.0"],
["2026.9.4", "2026.9.5"],
["2026.9.4", "2026.9.5-beta.1"],
["2026.9.4", "2026.9.4-1"],
])(
"Gateway %s accepts same-install node version %s",
async (serverVersion, clientVersion) =>
await withEnvAsync({ OPENCLAW_VERSION: serverVersion }, async () => {
let helloProtocol: number | undefined;
const client = await connectNode({
clientVersion,
onHelloOk: (hello) => {
helloProtocol = hello.protocol;
},
});
try {
expect(helloProtocol).toBe(PROTOCOL_VERSION);
} finally {
await client.stopAndWait({ timeoutMs: 2_000 });
}
}),
);
test.each(["default", "different", "omitted"])(

View file

@ -9,6 +9,7 @@ import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/s
import { ErrorCodes, PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/index.js";
import { getRuntimeConfig } from "../../../config/io.js";
import { captureAuthenticatedNodePairingState } from "../../../infra/device-pairing-node-state.js";
import { compareOpenClawReleaseVersions } from "../../../infra/npm-registry-spec.js";
import { upsertPresence } from "../../../infra/system-presence.js";
import { loadVoiceWakeRoutingConfig } from "../../../infra/voicewake-routing.js";
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
@ -65,9 +66,6 @@ import type {
GatewayConnectPhaseContext,
} from "./message-handler-types.js";
/** Match production release versions (YYYY.M.PATCH or YYYY.M.PATCH-beta.N). */
const RELEASED_VERSION_RE = /^\d{4}\.\d+\.\d+/;
type AuthenticatedNodePairingAdmission = NonNullable<
Awaited<ReturnType<typeof captureAuthenticatedNodePairingState>>
> & {
@ -473,19 +471,16 @@ export async function attachAuthenticatedGatewayConnect(
}
// Only an exact cryptographic device match proves the same install; independent
// SSH-tunneled or separate-state nodes are exempt even when they appear local.
// Restart stale nodes after a shared install update; an independently updated
// node can be newer than its Gateway and still use the negotiated protocol.
// Reject before registration/presence so supervisor restarts leave no phantom online state.
if (role === "node" && isLocalClient) {
const localNodeId = await resolveLocalNodeId();
if (localNodeId && device?.id === localNodeId) {
const gatewayVersion = resolveRuntimeServiceVersion(process.env);
const clientVersion = connectParams.client.version;
if (
clientVersion &&
gatewayVersion &&
clientVersion !== gatewayVersion &&
RELEASED_VERSION_RE.test(gatewayVersion) &&
RELEASED_VERSION_RE.test(clientVersion)
) {
const releaseOrder = compareOpenClawReleaseVersions(clientVersion, gatewayVersion);
if (releaseOrder !== null && releaseOrder < 0) {
logWsControl.info(
`node version mismatch conn=${connId} client=${formatForLog(clientLabel)} clientVersion=${formatForLog(clientVersion)} gatewayVersion=${gatewayVersion}; closing for supervisor restart`,
);

View file

@ -89,6 +89,7 @@ export function useNodeBootstrapArtifactFixtures() {
await write(packageRoot, "node-sqlite.mjs", "export const probe = true;");
await write(packageRoot, "node-runtime-update.mjs", "export const update = true;");
await write(packageRoot, "node-runtime-recovery.mjs", "export const recovery = true;");
await write(packageRoot, "node-host-launcher.mjs", "export const launcher = true;");
await write(packageRoot, "scripts/preinstall.mjs", "export {};\n");
await write(
packageRoot,

View file

@ -50,6 +50,7 @@ const BOOTSTRAP_LAUNCHER_FILES = [
"node-sqlite.mjs",
"node-runtime-update.mjs",
"node-runtime-recovery.mjs",
"node-host-launcher.mjs",
];
const READ_CONCURRENCY = 16;
const IGNORED_PLUGIN_DIRECTORIES = new Set(["node_modules", "src", "test", "tests"]);

View file

@ -118,6 +118,10 @@ describe("worker node enrollment", () => {
fs.writeFile(path.join(packageRoot, "openclaw.mjs"), 'import "./dist/entry.js";'),
fs.writeFile(path.join(packageRoot, "node-version.mjs"), "export const supported = true;"),
fs.writeFile(path.join(packageRoot, "node-sqlite.mjs"), "export const probe = true;"),
fs.writeFile(
path.join(packageRoot, "node-host-launcher.mjs"),
"export const launcher = true;",
),
fs.writeFile(
path.join(packageRoot, "node-runtime-update.mjs"),
"export const update = true;",

20
src/infra/config-dir.ts Normal file
View file

@ -0,0 +1,20 @@
// Resolves config-directory paths without initializing process-wide state.
import os from "node:os";
import path from "node:path";
import { resolveRequiredHomeDir, resolveUserPath } from "./home-dir.js";
/** Resolves the OpenClaw config directory from state/config env overrides or home. */
export function resolveConfigDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const override = env.OPENCLAW_STATE_DIR?.trim();
if (override) {
return resolveUserPath(override, env, homedir);
}
const configPath = env.OPENCLAW_CONFIG_PATH?.trim();
if (configPath) {
return path.dirname(resolveUserPath(configPath, env, homedir));
}
return path.join(resolveRequiredHomeDir(env, homedir), ".openclaw");
}

View file

@ -0,0 +1,263 @@
// Reads and applies global dotenv files without loading config or logging.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { parse as parseDotEnv } from "dotenv";
import { resolveConfigDir } from "./config-dir.js";
import { resolveRequiredHomeDir } from "./home-dir.js";
import { normalizeEnvVarKey } from "./host-env-security.js";
import { readRegularFile, readRegularFileSync } from "./regular-file.js";
/** Maximum bytes to read from any dotenv file. */
const MAX_DOTENV_FILE_BYTES = 1024 * 1024;
type DotEnvWarning = (message: string, meta?: Record<string, unknown>) => void;
type DotEnvEntry = {
key: string;
value: string;
};
export type LoadedDotEnvFile = {
filePath: string;
entries: DotEnvEntry[];
};
export type GlobalRuntimeDotEnvOptions = {
env?: NodeJS.ProcessEnv;
additionalEnvPaths?: string[];
entryFilter?: (key: string, value: string) => boolean;
/** Keys whose service-managed inherited values may be replaced by trusted dotenv files. */
overrideKeys?: Iterable<string>;
quiet?: boolean;
stateEnvPath?: string;
onWarning?: DotEnvWarning;
};
export type ReadDotEnvFileOptions = {
entryFilter?: (key: string, value: string) => boolean;
filePath: string;
quiet?: boolean;
onWarning?: DotEnvWarning;
};
function reportDotEnvReadError(params: ReadDotEnvFileOptions, error: unknown): void {
if (params.quiet) {
return;
}
const code =
error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
if (code !== "ENOENT") {
params.onWarning?.(`Failed to read ${params.filePath}: ${String(error)}`, { error });
}
// Surface oversized files so operators know a configured file was skipped.
if (error instanceof Error && error.message?.startsWith("File exceeds")) {
params.onWarning?.(
`skipping oversized .env file (max ${MAX_DOTENV_FILE_BYTES} bytes): ${params.filePath}`,
);
}
}
function parseDotEnvFile(params: ReadDotEnvFileOptions, content: Buffer): LoadedDotEnvFile {
const entries: DotEnvEntry[] = [];
for (const [rawKey, value] of Object.entries(parseDotEnv(content))) {
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (key && (params.entryFilter?.(key, value) ?? true)) {
entries.push({ key, value });
}
}
return { filePath: params.filePath, entries };
}
export function readDotEnvFileCore(params: ReadDotEnvFileOptions): LoadedDotEnvFile | null {
let content: Buffer;
try {
// Resolve symlinks so a symlinked .env file works while the bounded
// read still rejects oversized targets.
const resolved = fs.realpathSync(params.filePath);
const { buffer } = readRegularFileSync({
filePath: resolved,
maxBytes: MAX_DOTENV_FILE_BYTES,
});
content = buffer;
} catch (error) {
reportDotEnvReadError(params, error);
return null;
}
return parseDotEnvFile(params, content);
}
export async function readDotEnvFileAsyncCore(
params: ReadDotEnvFileOptions,
): Promise<LoadedDotEnvFile | null> {
let content: Buffer;
try {
const resolved = await fs.promises.realpath(params.filePath);
const { buffer } = await readRegularFile({
filePath: resolved,
maxBytes: MAX_DOTENV_FILE_BYTES,
});
content = buffer;
} catch (error) {
reportDotEnvReadError(params, error);
return null;
}
return parseDotEnvFile(params, content);
}
function loadParsedDotEnvFiles(
files: LoadedDotEnvFile[],
env: NodeJS.ProcessEnv,
overrideKeys?: Iterable<string>,
onWarning?: DotEnvWarning,
): Map<string, string[]> {
const preExistingKeys = new Set(Object.keys(env));
const canonicalizeKey = (key: string): string | null =>
normalizeEnvVarKey(key, { portable: true })?.toUpperCase() ?? null;
const normalizedOverrideKeys = new Set(
[...(overrideKeys ?? [])].flatMap((key) => {
const normalized = canonicalizeKey(key);
return normalized ? [normalized] : [];
}),
);
const conflicts = new Map<string, { keptPath: string; ignoredPath: string; keys: Set<string> }>();
const firstSeen = new Map<string, { value: string; filePath: string }>();
const appliedKeysByFile = new Map<string, string[]>();
for (const file of files) {
for (const { key, value } of file.entries) {
const canonicalKey = canonicalizeKey(key);
const mayOverride = canonicalKey !== null && normalizedOverrideKeys.has(canonicalKey);
const precedenceKey = mayOverride && canonicalKey ? canonicalKey : key;
if (preExistingKeys.has(key) && !mayOverride) {
continue;
}
const previous = firstSeen.get(precedenceKey);
if (previous) {
if (previous.value !== value) {
// First file wins for deterministic startup; conflicts are logged once
// after parsing so sensitive values are not printed.
const conflictKey = `${previous.filePath}\u0000${file.filePath}`;
const existing = conflicts.get(conflictKey);
if (existing) {
existing.keys.add(key);
} else {
conflicts.set(conflictKey, {
keptPath: previous.filePath,
ignoredPath: file.filePath,
keys: new Set([key]),
});
}
}
continue;
}
firstSeen.set(precedenceKey, { value, filePath: file.filePath });
if (env[key] === undefined || mayOverride) {
if (mayOverride) {
// Service ownership is case-insensitive. Refresh every inherited alias so Linux cannot
// retain a stale uppercase value beside a newly parsed lowercase dotenv key.
for (const inheritedKey of preExistingKeys) {
if (canonicalizeKey(inheritedKey) === canonicalKey) {
env[inheritedKey] = value;
}
}
}
env[key] = value;
const appliedKeys = appliedKeysByFile.get(file.filePath);
if (appliedKeys) {
appliedKeys.push(key);
} else {
appliedKeysByFile.set(file.filePath, [key]);
}
}
}
}
for (const conflict of conflicts.values()) {
const keys = [...conflict.keys].toSorted();
if (keys.length === 0) {
continue;
}
onWarning?.(
`Conflicting values in ${conflict.keptPath} and ${conflict.ignoredPath} for ${keys.join(", ")}; keeping ${conflict.keptPath}.`,
{ keptPath: conflict.keptPath, ignoredPath: conflict.ignoredPath, keys },
);
}
return appliedKeysByFile;
}
function resolveGlobalDotEnvPaths(opts: GlobalRuntimeDotEnvOptions, env: NodeJS.ProcessEnv) {
const stateEnvPath = opts.stateEnvPath ?? path.join(resolveConfigDir(env), ".env");
const globalEnvPaths = [...new Set([stateEnvPath, ...(opts.additionalEnvPaths ?? [])])];
const home = resolveRequiredHomeDir(env, os.homedir);
const defaultStateEnvPath = path.join(home, ".openclaw", ".env");
const hasExplicitNonDefaultStateDir =
env.OPENCLAW_STATE_DIR?.trim() !== undefined &&
path.resolve(stateEnvPath) !== path.resolve(defaultStateEnvPath);
return {
globalEnvPaths,
gatewayEnvPath: hasExplicitNonDefaultStateDir
? undefined
: path.join(home, ".config", "openclaw", "gateway.env"),
};
}
function applyGlobalDotEnvFiles(
globalEnvs: (LoadedDotEnvFile | null)[],
gatewayEnv: LoadedDotEnvFile | null,
env: NodeJS.ProcessEnv,
overrideKeys?: Iterable<string>,
onWarning?: DotEnvWarning,
) {
const parsed = [...globalEnvs, gatewayEnv].filter(
(file): file is LoadedDotEnvFile => file !== null,
);
const appliedKeysByFile = loadParsedDotEnvFiles(parsed, env, overrideKeys, onWarning);
return {
dotenvPresentKeys: [...new Set(parsed.flatMap((file) => file.entries.map(({ key }) => key)))],
stateEnvAppliedKeys: globalEnvs.flatMap((file) =>
file ? (appliedKeysByFile.get(file.filePath) ?? []) : [],
),
gatewayEnvAppliedKeys: gatewayEnv ? (appliedKeysByFile.get(gatewayEnv.filePath) ?? []) : [],
};
}
/** Load global runtime dotenv files with first-wins precedence, defaulting to `process.env`. */
export function loadGlobalRuntimeDotEnvFilesCore(opts: GlobalRuntimeDotEnvOptions = {}) {
const env = opts.env ?? process.env;
const { globalEnvPaths, gatewayEnvPath } = resolveGlobalDotEnvPaths(opts, env);
const readOptions = {
entryFilter: opts.entryFilter,
quiet: opts.quiet ?? true,
onWarning: opts.onWarning,
};
const globalEnvs = globalEnvPaths.map((filePath) =>
readDotEnvFileCore({ ...readOptions, filePath }),
);
const gatewayEnv = gatewayEnvPath
? readDotEnvFileCore({ ...readOptions, filePath: gatewayEnvPath })
: null;
return applyGlobalDotEnvFiles(globalEnvs, gatewayEnv, env, opts.overrideKeys, opts.onWarning);
}
/** Read global runtime dotenv files asynchronously into a caller-owned environment. */
export async function loadGlobalRuntimeDotEnvFilesAsyncCore(
opts: GlobalRuntimeDotEnvOptions & { env: NodeJS.ProcessEnv },
) {
const { env } = opts;
const { globalEnvPaths, gatewayEnvPath } = resolveGlobalDotEnvPaths(opts, env);
const readOptions = {
entryFilter: opts.entryFilter,
quiet: opts.quiet ?? true,
onWarning: opts.onWarning,
};
const globalEnvs: (LoadedDotEnvFile | null)[] = [];
for (const filePath of globalEnvPaths) {
globalEnvs.push(await readDotEnvFileAsyncCore({ ...readOptions, filePath }));
}
const gatewayEnv = gatewayEnvPath
? await readDotEnvFileAsyncCore({ ...readOptions, filePath: gatewayEnvPath })
: null;
return applyGlobalDotEnvFiles(globalEnvs, gatewayEnv, env, opts.overrideKeys, opts.onWarning);
}

View file

@ -1,252 +1,38 @@
// Loads global dotenv files into process environment when requested.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { parse as parseDotEnv } from "dotenv";
// Loads global dotenv files with runtime logging for diagnostics.
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveConfigDir } from "../utils.js";
import { resolveRequiredHomeDir } from "./home-dir.js";
import { normalizeEnvVarKey } from "./host-env-security.js";
import { readRegularFile, readRegularFileSync } from "./regular-file.js";
import {
loadGlobalRuntimeDotEnvFilesCore,
loadGlobalRuntimeDotEnvFilesAsyncCore,
readDotEnvFileCore,
readDotEnvFileAsyncCore,
type GlobalRuntimeDotEnvOptions as CoreGlobalRuntimeDotEnvOptions,
type LoadedDotEnvFile,
type ReadDotEnvFileOptions as CoreReadDotEnvFileOptions,
} from "./dotenv-global-core.js";
// Global dotenv loading imports operator-level gateway env files without
// overriding variables already present in the process environment.
const logger = createSubsystemLogger("infra:dotenv");
/** Maximum bytes to read from any dotenv file. */
const MAX_DOTENV_FILE_BYTES = 1024 * 1024;
type DotEnvEntry = {
key: string;
value: string;
};
type LoadedDotEnvFile = {
filePath: string;
entries: DotEnvEntry[];
};
type GlobalRuntimeDotEnvOptions = {
env?: NodeJS.ProcessEnv;
additionalEnvPaths?: string[];
entryFilter?: (key: string, value: string) => boolean;
/** Keys whose service-managed inherited values may be replaced by trusted dotenv files. */
overrideKeys?: Iterable<string>;
quiet?: boolean;
stateEnvPath?: string;
};
type ReadDotEnvFileOptions = {
entryFilter?: (key: string, value: string) => boolean;
filePath: string;
quiet?: boolean;
};
function reportDotEnvReadError(params: ReadDotEnvFileOptions, error: unknown): void {
if (params.quiet) {
return;
}
const code =
error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
if (code !== "ENOENT") {
logger.warn(`Failed to read ${params.filePath}: ${String(error)}`, { error });
}
// Surface oversized files so operators know a configured file was skipped.
if (error instanceof Error && error.message?.startsWith("File exceeds")) {
logger.warn(
`skipping oversized .env file (max ${MAX_DOTENV_FILE_BYTES} bytes): ${params.filePath}`,
);
}
}
function parseDotEnvFile(params: ReadDotEnvFileOptions, content: Buffer): LoadedDotEnvFile {
const entries: DotEnvEntry[] = [];
for (const [rawKey, value] of Object.entries(parseDotEnv(content))) {
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (key && (params.entryFilter?.(key, value) ?? true)) {
entries.push({ key, value });
}
}
return { filePath: params.filePath, entries };
}
type GlobalRuntimeDotEnvOptions = Omit<CoreGlobalRuntimeDotEnvOptions, "onWarning">;
type ReadDotEnvFileOptions = Omit<CoreReadDotEnvFileOptions, "onWarning">;
export function readDotEnvFile(params: ReadDotEnvFileOptions): LoadedDotEnvFile | null {
let content: Buffer;
try {
// Resolve symlinks so a symlinked .env file works while the bounded
// read still rejects oversized targets.
const resolved = fs.realpathSync(params.filePath);
const { buffer } = readRegularFileSync({
filePath: resolved,
maxBytes: MAX_DOTENV_FILE_BYTES,
});
content = buffer;
} catch (error) {
reportDotEnvReadError(params, error);
return null;
}
return parseDotEnvFile(params, content);
return readDotEnvFileCore({ ...params, onWarning: logger.warn });
}
export async function readDotEnvFileAsync(
params: ReadDotEnvFileOptions,
): Promise<LoadedDotEnvFile | null> {
let content: Buffer;
try {
const resolved = await fs.promises.realpath(params.filePath);
const { buffer } = await readRegularFile({
filePath: resolved,
maxBytes: MAX_DOTENV_FILE_BYTES,
});
content = buffer;
} catch (error) {
reportDotEnvReadError(params, error);
return null;
}
return parseDotEnvFile(params, content);
}
function loadParsedDotEnvFiles(
files: LoadedDotEnvFile[],
env: NodeJS.ProcessEnv,
overrideKeys?: Iterable<string>,
): Map<string, string[]> {
const preExistingKeys = new Set(Object.keys(env));
const canonicalizeKey = (key: string): string | null =>
normalizeEnvVarKey(key, { portable: true })?.toUpperCase() ?? null;
const normalizedOverrideKeys = new Set(
[...(overrideKeys ?? [])].flatMap((key) => {
const normalized = canonicalizeKey(key);
return normalized ? [normalized] : [];
}),
);
const conflicts = new Map<string, { keptPath: string; ignoredPath: string; keys: Set<string> }>();
const firstSeen = new Map<string, { value: string; filePath: string }>();
const appliedKeysByFile = new Map<string, string[]>();
for (const file of files) {
for (const { key, value } of file.entries) {
const canonicalKey = canonicalizeKey(key);
const mayOverride = canonicalKey !== null && normalizedOverrideKeys.has(canonicalKey);
const precedenceKey = mayOverride && canonicalKey ? canonicalKey : key;
if (preExistingKeys.has(key) && !mayOverride) {
continue;
}
const previous = firstSeen.get(precedenceKey);
if (previous) {
if (previous.value !== value) {
// First file wins for deterministic startup; conflicts are logged once
// after parsing so sensitive values are not printed.
const conflictKey = `${previous.filePath}\u0000${file.filePath}`;
const existing = conflicts.get(conflictKey);
if (existing) {
existing.keys.add(key);
} else {
conflicts.set(conflictKey, {
keptPath: previous.filePath,
ignoredPath: file.filePath,
keys: new Set([key]),
});
}
}
continue;
}
firstSeen.set(precedenceKey, { value, filePath: file.filePath });
if (env[key] === undefined || mayOverride) {
if (mayOverride) {
// Service ownership is case-insensitive. Refresh every inherited alias so Linux cannot
// retain a stale uppercase value beside a newly parsed lowercase dotenv key.
for (const inheritedKey of preExistingKeys) {
if (canonicalizeKey(inheritedKey) === canonicalKey) {
env[inheritedKey] = value;
}
}
}
env[key] = value;
const appliedKeys = appliedKeysByFile.get(file.filePath);
if (appliedKeys) {
appliedKeys.push(key);
} else {
appliedKeysByFile.set(file.filePath, [key]);
}
}
}
}
for (const conflict of conflicts.values()) {
const keys = [...conflict.keys].toSorted();
if (keys.length === 0) {
continue;
}
logger.warn(
`Conflicting values in ${conflict.keptPath} and ${conflict.ignoredPath} for ${keys.join(", ")}; keeping ${conflict.keptPath}.`,
{ keptPath: conflict.keptPath, ignoredPath: conflict.ignoredPath, keys },
);
}
return appliedKeysByFile;
}
function resolveGlobalDotEnvPaths(opts: GlobalRuntimeDotEnvOptions, env: NodeJS.ProcessEnv) {
const stateEnvPath = opts.stateEnvPath ?? path.join(resolveConfigDir(env), ".env");
const globalEnvPaths = [...new Set([stateEnvPath, ...(opts.additionalEnvPaths ?? [])])];
const home = resolveRequiredHomeDir(env, os.homedir);
const defaultStateEnvPath = path.join(home, ".openclaw", ".env");
const hasExplicitNonDefaultStateDir =
env.OPENCLAW_STATE_DIR?.trim() !== undefined &&
path.resolve(stateEnvPath) !== path.resolve(defaultStateEnvPath);
return {
globalEnvPaths,
gatewayEnvPath: hasExplicitNonDefaultStateDir
? undefined
: path.join(home, ".config", "openclaw", "gateway.env"),
};
}
function applyGlobalDotEnvFiles(
globalEnvs: (LoadedDotEnvFile | null)[],
gatewayEnv: LoadedDotEnvFile | null,
env: NodeJS.ProcessEnv,
overrideKeys?: Iterable<string>,
) {
const parsed = [...globalEnvs, gatewayEnv].filter(
(file): file is LoadedDotEnvFile => file !== null,
);
const appliedKeysByFile = loadParsedDotEnvFiles(parsed, env, overrideKeys);
return {
dotenvPresentKeys: [...new Set(parsed.flatMap((file) => file.entries.map(({ key }) => key)))],
stateEnvAppliedKeys: globalEnvs.flatMap((file) =>
file ? (appliedKeysByFile.get(file.filePath) ?? []) : [],
),
gatewayEnvAppliedKeys: gatewayEnv ? (appliedKeysByFile.get(gatewayEnv.filePath) ?? []) : [],
};
return await readDotEnvFileAsyncCore({ ...params, onWarning: logger.warn });
}
/** Load global runtime dotenv files with first-wins precedence, defaulting to `process.env`. */
export function loadGlobalRuntimeDotEnvFiles(opts: GlobalRuntimeDotEnvOptions = {}) {
const env = opts.env ?? process.env;
const { globalEnvPaths, gatewayEnvPath } = resolveGlobalDotEnvPaths(opts, env);
const readOptions = { entryFilter: opts.entryFilter, quiet: opts.quiet ?? true };
const globalEnvs = globalEnvPaths.map((filePath) => readDotEnvFile({ ...readOptions, filePath }));
const gatewayEnv = gatewayEnvPath
? readDotEnvFile({ ...readOptions, filePath: gatewayEnvPath })
: null;
return applyGlobalDotEnvFiles(globalEnvs, gatewayEnv, env, opts.overrideKeys);
return loadGlobalRuntimeDotEnvFilesCore({ ...opts, onWarning: logger.warn });
}
/** Read global runtime dotenv files asynchronously into a caller-owned environment. */
export async function loadGlobalRuntimeDotEnvFilesAsync(
opts: GlobalRuntimeDotEnvOptions & { env: NodeJS.ProcessEnv },
) {
const { env } = opts;
const { globalEnvPaths, gatewayEnvPath } = resolveGlobalDotEnvPaths(opts, env);
const readOptions = { entryFilter: opts.entryFilter, quiet: opts.quiet ?? true };
const globalEnvs: (LoadedDotEnvFile | null)[] = [];
for (const filePath of globalEnvPaths) {
globalEnvs.push(await readDotEnvFileAsync({ ...readOptions, filePath }));
}
const gatewayEnv = gatewayEnvPath
? await readDotEnvFileAsync({ ...readOptions, filePath: gatewayEnvPath })
: null;
return applyGlobalDotEnvFiles(globalEnvs, gatewayEnv, env, opts.overrideKeys);
return await loadGlobalRuntimeDotEnvFilesAsyncCore({ ...opts, onWarning: logger.warn });
}

View file

@ -429,7 +429,8 @@ async function createStableReadOnlyCopy(
}
}
async function createOnlineReadOnlyBackup(
/** Native reads may create WAL-index files; callers need an isolated child or a private source. */
export async function createOnlineReadOnlyBackup(
pathname: string,
stagingRoot?: string,
signal?: AbortSignal,

Some files were not shown because too many files have changed in this diff Show more