@alfe.ai/gateway 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { a as installService, d as uninstallService, h as SOCKET_PATH, i as checkExistingDaemon, l as stopExistingDaemon, n as queryDaemonHealth, r as startDaemon, t as formatHealthReport } from "../health.js";
3
- import { p as LOG_FILE } from "../sentry.js";
3
+ import { m as LOG_FILE } from "../sentry.js";
4
4
  import { spawn } from "node:child_process";
5
5
  //#region bin/gateway.ts
6
6
  /**
package/dist/health.js CHANGED
@@ -1,5 +1,5 @@
1
- import { a as ErrorLineDetector, c as createLogger, f as __commonJSMin, i as CaptureThrottle, o as STRICT_ERROR_CAPTURE_PROFILE, p as __require, r as RuntimeProcess, s as toFingerprintKey, t as DRAIN_GRACE_MS } from "./runtime-process.js";
2
- import { a as captureFatal, d as initAgentSentry, f as setAgentContext, m as logger, o as captureIntegrationFailure, s as captureMcpFailure, u as flushSentry } from "./sentry.js";
1
+ import { a as ErrorLineDetector, c as createLogger, f as parseOpenClawVersion, h as __require, i as CaptureThrottle, m as __commonJSMin, o as STRICT_ERROR_CAPTURE_PROFILE, r as RuntimeProcess, s as toFingerprintKey, t as DRAIN_GRACE_MS } from "./runtime-process.js";
2
+ import { a as captureFatal, d as flushSentry, f as initAgentSentry, h as logger, o as captureIntegrationFailure, p as setAgentContext, s as captureMcpFailure } from "./sentry.js";
3
3
  import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  import { homedir } from "node:os";
@@ -7689,9 +7689,7 @@ var IPCServer = class {
7689
7689
  reject,
7690
7690
  timer
7691
7691
  });
7692
- try {
7693
- conn.socket.write(JSON.stringify(request) + "\n");
7694
- } catch (err) {
7692
+ if (!this.writeFrame(conn, request, "request")) {
7695
7693
  clearTimeout(timer);
7696
7694
  conn.pending.delete(id);
7697
7695
  resolve({
@@ -7699,7 +7697,7 @@ var IPCServer = class {
7699
7697
  ok: false,
7700
7698
  error: {
7701
7699
  code: "SEND_FAILED",
7702
- message: `Failed to send to plugin: ${err instanceof Error ? err.message : String(err)}`
7700
+ message: "Failed to send to plugin (write failed or frame over the transport limit)"
7703
7701
  }
7704
7702
  });
7705
7703
  }
@@ -7919,21 +7917,57 @@ var IPCServer = class {
7919
7917
  }));
7920
7918
  }
7921
7919
  sendResponse(conn, response) {
7920
+ if (!this.writeFrame(conn, response, "response")) logger.error({
7921
+ connId: conn.id,
7922
+ requestId: response.id
7923
+ }, "IPC: failed to send response");
7924
+ }
7925
+ sendEvent(conn, event, payload) {
7926
+ return this.writeFrame(conn, createIPCEvent(event, payload), "event");
7927
+ }
7928
+ /**
7929
+ * Every outbound frame goes through here. The plugin destroys its socket on
7930
+ * a frame over MAX_IPC_MESSAGE_BYTES (the mirror of our inbound cap), so a
7931
+ * frame we know it will reject must never be written: responses degrade to a
7932
+ * typed per-request error the plugin surfaces to the model, while requests
7933
+ * and events are logged and dropped (a dropped request times out at the
7934
+ * peer instead of killing the connection). MCP tool results are additionally
7935
+ * bounded semantically in mcp-handlers.ts so this is the backstop, not the
7936
+ * normal path.
7937
+ */
7938
+ writeFrame(conn, message, kind) {
7939
+ let frame;
7922
7940
  try {
7923
- conn.socket.write(JSON.stringify(response) + "\n");
7941
+ frame = JSON.stringify(message) + "\n";
7924
7942
  } catch (err) {
7925
7943
  logger.error({
7926
7944
  connId: conn.id,
7945
+ kind,
7927
7946
  err
7928
- }, "IPC: failed to send response");
7947
+ }, "IPC: failed to serialize outbound frame");
7948
+ if (kind !== "response") return false;
7949
+ frame = JSON.stringify(createIPCError(message.id, "INTERNAL", "Response could not be serialized")) + "\n";
7950
+ }
7951
+ const bytes = Buffer.byteLength(frame, "utf8");
7952
+ if (bytes > 1048576) {
7953
+ logger.warn({
7954
+ connId: conn.id,
7955
+ kind,
7956
+ bytes,
7957
+ maxBytes: MAX_IPC_MESSAGE_BYTES
7958
+ }, "IPC: outbound frame exceeded size limit");
7959
+ if (kind !== "response") return false;
7960
+ frame = JSON.stringify(createIPCError(message.id, "RESPONSE_TOO_LARGE", `Response exceeded the ${String(MAX_IPC_MESSAGE_BYTES)} byte transport limit and was dropped. Narrow the request (filters, smaller page size) and retry.`)) + "\n";
7929
7961
  }
7930
- }
7931
- sendEvent(conn, event, payload) {
7932
7962
  try {
7933
- const msg = createIPCEvent(event, payload);
7934
- conn.socket.write(JSON.stringify(msg) + "\n");
7963
+ conn.socket.write(frame);
7935
7964
  return true;
7936
- } catch {
7965
+ } catch (err) {
7966
+ logger.error({
7967
+ connId: conn.id,
7968
+ kind,
7969
+ err
7970
+ }, "IPC: failed to write frame");
7937
7971
  return false;
7938
7972
  }
7939
7973
  }
@@ -8006,9 +8040,24 @@ function resolveRuntimeVersionCommand(runtime) {
8006
8040
  return RUNTIME_VERSION_COMMANDS[runtime];
8007
8041
  }
8008
8042
  /**
8043
+ * Reduce a runtime's `--version` output to the bare version the control plane
8044
+ * compares against catalog versions. Every OpenClaw release prints a branded
8045
+ * line (`OpenClaw 2026.9.5 (ec9c1a1)`); reporting that verbatim hid every
8046
+ * dashboard upgrade affordance for the agents that most needed one. The agents
8047
+ * service also normalises at ingest (it reaches hosts on older daemons); this
8048
+ * is the producer-side half. Falls back to the trimmed output when no version
8049
+ * token is found so an unexpected format is still visible rather than blank.
8050
+ */
8051
+ function normalizeRuntimeVersion(runtime, stdout) {
8052
+ const trimmed = stdout.trim();
8053
+ if (!trimmed) return void 0;
8054
+ if (runtime === "openclaw") return parseOpenClawVersion(trimmed) ?? trimmed;
8055
+ return trimmed;
8056
+ }
8057
+ /**
8009
8058
  * Resolve the installed runtime version by running the per-runtime version
8010
- * command and returning the trimmed stdout. An unknown runtime (no command in
8011
- * the map) returns `undefined` without spawning — it must never throw.
8059
+ * command and normalising its stdout. An unknown runtime (no command in the
8060
+ * map) returns `undefined` without spawning — it must never throw.
8012
8061
  */
8013
8062
  async function getRuntimeVersion(runtime) {
8014
8063
  const cmd = resolveRuntimeVersionCommand(runtime);
@@ -8018,7 +8067,7 @@ async function getRuntimeVersion(runtime) {
8018
8067
  }
8019
8068
  try {
8020
8069
  const { stdout } = await execFileAsync$2(cmd.command, cmd.args);
8021
- return stdout.trim() || void 0;
8070
+ return normalizeRuntimeVersion(runtime, stdout);
8022
8071
  } catch {
8023
8072
  logger.debug({ runtime }, "Could not resolve runtime version");
8024
8073
  return;
@@ -8162,6 +8211,250 @@ async function startIpcServer(socketPath, requestHandler, daemonVersion) {
8162
8211
  //#endregion
8163
8212
  //#region src/mcp-handlers.ts
8164
8213
  /**
8214
+ * Aggregate text budget for one MCP tool call. An order of magnitude under
8215
+ * MAX_IPC_MESSAGE_BYTES because JSON escaping inflates text: quotes and
8216
+ * newlines cost 2 bytes, control characters 6. The serialized fence below
8217
+ * makes the guarantee absolute; this is the budget the model sees.
8218
+ */
8219
+ const MAX_MCP_RESULT_TEXT_BYTES = 256 * 1024;
8220
+ /**
8221
+ * Per-text-item cap. The consuming plugin (`@alfe.ai/openclaw-mcp-bundler`,
8222
+ * `plugin.ts` `MAX_JSON_STRING_LENGTH = 65_536`) rejects the WHOLE result as
8223
+ * "malformed" if any single string exceeds 65,536 UTF-16 code units, so a
8224
+ * kept item must never exceed that. UTF-8 bytes ≥ UTF-16 units, so capping
8225
+ * bytes at 64 KiB is safe for every already-deployed bundler.
8226
+ */
8227
+ const MAX_MCP_RESULT_ITEM_TEXT_BYTES = 64 * 1024;
8228
+ /** Worst-case JSON wrapper per content item (`{"type":"text","text":""},`) for the fast-path estimate. */
8229
+ const ITEM_ENVELOPE_BYTES = 64;
8230
+ function isTextItem(item) {
8231
+ return typeof item === "object" && item !== null && item.type === "text" && typeof item.text === "string";
8232
+ }
8233
+ function serializedBytes(value) {
8234
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
8235
+ }
8236
+ /**
8237
+ * Longest string value anywhere inside `value`, in UTF-8 bytes. The bundler
8238
+ * plugin's parser rejects the WHOLE result when any string at any depth is
8239
+ * over its cap (counted in UTF-16 units; UTF-8 bytes are never fewer), so
8240
+ * this is what a non-text item (image data, resource text) is measured by.
8241
+ */
8242
+ /**
8243
+ * The bundler plugin's `sanitizeJsonRecord` limits, mirrored so the daemon
8244
+ * never hands it an item it will reject (which nulls the WHOLE result).
8245
+ * Keep in sync with `packages/openclaw-mcp-bundler/src/plugin.ts`.
8246
+ */
8247
+ const PLUGIN_MAX_JSON_NODES = 1e4;
8248
+ const PLUGIN_MAX_JSON_DEPTH = 32;
8249
+ const PLUGIN_MAX_JSON_COLLECTION_ENTRIES = 2048;
8250
+ const PLUGIN_MAX_KEY_LENGTH = 512;
8251
+ /**
8252
+ * True when a content item stays inside the plugin parser's structural limits
8253
+ * (node budget per item, nesting depth, entries per collection, key length).
8254
+ * String length is bounded separately because `text` can be cut to fit; a
8255
+ * structural violation cannot, so the item is dropped.
8256
+ */
8257
+ function withinPluginStructure(item) {
8258
+ const budget = { nodes: 0 };
8259
+ const walk = (value, depth) => {
8260
+ budget.nodes += 1;
8261
+ if (budget.nodes > PLUGIN_MAX_JSON_NODES || depth > PLUGIN_MAX_JSON_DEPTH) return false;
8262
+ if (Array.isArray(value)) {
8263
+ if (value.length > PLUGIN_MAX_JSON_COLLECTION_ENTRIES) return false;
8264
+ for (const entry of value) if (!walk(entry, depth + 1)) return false;
8265
+ return true;
8266
+ }
8267
+ if (typeof value === "object" && value !== null) {
8268
+ const entries = Object.entries(value);
8269
+ if (entries.length > PLUGIN_MAX_JSON_COLLECTION_ENTRIES) return false;
8270
+ return entries.every(([key, entry]) => key.length <= PLUGIN_MAX_KEY_LENGTH && walk(entry, depth + 1));
8271
+ }
8272
+ return true;
8273
+ };
8274
+ return walk(item, 0);
8275
+ }
8276
+ function longestStringBytes(value) {
8277
+ if (typeof value === "string") return Buffer.byteLength(value, "utf8");
8278
+ if (Array.isArray(value)) {
8279
+ let max = 0;
8280
+ for (const entry of value) max = Math.max(max, longestStringBytes(entry));
8281
+ return max;
8282
+ }
8283
+ if (typeof value === "object" && value !== null) {
8284
+ let max = 0;
8285
+ for (const entry of Object.values(value)) max = Math.max(max, longestStringBytes(entry));
8286
+ return max;
8287
+ }
8288
+ return 0;
8289
+ }
8290
+ /** Cut at a UTF-8 boundary; drop a replacement char introduced by the cut. */
8291
+ function truncateUtf8(text, maxBytes) {
8292
+ const buf = Buffer.from(text, "utf8");
8293
+ if (buf.byteLength <= maxBytes) return text;
8294
+ const out = buf.subarray(0, maxBytes).toString("utf8");
8295
+ return out.endsWith("�") && !text.endsWith("�") ? out.slice(0, -1) : out;
8296
+ }
8297
+ /**
8298
+ * Model-visible notice. Deliberately names no bare tool: MCP hosts prefix tool
8299
+ * names, so a bare name is a dead end (packages/DEVELOPING.md, "Model-visible
8300
+ * errors carry the fix").
8301
+ */
8302
+ function truncationNotice(args) {
8303
+ const dropped = [];
8304
+ if (args.droppedItems > 0) dropped.push(`${String(args.droppedItems)} further content item(s)`);
8305
+ if (args.droppedStructured) dropped.push("the structured-content block");
8306
+ return [
8307
+ "[alfe] This tool result was truncated to fit the agent transport limit.",
8308
+ `Source result was ${String(args.sourceBytes)} bytes; returned ${String(args.keptBytes)} bytes of text` + (args.truncatedItems > 0 ? ` (${String(args.truncatedItems)} item(s) cut short).` : ".") + (dropped.length > 0 ? ` Dropped ${dropped.join(" and ")}.` : ""),
8309
+ "Do NOT retry this call unchanged; the same request will truncate again.",
8310
+ "Narrow it instead: add a date range or status filter, lower the page size",
8311
+ "(top / $top / limit), and page with skip / $skip. If this data source offers a",
8312
+ "summary or report form of the same question, prefer that over listing raw records."
8313
+ ].join("\n");
8314
+ }
8315
+ /**
8316
+ * Bound a bundler tool result so the daemon never writes an IPC frame the
8317
+ * plugin will reject. In-budget results are returned by reference (no clone).
8318
+ * Over budget: items are kept front-to-back until the budget (text bytes plus
8319
+ * a per-item envelope allowance) is spent. Text items are cut at the per-item
8320
+ * cap on a UTF-8 boundary; a non-text item carrying any string over that cap
8321
+ * (image data, resource text) is dropped outright because the plugin would
8322
+ * reject the whole result on its account and it cannot be cut meaningfully.
8323
+ * `structuredContent` is dropped, `isError` is preserved verbatim, and a
8324
+ * model-visible notice is appended. If the serialized result is still over
8325
+ * the fence, only the notice is returned.
8326
+ */
8327
+ function boundMcpToolResult(result) {
8328
+ if (!Array.isArray(result.content)) throw new Error("MCP tool result content must be an array");
8329
+ const content = result.content;
8330
+ let textBytes = 0;
8331
+ let maxItemBytes = 0;
8332
+ let onlyBareText = true;
8333
+ let structurallyValid = true;
8334
+ for (const item of content) {
8335
+ if (isTextItem(item)) {
8336
+ textBytes += Buffer.byteLength(item.text, "utf8");
8337
+ if (onlyBareText && Object.keys(item).some((key) => key !== "type" && key !== "text")) onlyBareText = false;
8338
+ } else onlyBareText = false;
8339
+ maxItemBytes = Math.max(maxItemBytes, longestStringBytes(item));
8340
+ if (structurallyValid && !withinPluginStructure(item)) structurallyValid = false;
8341
+ }
8342
+ const structured = result.structuredContent;
8343
+ const hasExtraKeys = Object.keys(result).some((key) => key !== "content" && key !== "isError" && key !== "structuredContent");
8344
+ if (structurallyValid && textBytes <= 262144 && maxItemBytes <= 65536) {
8345
+ const worstCase = textBytes * 6 + content.length * ITEM_ENVELOPE_BYTES + 256;
8346
+ if (onlyBareText && structured === void 0 && !hasExtraKeys && worstCase <= 524288) return result;
8347
+ if (serializedBytes(result) <= 524288) return result;
8348
+ }
8349
+ const sourceBytes = serializedBytes(result);
8350
+ const { structuredContent: _dropped, ...withExtras } = result;
8351
+ const rest = hasExtraKeys && serializedBytes(withExtras) - serializedBytes(content) > 524288 / 2 ? {
8352
+ content,
8353
+ ...result.isError === void 0 ? {} : { isError: result.isError }
8354
+ } : withExtras;
8355
+ for (const textBudget of [
8356
+ MAX_MCP_RESULT_TEXT_BYTES,
8357
+ MAX_MCP_RESULT_TEXT_BYTES / 2,
8358
+ MAX_MCP_RESULT_TEXT_BYTES / 4,
8359
+ MAX_MCP_RESULT_TEXT_BYTES / 8
8360
+ ]) {
8361
+ const candidate = boundWithBudget(content, rest, textBudget, sourceBytes, structured !== void 0);
8362
+ if (serializedBytes(candidate) <= 524288) return candidate;
8363
+ }
8364
+ logger.warn({ sourceBytes }, "MCP tool result still over the serialized fence after text truncation; returning notice only");
8365
+ const notice = truncationNotice({
8366
+ sourceBytes,
8367
+ keptBytes: 0,
8368
+ truncatedItems: 0,
8369
+ droppedItems: content.length,
8370
+ droppedStructured: structured !== void 0
8371
+ });
8372
+ return {
8373
+ ...rest,
8374
+ content: [{
8375
+ type: "text",
8376
+ text: notice
8377
+ }]
8378
+ };
8379
+ }
8380
+ function boundWithBudget(content, rest, textBudget, sourceBytes, droppedStructured) {
8381
+ let remaining = textBudget;
8382
+ let kept = 0;
8383
+ let truncatedItems = 0;
8384
+ let droppedItems = 0;
8385
+ let exhausted = false;
8386
+ const bounded = [];
8387
+ for (const item of content) {
8388
+ if (exhausted) {
8389
+ droppedItems += 1;
8390
+ continue;
8391
+ }
8392
+ if (!withinPluginStructure(item)) {
8393
+ droppedItems += 1;
8394
+ continue;
8395
+ }
8396
+ if (!isTextItem(item)) {
8397
+ if (longestStringBytes(item) > 65536) {
8398
+ droppedItems += 1;
8399
+ continue;
8400
+ }
8401
+ const size = serializedBytes(item) + ITEM_ENVELOPE_BYTES;
8402
+ if (size > remaining) {
8403
+ droppedItems += 1;
8404
+ exhausted = true;
8405
+ continue;
8406
+ }
8407
+ remaining -= size;
8408
+ bounded.push(item);
8409
+ continue;
8410
+ }
8411
+ if (longestStringBytes({
8412
+ ...item,
8413
+ text: ""
8414
+ }) > 65536) {
8415
+ droppedItems += 1;
8416
+ continue;
8417
+ }
8418
+ const size = Buffer.byteLength(item.text, "utf8");
8419
+ const allow = Math.min(remaining - ITEM_ENVELOPE_BYTES, MAX_MCP_RESULT_ITEM_TEXT_BYTES);
8420
+ if (allow <= 0) {
8421
+ droppedItems += 1;
8422
+ exhausted = true;
8423
+ continue;
8424
+ }
8425
+ if (size <= allow) {
8426
+ remaining -= size + ITEM_ENVELOPE_BYTES;
8427
+ kept += size;
8428
+ bounded.push(item);
8429
+ continue;
8430
+ }
8431
+ const cut = truncateUtf8(item.text, allow);
8432
+ const cutBytes = Buffer.byteLength(cut, "utf8");
8433
+ kept += cutBytes;
8434
+ remaining -= cutBytes + ITEM_ENVELOPE_BYTES;
8435
+ truncatedItems += 1;
8436
+ bounded.push({
8437
+ ...item,
8438
+ text: cut
8439
+ });
8440
+ if (remaining <= 0) exhausted = true;
8441
+ }
8442
+ const notice = {
8443
+ type: "text",
8444
+ text: truncationNotice({
8445
+ sourceBytes,
8446
+ keptBytes: kept,
8447
+ truncatedItems,
8448
+ droppedItems,
8449
+ droppedStructured
8450
+ })
8451
+ };
8452
+ return {
8453
+ ...rest,
8454
+ content: [...bounded, notice]
8455
+ };
8456
+ }
8457
+ /**
8165
8458
  * Return the daemon-hosted MCP bundler's current namespaced tool catalog.
8166
8459
  * Called by the openclaw-mcp-bundler plugin on every tool-factory invocation;
8167
8460
  * cheap (in-memory snapshot, no I/O).
@@ -8429,7 +8722,7 @@ async function handleMcpCallTool(bundler, params) {
8429
8722
  try {
8430
8723
  return {
8431
8724
  ok: true,
8432
- payload: await bundler.callTool(name, args)
8725
+ payload: boundMcpToolResult(await bundler.callTool(name, args))
8433
8726
  };
8434
8727
  } catch (err) {
8435
8728
  const message = err instanceof Error ? err.message : String(err);
@@ -9695,7 +9988,7 @@ async function executeCloudCommand(command) {
9695
9988
  };
9696
9989
  const payload = command.payload;
9697
9990
  const runtime = config.runtime;
9698
- const version = payload?.version ?? (runtime === "openclaw" ? "2026.8.1" : runtime === "claude-code" ? "0.1.6" : void 0);
9991
+ const version = payload?.version ?? (runtime === "openclaw" ? "2026.9.5" : runtime === "claude-code" ? "0.1.6" : void 0);
9699
9992
  upgradingRuntime = true;
9700
9993
  setTimeout(() => {
9701
9994
  (async () => {
@@ -9703,6 +9996,7 @@ async function executeCloudCommand(command) {
9703
9996
  const { upgradeRuntime } = await import("./runtime-upgrade.js");
9704
9997
  const result = await upgradeRuntime(runtime, version, runtimeProcess, {
9705
9998
  ...runtime === "openclaw" && openclawCliLockRef ? { cliLock: openclawCliLockRef } : {},
9999
+ ...runtime === "openclaw" && isManagedMode() && process.env.ALFE_PRIVATE_DATA_PLANE !== "true" ? { nodeRemediation: {} } : {},
9706
10000
  ...runtime === "openclaw" && runtimeGateRef ? { gate: runtimeGateRef } : {}
9707
10001
  });
9708
10002
  if (result.success || result.runtimeChanged) {
@@ -9714,7 +10008,8 @@ async function executeCloudCommand(command) {
9714
10008
  else {
9715
10009
  logger.info({
9716
10010
  runtime,
9717
- runtimeVersion: resolvedRuntimeVersion
10011
+ runtimeVersion: resolvedRuntimeVersion,
10012
+ nodeUpgraded: result.nodeUpgraded ?? false
9718
10013
  }, "Runtime version updated after upgrade");
9719
10014
  cloudClient.updateRuntimeVersionAndReRegister(resolvedRuntimeVersion);
9720
10015
  }
@@ -9722,6 +10017,7 @@ async function executeCloudCommand(command) {
9722
10017
  if (!result.success) logger.error({
9723
10018
  runtime,
9724
10019
  version,
10020
+ nodeUpgraded: result.nodeUpgraded ?? false,
9725
10021
  error: result.error
9726
10022
  }, "Runtime upgrade did not complete cleanly");
9727
10023
  } catch (err) {
@@ -0,0 +1,163 @@
1
+ import { p as supportsOpenClawNode, u as OPENCLAW_NODE_REQUIREMENT } from "./runtime-process.js";
2
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ //#region src/node-remediation.ts
6
+ /**
7
+ * Managed-host Node.js remediation.
8
+ *
9
+ * OpenClaw 2026.9.x dropped Node 22 (and 25) from its engine range and its
10
+ * preinstall refuses to install on them, while every managed Hetzner VM was
11
+ * provisioned on Node 22. Rather than rebuild the fleet, the daemon's
12
+ * `runtime.update` preflight and `alfe setup --managed` install the NodeSource
13
+ * channel for `OPENCLAW_MANAGED_NODE_MAJOR` in place, re-verify, and continue.
14
+ *
15
+ * Self-hosted hosts never reach this module: the operator owns their Node and
16
+ * gets the explicit engine error instead.
17
+ */
18
+ /**
19
+ * NodeSource setup + apt on a 2-vCPU box can take a few minutes, and
20
+ * `unattended-upgrades` may hold the dpkg lock for a while first.
21
+ */
22
+ const NODE_REMEDIATION_TIMEOUT_MS = 6e5;
23
+ /**
24
+ * After a failed attempt, refuse to re-run `curl | bash` + apt for this long.
25
+ * `runtime.update` has no server-side dedupe or backoff (the dashboard and the
26
+ * fleet bulk path can re-issue it immediately and indefinitely), so a host
27
+ * whose remediation keeps failing must degrade to a cheap, loud no-op rather
28
+ * than a NodeSource fetch storm.
29
+ */
30
+ const NODE_REMEDIATION_FAILURE_COOLDOWN_MS = 30 * 6e4;
31
+ const NODESOURCE_SETUP_URL = `https://deb.nodesource.com/setup_${String(24)}.x`;
32
+ const FAILURE_MARKER_FILE = "node-remediation-failure.json";
33
+ /**
34
+ * Thrown when remediation fails. `nodeReplaced` is the load-bearing field: a
35
+ * failure AFTER apt has swapped the host Node (verification or the installed
36
+ * runtime's boot check failed) leaves the host on a new, unproven Node, which
37
+ * is the state that needs an operator — very different from "never touched".
38
+ */
39
+ var NodeRemediationError = class extends Error {
40
+ nodeReplaced;
41
+ resolvedNode;
42
+ cooldownActive;
43
+ constructor(message, details) {
44
+ super(message, details.cause === void 0 ? void 0 : { cause: details.cause });
45
+ this.name = "NodeRemediationError";
46
+ this.nodeReplaced = details.nodeReplaced;
47
+ this.resolvedNode = details.resolvedNode;
48
+ this.cooldownActive = details.cooldownActive ?? false;
49
+ }
50
+ };
51
+ async function readFailureMarker(path) {
52
+ try {
53
+ const parsed = JSON.parse(await readFile(path, "utf8"));
54
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.failedAt === "number") return parsed;
55
+ } catch {}
56
+ return null;
57
+ }
58
+ async function writeFailureMarker(path, marker) {
59
+ try {
60
+ await mkdir(dirname(path), {
61
+ recursive: true,
62
+ mode: 448
63
+ });
64
+ await writeFile(path, JSON.stringify(marker), { mode: 384 });
65
+ } catch {}
66
+ }
67
+ /**
68
+ * Install the managed Node.js channel on a Debian/Ubuntu managed host and
69
+ * return the resolved `node --version`.
70
+ *
71
+ * Runs BEFORE any runtime gate suspend or backup: the live OpenClaw child keeps
72
+ * its already-open binary, and later `openclaw`/`npm` spawns resolve the new
73
+ * binary through PATH. Fails closed (with `nodeReplaced` set accurately) if the
74
+ * resulting Node still misses the range or if the currently installed OpenClaw
75
+ * no longer boots under it — in that case the caller must not proceed to a
76
+ * backup it could not produce.
77
+ */
78
+ async function remediateManagedNode(runCommand, baseEnv, options = {}) {
79
+ const now = options.now ?? Date.now;
80
+ const stateDirectory = options.stateDirectory === void 0 ? join(homedir(), ".alfe") : options.stateDirectory;
81
+ const markerPath = stateDirectory ? join(stateDirectory, FAILURE_MARKER_FILE) : null;
82
+ if (markerPath) {
83
+ const marker = await readFailureMarker(markerPath);
84
+ if (marker && now() - marker.failedAt < 18e5) {
85
+ const remaining = Math.ceil((NODE_REMEDIATION_FAILURE_COOLDOWN_MS - (now() - marker.failedAt)) / 6e4);
86
+ throw new NodeRemediationError(`Node.js remediation failed ${String(Math.round((now() - marker.failedAt) / 6e4))} min ago (${marker.error}); not retrying for another ${String(remaining)} min. Fix the host (network egress to deb.nodesource.com, apt state) or remove ${markerPath} to retry now.`, {
87
+ nodeReplaced: false,
88
+ cooldownActive: true
89
+ });
90
+ }
91
+ }
92
+ const env = {
93
+ ...baseEnv,
94
+ DEBIAN_FRONTEND: "noninteractive"
95
+ };
96
+ const longRunning = {
97
+ timeout: NODE_REMEDIATION_TIMEOUT_MS,
98
+ env
99
+ };
100
+ const quick = {
101
+ timeout: 1e4,
102
+ env
103
+ };
104
+ const { stdout: before } = await runCommand("node", ["--version"], quick).catch(() => ({ stdout: "" }));
105
+ const fromNode = before.trim();
106
+ const fail = async (message, details) => {
107
+ if (markerPath) await writeFailureMarker(markerPath, {
108
+ failedAt: now(),
109
+ fromNode,
110
+ error: message
111
+ });
112
+ throw new NodeRemediationError(message, details);
113
+ };
114
+ try {
115
+ await runCommand("bash", ["-c", `set -o pipefail; curl -fsSL --retry 3 --retry-connrefused --max-time 180 ${NODESOURCE_SETUP_URL} | bash -`], longRunning);
116
+ await runCommand("apt-get", [
117
+ "-o",
118
+ `DPkg::Lock::Timeout=${String(300)}`,
119
+ "install",
120
+ "-y",
121
+ "-qq",
122
+ "nodejs"
123
+ ], longRunning);
124
+ } catch (err) {
125
+ const message = err instanceof Error ? err.message : String(err);
126
+ const observed = await runCommand("node", ["--version"], quick).then((r) => r.stdout.trim()).catch(() => void 0);
127
+ const nodeReplaced = observed !== void 0 && observed !== fromNode;
128
+ return fail(`Node.js channel install failed: ${message}`, {
129
+ nodeReplaced,
130
+ ...nodeReplaced ? { resolvedNode: observed } : {},
131
+ cause: err
132
+ });
133
+ }
134
+ let nodeVersion;
135
+ try {
136
+ const { stdout } = await runCommand("node", ["--version"], quick);
137
+ nodeVersion = stdout.trim();
138
+ } catch (err) {
139
+ return fail(`Node.js was replaced but \`node --version\` failed afterwards: ${err instanceof Error ? err.message : String(err)}`, {
140
+ nodeReplaced: true,
141
+ cause: err
142
+ });
143
+ }
144
+ const nodeReplaced = nodeVersion !== fromNode;
145
+ if (!supportsOpenClawNode(nodeVersion)) return fail(`Node.js remediation resolved ${nodeVersion}, which does not satisfy ${OPENCLAW_NODE_REQUIREMENT}`, {
146
+ nodeReplaced,
147
+ resolvedNode: nodeVersion
148
+ });
149
+ if (options.verifyInstalledRuntime ?? true) try {
150
+ await runCommand("openclaw", ["--version"], quick);
151
+ } catch (err) {
152
+ const message = err instanceof Error ? err.message : String(err);
153
+ return fail(`Host Node.js was replaced with ${nodeVersion} but the installed OpenClaw no longer boots under it: ${message}`, {
154
+ nodeReplaced,
155
+ resolvedNode: nodeVersion,
156
+ cause: err
157
+ });
158
+ }
159
+ if (markerPath) await unlink(markerPath).catch(() => void 0);
160
+ return nodeVersion;
161
+ }
162
+ //#endregion
163
+ export { remediateManagedNode as n, NodeRemediationError as t };
@@ -1,4 +1,4 @@
1
- import { c as captureRuntimeCrash, l as captureRuntimeErrorOutput } from "./sentry.js";
1
+ import { l as captureRuntimeCrash, u as captureRuntimeErrorOutput } from "./sentry.js";
2
2
  import { createRequire } from "node:module";
3
3
  import pino from "pino";
4
4
  import crypto from "crypto";
@@ -50,32 +50,55 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
50
50
  * truth for every install path:
51
51
  * - `@alfe.ai/cli` `installRuntime` (fresh `alfe setup`)
52
52
  * - the daemon `runtime.update` default (this package)
53
- * - managed provisioning (`services/compute/src/lib/provisioner.ts` installs a
54
- * compatible Node.js 22 before the CLI consumes this pin)
53
+ * - managed provisioning (`services/compute/src/lib/provisioner.ts` installs the
54
+ * Node.js 24 channel before the CLI consumes this pin)
55
55
  *
56
56
  * Pinning (rather than floating `npm install -g openclaw` → `latest`) is deliberate:
57
57
  * OpenClaw runs on every managed agent, so a `latest` that changes runtime semantics —
58
58
  * e.g. 2026.6.8 made a non-empty `plugins.allow` an exclusive allowlist — becomes a
59
59
  * silent fleet outage with no Alfe deploy. Bump deliberately after validating a new
60
60
  * OpenClaw, then cut a CLI release so the pin travels with the CLI version.
61
+ *
62
+ * Raising the engine range below obligates every consumer of it: the daemon upgrade
63
+ * preflight (`runtime-upgrade.ts`), the CLI `installRuntime`, and the compute
64
+ * cloud-init literals (tied to these constants by `provisioner.test.ts`).
65
+ */
66
+ const PINNED_OPENCLAW_VERSION = "2026.9.5";
67
+ /**
68
+ * OpenClaw 2026.9.5's published Node.js engine floor. Node 22 and Node 25 are no
69
+ * longer supported upstream (2026.9.x dropped both branches; the preinstall script
70
+ * refuses to install on them).
71
+ */
72
+ const OPENCLAW_NODE_REQUIREMENT = ">=24.16.0 <25 || >=26.1.0";
73
+ /**
74
+ * The Node.js major the managed image installs (NodeSource `setup_24.x`) and that
75
+ * `upgradeRuntime` remediates existing managed hosts onto. 24 is the current LTS
76
+ * line; 26 is not LTS yet.
61
77
  */
62
- const PINNED_OPENCLAW_VERSION = "2026.8.1";
63
- /** OpenClaw 2026.8.1's published Node.js engine floor. */
64
- const OPENCLAW_NODE_REQUIREMENT = ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0";
78
+ const OPENCLAW_MANAGED_NODE_MAJOR = 24;
65
79
  /**
66
- * Whether a concrete Node.js version satisfies OpenClaw 2.x's engine range.
67
- * Accepts `process.versions.node` (`22.22.3`) or CLI output (`v22.22.3`).
80
+ * Whether a concrete Node.js version satisfies OpenClaw's engine range.
81
+ * Accepts `process.versions.node` (`24.21.0`) or CLI output (`v24.21.0`).
82
+ * Pre-release tags are rejected; build metadata (`+…`) is accepted.
68
83
  */
69
84
  function supportsOpenClawNode(nodeVersion) {
70
85
  const match = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/u.exec(nodeVersion.trim());
71
86
  if (!match) return false;
72
87
  const major = Number(match[1]);
73
88
  const minor = Number(match[2]);
74
- const patch = Number(match[3]);
75
- if (major === 22) return minor > 22 || minor === 22 && patch >= 3;
76
- if (major === 24) return minor >= 15;
77
- if (major === 25) return minor > 9 || minor === 9 && patch >= 0;
78
- return major > 25;
89
+ if (major === 24) return minor >= 16;
90
+ if (major === 26) return minor >= 1;
91
+ return major > 26;
92
+ }
93
+ /**
94
+ * Extract the semver-like version token printed by `openclaw --version`.
95
+ * Every OpenClaw release prints a branded line (`OpenClaw 2026.6.11 (e085fa1)`,
96
+ * `OpenClaw 2026.9.5 (ec9c1a1)`); this reduces it to the bare token. Requires
97
+ * whitespace delimiters and a four-digit year major, so unexpected formats
98
+ * return `undefined` and callers fall back to the raw line.
99
+ */
100
+ function parseOpenClawVersion(output) {
101
+ return /(?:^|\s)(\d{4}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\s|$)/u.exec(output.trim())?.[1];
79
102
  }
80
103
  //#endregion
81
104
  //#region ../../node_modules/.pnpm/dotenv@17.2.2/node_modules/dotenv/package.json
@@ -15389,11 +15412,25 @@ const DETECTOR_FLUSH_DEBOUNCE_MS = 1500;
15389
15412
  const BACKOFF_INITIAL_MS = 1e3;
15390
15413
  const BACKOFF_MAX_MS = 3e4;
15391
15414
  const STABLE_UPTIME_MS = 6e4;
15392
- /** Alfe owns supervision and version rollout; OpenClaw must not self-manage. */
15415
+ /**
15416
+ * Alfe owns supervision and version rollout; OpenClaw must not self-manage.
15417
+ *
15418
+ * `OPENCLAW_NODE_UPDATE_RESPAWNED=1` also disables 2026.9.x's node-runtime
15419
+ * recovery, which would otherwise re-exec the CLI under whatever other Node it
15420
+ * finds on PATH when the current one fails engine admission. Alfe chooses the
15421
+ * Node binary (managed remediation in `node-remediation.ts`); an admission
15422
+ * failure must surface as a clean exit the daemon can report, not a silent
15423
+ * respawn under an unmanaged interpreter.
15424
+ *
15425
+ * `OPENCLAW_CONFIG_READONLY` must never be set on the child or on upgrade
15426
+ * commands: since 2026.9.x it makes openclaw.json immutable and blocks setup,
15427
+ * doctor repairs and plugin installs.
15428
+ */
15393
15429
  const OPENCLAW_EXTERNAL_SUPERVISOR_ENV = {
15394
15430
  OPENCLAW_SUPERVISOR_MODE: "external",
15395
15431
  OPENCLAW_SERVICE_REPAIR_POLICY: "external",
15396
- OPENCLAW_NO_AUTO_UPDATE: "1"
15432
+ OPENCLAW_NO_AUTO_UPDATE: "1",
15433
+ OPENCLAW_NODE_UPDATE_RESPAWNED: "1"
15397
15434
  };
15398
15435
  /**
15399
15436
  * Max time a NON-crash restart waits for an in-flight chat turn to finish
@@ -15739,4 +15776,4 @@ var RuntimeProcess = class {
15739
15776
  }
15740
15777
  };
15741
15778
  //#endregion
15742
- export { ErrorLineDetector as a, createLogger as c, supportsOpenClawNode as d, __commonJSMin as f, CaptureThrottle as i, OPENCLAW_NODE_REQUIREMENT as l, OPENCLAW_EXTERNAL_SUPERVISOR_ENV as n, STRICT_ERROR_CAPTURE_PROFILE as o, __require as p, RuntimeProcess as r, toFingerprintKey as s, DRAIN_GRACE_MS as t, PINNED_OPENCLAW_VERSION as u };
15779
+ export { ErrorLineDetector as a, createLogger as c, PINNED_OPENCLAW_VERSION as d, parseOpenClawVersion as f, __require as h, CaptureThrottle as i, OPENCLAW_MANAGED_NODE_MAJOR as l, __commonJSMin as m, OPENCLAW_EXTERNAL_SUPERVISOR_ENV as n, STRICT_ERROR_CAPTURE_PROFILE as o, supportsOpenClawNode as p, RuntimeProcess as r, toFingerprintKey as s, DRAIN_GRACE_MS as t, OPENCLAW_NODE_REQUIREMENT as u };
@@ -1,5 +1,6 @@
1
- import { d as supportsOpenClawNode, l as OPENCLAW_NODE_REQUIREMENT, n as OPENCLAW_EXTERNAL_SUPERVISOR_ENV } from "./runtime-process.js";
2
- import { m as logger } from "./sentry.js";
1
+ import { f as parseOpenClawVersion, n as OPENCLAW_EXTERNAL_SUPERVISOR_ENV, p as supportsOpenClawNode, u as OPENCLAW_NODE_REQUIREMENT } from "./runtime-process.js";
2
+ import { c as captureNodeRemediation, h as logger } from "./sentry.js";
3
+ import { n as remediateManagedNode, t as NodeRemediationError } from "./node-remediation.js";
3
4
  import { mkdir } from "node:fs/promises";
4
5
  import { join } from "node:path";
5
6
  import { homedir } from "node:os";
@@ -25,7 +26,21 @@ import { promisify } from "node:util";
25
26
  */
26
27
  const execFileAsync = promisify(execFile);
27
28
  const OPENCLAW_UPGRADE_TIMEOUT_MS = 3e5;
28
- const OPENCLAW_DOCTOR_TIMEOUT_MS = 3e5;
29
+ const OPENCLAW_DOCTOR_TIMEOUT_MS = 9e5;
30
+ /**
31
+ * A non-diagnostic probe run right after the new package lands. 2026.9.x
32
+ * classifies `--version`/`--help` as diagnostic and lets them succeed even when
33
+ * Node/SQLite runtime admission fails; `config` does not get that pass, so an
34
+ * admission failure surfaces here — while `doctorStarted` is still false and
35
+ * the pre-doctor package rollback is still safe. `config file` (prints the
36
+ * resolved config path) is used rather than `config get <key>` because `get`
37
+ * exits 1 for a valid-but-unset key, and not every agent has every key
38
+ * authored (self-hosted agents set up by older CLIs, the ENOENT branch of
39
+ * `initWorkspace`); a state-dependent probe would roll a good package back.
40
+ * `config file` exists on 2026.8.1 and 2026.9.5 and exits 0 on an admitted
41
+ * runtime regardless of config contents.
42
+ */
43
+ const OPENCLAW_ADMISSION_PROBE_ARGS = ["config", "file"];
29
44
  function resolveUpgradeCommand(runtime, version) {
30
45
  switch (runtime) {
31
46
  case "openclaw":
@@ -55,10 +70,6 @@ function resolveUpgradeCommand(runtime, version) {
55
70
  default: return;
56
71
  }
57
72
  }
58
- /** Extract the semver-like version token printed by `openclaw --version`. */
59
- function parseOpenClawVersion(output) {
60
- return /(?:^|\s)(\d{4}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\s|$)/u.exec(output.trim())?.[1];
61
- }
62
73
  async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
63
74
  const runCommand = options.runCommand ?? (async (command, args, commandOptions) => {
64
75
  const result = await execFileAsync(command, args, commandOptions);
@@ -67,12 +78,13 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
67
78
  stderr: result.stderr
68
79
  };
69
80
  });
81
+ const openclawEnv = {
82
+ ...process.env,
83
+ ...OPENCLAW_EXTERNAL_SUPERVISOR_ENV
84
+ };
70
85
  const commandOptions = (timeout) => runtime === "openclaw" ? {
71
86
  timeout,
72
- env: {
73
- ...process.env,
74
- ...OPENCLAW_EXTERNAL_SUPERVISOR_ENV
75
- }
87
+ env: openclawEnv
76
88
  } : { timeout };
77
89
  const cmd = resolveUpgradeCommand(runtime, version);
78
90
  if (!cmd) {
@@ -89,24 +101,60 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
89
101
  };
90
102
  }
91
103
  let previousOpenClawVersion;
104
+ let nodeUpgraded = false;
92
105
  if (runtime === "openclaw") try {
93
106
  const [{ stdout: nodeOutput }, { stdout: openClawOutput }] = await Promise.all([runCommand("node", ["--version"], commandOptions(1e4)), runCommand("openclaw", ["--version"], commandOptions(1e4))]);
94
- const nodeVersion = nodeOutput.trim();
107
+ previousOpenClawVersion = parseOpenClawVersion(openClawOutput);
108
+ let nodeVersion = nodeOutput.trim();
95
109
  if (!supportsOpenClawNode(nodeVersion)) {
96
- const message = `OpenClaw ${version ?? "2.x"} requires Node.js ${OPENCLAW_NODE_REQUIREMENT}; this host resolves ${nodeVersion}. Upgrade Node.js or rebuild the agent before retrying.`;
97
- logger.error({
110
+ if (!options.nodeRemediation) {
111
+ const message = `OpenClaw ${version ?? "2.x"} requires Node.js ${OPENCLAW_NODE_REQUIREMENT}; this host resolves ${nodeVersion}. Upgrade Node.js or rebuild the agent before retrying.`;
112
+ logger.error({
113
+ runtime,
114
+ version,
115
+ nodeVersion
116
+ }, message);
117
+ return {
118
+ success: false,
119
+ runtime,
120
+ version,
121
+ error: message
122
+ };
123
+ }
124
+ const fromNode = nodeVersion;
125
+ logger.warn({
98
126
  runtime,
99
127
  version,
128
+ nodeVersion,
129
+ requirement: OPENCLAW_NODE_REQUIREMENT
130
+ }, "Host Node.js does not satisfy OpenClaw; installing the managed Node channel first");
131
+ try {
132
+ nodeVersion = await remediateManagedNode(runCommand, openclawEnv, options.nodeRemediation);
133
+ } catch (err) {
134
+ const replaced = err instanceof NodeRemediationError && err.nodeReplaced;
135
+ const resolvedNode = err instanceof NodeRemediationError ? err.resolvedNode : void 0;
136
+ nodeUpgraded = replaced;
137
+ if (!(err instanceof NodeRemediationError && err.cooldownActive)) captureNodeRemediation({
138
+ outcome: "failure",
139
+ fromNode,
140
+ ...replaced && resolvedNode ? { toNode: resolvedNode } : {},
141
+ runtimeVersion: previousOpenClawVersion,
142
+ cause: err
143
+ });
144
+ throw err;
145
+ }
146
+ nodeUpgraded = true;
147
+ captureNodeRemediation({
148
+ outcome: "success",
149
+ fromNode,
150
+ toNode: nodeVersion,
151
+ runtimeVersion: previousOpenClawVersion
152
+ });
153
+ logger.info({
154
+ fromNode,
100
155
  nodeVersion
101
- }, message);
102
- return {
103
- success: false,
104
- runtime,
105
- version,
106
- error: message
107
- };
156
+ }, "Managed Node.js remediation complete");
108
157
  }
109
- previousOpenClawVersion = parseOpenClawVersion(openClawOutput);
110
158
  } catch (err) {
111
159
  const message = `OpenClaw upgrade preflight failed: ${err instanceof Error ? err.message : String(err)}`;
112
160
  logger.error({
@@ -117,6 +165,7 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
117
165
  success: false,
118
166
  runtime,
119
167
  version,
168
+ nodeUpgraded,
120
169
  error: message
121
170
  };
122
171
  }
@@ -155,6 +204,7 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
155
204
  const installedVersion = parseOpenClawVersion((await runCommand("openclaw", ["--version"], commandOptions(1e4))).stdout);
156
205
  if (installedVersion !== version) throw new Error(`OpenClaw package verification failed: requested ${version}, resolved ${installedVersion ?? "unknown"}`);
157
206
  runtimeChanged = installedVersion !== previousOpenClawVersion;
207
+ await runCommand("openclaw", [...OPENCLAW_ADMISSION_PROBE_ARGS], commandOptions(6e4));
158
208
  doctorStarted = true;
159
209
  await runCommand("openclaw", [
160
210
  "doctor",
@@ -195,13 +245,15 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
195
245
  else await performUpgrade();
196
246
  logger.info({
197
247
  runtime,
198
- version
248
+ version,
249
+ nodeUpgraded
199
250
  }, "Runtime upgraded successfully");
200
251
  return {
201
252
  success: true,
202
253
  runtime,
203
254
  version,
204
- runtimeChanged
255
+ runtimeChanged,
256
+ nodeUpgraded
205
257
  };
206
258
  } catch (err) {
207
259
  const message = err instanceof Error ? err.message : String(err);
@@ -215,6 +267,7 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
215
267
  runtime,
216
268
  version,
217
269
  runtimeChanged,
270
+ nodeUpgraded,
218
271
  error: message
219
272
  };
220
273
  } finally {
package/dist/sentry.js CHANGED
@@ -313,6 +313,34 @@ function captureCliFailure(context, cause) {
313
313
  } catch {}
314
314
  }
315
315
  /**
316
+ * Fleet-visible record of a managed Node.js remediation attempt (the in-place
317
+ * Node 22 → 24 move `runtime-upgrade.ts` performs before installing an OpenClaw
318
+ * that dropped Node 22). One event per host per attempt so the campaign can
319
+ * count remediated vs failed agents; the daemon's local log alone cannot answer
320
+ * that. No-op when Sentry is not initialised. Never throws.
321
+ */
322
+ function captureNodeRemediation(opts) {
323
+ if (!sentry) return;
324
+ try {
325
+ const tags = {
326
+ context: "node-remediation",
327
+ outcome: opts.outcome,
328
+ fromNode: opts.fromNode,
329
+ ...opts.toNode ? { toNode: opts.toNode } : {},
330
+ ...opts.runtimeVersion ? { runtimeVersion: opts.runtimeVersion } : {}
331
+ };
332
+ if (opts.outcome === "failure" && opts.cause instanceof Error) {
333
+ sentry.captureException(opts.cause, { tags });
334
+ return;
335
+ }
336
+ const detail = opts.outcome === "failure" && opts.cause !== void 0 ? `: ${String(opts.cause)}` : "";
337
+ sentry.captureMessage(`node remediation ${opts.outcome}${detail}`, {
338
+ level: opts.outcome === "failure" ? "error" : "info",
339
+ tags
340
+ });
341
+ } catch {}
342
+ }
343
+ /**
316
344
  * Capture a runtime child-process crash (non-graceful exit) or spawn failure
317
345
  * into the dedicated `agent-runtime` project. Recent output travels as
318
346
  * breadcrumbs — NEVER `extra`, which `scrubEvent` (the runtime client's
@@ -428,4 +456,4 @@ function captureFatal(cause) {
428
456
  } catch {}
429
457
  }
430
458
  //#endregion
431
- export { captureFatal as a, captureRuntimeCrash as c, initAgentSentry as d, setAgentContext as f, captureCliFailure as i, captureRuntimeErrorOutput as l, logger as m, AGENT_MCP_SENTRY_DSN as n, captureIntegrationFailure as o, LOG_FILE as p, AGENT_RUNTIME_SENTRY_DSN as r, captureMcpFailure as s, AGENT_DAEMON_SENTRY_DSN as t, flushSentry as u };
459
+ export { captureFatal as a, captureNodeRemediation as c, flushSentry as d, initAgentSentry as f, logger as h, captureCliFailure as i, captureRuntimeCrash as l, LOG_FILE as m, AGENT_MCP_SENTRY_DSN as n, captureIntegrationFailure as o, setAgentContext as p, AGENT_RUNTIME_SENTRY_DSN as r, captureMcpFailure as s, AGENT_DAEMON_SENTRY_DSN as t, captureRuntimeErrorOutput as u };
@@ -44,23 +44,89 @@ declare function startDaemon(): Promise<void>;
44
44
  * truth for every install path:
45
45
  * - `@alfe.ai/cli` `installRuntime` (fresh `alfe setup`)
46
46
  * - the daemon `runtime.update` default (this package)
47
- * - managed provisioning (`services/compute/src/lib/provisioner.ts` installs a
48
- * compatible Node.js 22 before the CLI consumes this pin)
47
+ * - managed provisioning (`services/compute/src/lib/provisioner.ts` installs the
48
+ * Node.js 24 channel before the CLI consumes this pin)
49
49
  *
50
50
  * Pinning (rather than floating `npm install -g openclaw` → `latest`) is deliberate:
51
51
  * OpenClaw runs on every managed agent, so a `latest` that changes runtime semantics —
52
52
  * e.g. 2026.6.8 made a non-empty `plugins.allow` an exclusive allowlist — becomes a
53
53
  * silent fleet outage with no Alfe deploy. Bump deliberately after validating a new
54
54
  * OpenClaw, then cut a CLI release so the pin travels with the CLI version.
55
+ *
56
+ * Raising the engine range below obligates every consumer of it: the daemon upgrade
57
+ * preflight (`runtime-upgrade.ts`), the CLI `installRuntime`, and the compute
58
+ * cloud-init literals (tied to these constants by `provisioner.test.ts`).
59
+ */
60
+ declare const PINNED_OPENCLAW_VERSION = "2026.9.5";
61
+ /**
62
+ * OpenClaw 2026.9.5's published Node.js engine floor. Node 22 and Node 25 are no
63
+ * longer supported upstream (2026.9.x dropped both branches; the preinstall script
64
+ * refuses to install on them).
55
65
  */
56
- declare const PINNED_OPENCLAW_VERSION = "2026.8.1";
57
- /** OpenClaw 2026.8.1's published Node.js engine floor. */
58
- declare const OPENCLAW_NODE_REQUIREMENT = ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0";
66
+ declare const OPENCLAW_NODE_REQUIREMENT = ">=24.16.0 <25 || >=26.1.0";
59
67
  /**
60
- * Whether a concrete Node.js version satisfies OpenClaw 2.x's engine range.
61
- * Accepts `process.versions.node` (`22.22.3`) or CLI output (`v22.22.3`).
68
+ * The Node.js major the managed image installs (NodeSource `setup_24.x`) and that
69
+ * `upgradeRuntime` remediates existing managed hosts onto. 24 is the current LTS
70
+ * line; 26 is not LTS yet.
71
+ */
72
+ declare const OPENCLAW_MANAGED_NODE_MAJOR = 24;
73
+ /**
74
+ * Whether a concrete Node.js version satisfies OpenClaw's engine range.
75
+ * Accepts `process.versions.node` (`24.21.0`) or CLI output (`v24.21.0`).
76
+ * Pre-release tags are rejected; build metadata (`+…`) is accepted.
62
77
  */
63
78
  declare function supportsOpenClawNode(nodeVersion: string): boolean;
79
+ /**
80
+ * Extract the semver-like version token printed by `openclaw --version`.
81
+ * Every OpenClaw release prints a branded line (`OpenClaw 2026.6.11 (e085fa1)`,
82
+ * `OpenClaw 2026.9.5 (ec9c1a1)`); this reduces it to the bare token. Requires
83
+ * whitespace delimiters and a four-digit year major, so unexpected formats
84
+ * return `undefined` and callers fall back to the raw line.
85
+ */
86
+ //#endregion
87
+ //#region src/node-remediation.d.ts
88
+
89
+ type RemediationCommandRunner = (command: string, args: string[], options: {
90
+ timeout: number;
91
+ env?: NodeJS.ProcessEnv;
92
+ }) => Promise<{
93
+ stdout: string;
94
+ stderr: string;
95
+ }>;
96
+ interface RemediateManagedNodeOptions {
97
+ /**
98
+ * Prove the currently installed `openclaw` still boots under the new Node
99
+ * (default true — the daemon needs the old runtime for the pre-upgrade
100
+ * backup). `alfe setup` on a fresh VM passes false: nothing is installed yet.
101
+ */
102
+ verifyInstalledRuntime?: boolean;
103
+ /**
104
+ * Where the failure cooldown marker lives (default `~/.alfe`). Test seam;
105
+ * `null` disables the marker entirely.
106
+ */
107
+ stateDirectory?: string | null;
108
+ /** Test seam for the cooldown clock. */
109
+ now?: () => number;
110
+ }
111
+ /**
112
+ * Thrown when remediation fails. `nodeReplaced` is the load-bearing field: a
113
+ * failure AFTER apt has swapped the host Node (verification or the installed
114
+ * runtime's boot check failed) leaves the host on a new, unproven Node, which
115
+ * is the state that needs an operator — very different from "never touched".
116
+ */
117
+
118
+ /**
119
+ * Install the managed Node.js channel on a Debian/Ubuntu managed host and
120
+ * return the resolved `node --version`.
121
+ *
122
+ * Runs BEFORE any runtime gate suspend or backup: the live OpenClaw child keeps
123
+ * its already-open binary, and later `openclaw`/`npm` spawns resolve the new
124
+ * binary through PATH. Fails closed (with `nodeReplaced` set accurately) if the
125
+ * resulting Node still misses the range or if the currently installed OpenClaw
126
+ * no longer boots under it — in that case the caller must not proceed to a
127
+ * backup it could not produce.
128
+ */
129
+ declare function remediateManagedNode(runCommand: RemediationCommandRunner, baseEnv: NodeJS.ProcessEnv, options?: RemediateManagedNodeOptions): Promise<string>;
64
130
  //#endregion
65
131
  //#region src/claude-code-host-version.d.ts
66
132
  /**
@@ -164,6 +230,14 @@ declare function captureIntegrationFailure(integration: string, phase: string, c
164
230
  * Sentry is not initialised. Never throws.
165
231
  */
166
232
  declare function captureCliFailure(context: string, cause?: unknown): void;
233
+ /**
234
+ * Fleet-visible record of a managed Node.js remediation attempt (the in-place
235
+ * Node 22 → 24 move `runtime-upgrade.ts` performs before installing an OpenClaw
236
+ * that dropped Node 22). One event per host per attempt so the campaign can
237
+ * count remediated vs failed agents; the daemon's local log alone cannot answer
238
+ * that. No-op when Sentry is not initialised. Never throws.
239
+ */
240
+
167
241
  /** A line of runtime child-process output attached to a crash event. */
168
242
  interface RuntimeOutputLine {
169
243
  stream: "stdout" | "stderr";
@@ -414,4 +488,4 @@ declare function checkExistingDaemon(): Promise<number | null>;
414
488
  */
415
489
  declare function stopExistingDaemon(): Promise<boolean>;
416
490
  //#endregion
417
- export { AGENT_DAEMON_SENTRY_DSN, AGENT_MCP_SENTRY_DSN, AGENT_RUNTIME_SENTRY_DSN, ALFE_DIR, type AgentIdentity, type AgentWorkspaceConfig, type DaemonConfig, type DaemonHealth, type IPCEvent, type IPCRequest, type IPCResponse, type InitAgentSentryOptions, OPENCLAW_NODE_REQUIREMENT, PID_PATH, PINNED_CLAUDE_CODE_HOST_VERSION, PINNED_OPENCLAW_VERSION, PROTOCOL_VERSION, type RuntimeOutputLine, SOCKET_PATH, type SentrySurface, captureCliFailure, captureFatal, captureIntegrationFailure, captureMcpFailure, captureRuntimeCrash, captureRuntimeErrorOutput, checkExistingDaemon, fetchAgentConfig, flushSentry, formatHealthReport, initAgentSentry, installService, isServiceInstalled, loadDaemonConfig, logger, queryDaemonHealth, resolveAgentIdentity, restartService, setAgentContext, startDaemon, startService, stopExistingDaemon, stopService, supportsOpenClawNode, uninstallService };
491
+ export { AGENT_DAEMON_SENTRY_DSN, AGENT_MCP_SENTRY_DSN, AGENT_RUNTIME_SENTRY_DSN, ALFE_DIR, type AgentIdentity, type AgentWorkspaceConfig, type DaemonConfig, type DaemonHealth, type IPCEvent, type IPCRequest, type IPCResponse, type InitAgentSentryOptions, OPENCLAW_MANAGED_NODE_MAJOR, OPENCLAW_NODE_REQUIREMENT, PID_PATH, PINNED_CLAUDE_CODE_HOST_VERSION, PINNED_OPENCLAW_VERSION, PROTOCOL_VERSION, type RemediateManagedNodeOptions, type RemediationCommandRunner, type RuntimeOutputLine, SOCKET_PATH, type SentrySurface, captureCliFailure, captureFatal, captureIntegrationFailure, captureMcpFailure, captureRuntimeCrash, captureRuntimeErrorOutput, checkExistingDaemon, fetchAgentConfig, flushSentry, formatHealthReport, initAgentSentry, installService, isServiceInstalled, loadDaemonConfig, logger, queryDaemonHealth, remediateManagedNode, resolveAgentIdentity, restartService, setAgentContext, startDaemon, startService, stopExistingDaemon, stopService, supportsOpenClawNode, uninstallService };
package/dist/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { d as supportsOpenClawNode, l as OPENCLAW_NODE_REQUIREMENT, u as PINNED_OPENCLAW_VERSION } from "../runtime-process.js";
1
+ import { d as PINNED_OPENCLAW_VERSION, l as OPENCLAW_MANAGED_NODE_MAJOR, p as supportsOpenClawNode, u as OPENCLAW_NODE_REQUIREMENT } from "../runtime-process.js";
2
2
  import { _ as loadDaemonConfig, a as installService, c as startService, d as uninstallService, f as PROTOCOL_VERSION, g as fetchAgentConfig, h as SOCKET_PATH, i as checkExistingDaemon, l as stopExistingDaemon, m as PID_PATH, n as queryDaemonHealth, o as isServiceInstalled, p as ALFE_DIR, r as startDaemon, s as restartService, t as formatHealthReport, u as stopService, v as resolveAgentIdentity, y as PINNED_CLAUDE_CODE_HOST_VERSION } from "../health.js";
3
- import { a as captureFatal, c as captureRuntimeCrash, d as initAgentSentry, f as setAgentContext, i as captureCliFailure, l as captureRuntimeErrorOutput, m as logger, n as AGENT_MCP_SENTRY_DSN, o as captureIntegrationFailure, r as AGENT_RUNTIME_SENTRY_DSN, s as captureMcpFailure, t as AGENT_DAEMON_SENTRY_DSN, u as flushSentry } from "../sentry.js";
4
- export { AGENT_DAEMON_SENTRY_DSN, AGENT_MCP_SENTRY_DSN, AGENT_RUNTIME_SENTRY_DSN, ALFE_DIR, OPENCLAW_NODE_REQUIREMENT, PID_PATH, PINNED_CLAUDE_CODE_HOST_VERSION, PINNED_OPENCLAW_VERSION, PROTOCOL_VERSION, SOCKET_PATH, captureCliFailure, captureFatal, captureIntegrationFailure, captureMcpFailure, captureRuntimeCrash, captureRuntimeErrorOutput, checkExistingDaemon, fetchAgentConfig, flushSentry, formatHealthReport, initAgentSentry, installService, isServiceInstalled, loadDaemonConfig, logger, queryDaemonHealth, resolveAgentIdentity, restartService, setAgentContext, startDaemon, startService, stopExistingDaemon, stopService, supportsOpenClawNode, uninstallService };
3
+ import { a as captureFatal, d as flushSentry, f as initAgentSentry, h as logger, i as captureCliFailure, l as captureRuntimeCrash, n as AGENT_MCP_SENTRY_DSN, o as captureIntegrationFailure, p as setAgentContext, r as AGENT_RUNTIME_SENTRY_DSN, s as captureMcpFailure, t as AGENT_DAEMON_SENTRY_DSN, u as captureRuntimeErrorOutput } from "../sentry.js";
4
+ import { n as remediateManagedNode } from "../node-remediation.js";
5
+ export { AGENT_DAEMON_SENTRY_DSN, AGENT_MCP_SENTRY_DSN, AGENT_RUNTIME_SENTRY_DSN, ALFE_DIR, OPENCLAW_MANAGED_NODE_MAJOR, OPENCLAW_NODE_REQUIREMENT, PID_PATH, PINNED_CLAUDE_CODE_HOST_VERSION, PINNED_OPENCLAW_VERSION, PROTOCOL_VERSION, SOCKET_PATH, captureCliFailure, captureFatal, captureIntegrationFailure, captureMcpFailure, captureRuntimeCrash, captureRuntimeErrorOutput, checkExistingDaemon, fetchAgentConfig, flushSentry, formatHealthReport, initAgentSentry, installService, isServiceInstalled, loadDaemonConfig, logger, queryDaemonHealth, remediateManagedNode, resolveAgentIdentity, restartService, setAgentContext, startDaemon, startService, stopExistingDaemon, stopService, supportsOpenClawNode, uninstallService };
package/dist/upgrade.js CHANGED
@@ -1,4 +1,4 @@
1
- import { i as captureCliFailure, m as logger } from "./sentry.js";
1
+ import { h as logger, i as captureCliFailure } from "./sentry.js";
2
2
  import { existsSync } from "node:fs";
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/gateway",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Alfe local gateway daemon — persistent control plane for agent integrations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "@alfe.ai/ai-proxy-local": "^0.0.17",
28
28
  "@alfe.ai/config": "^0.4.1",
29
29
  "@alfe.ai/integration-manifest": "^0.4.2",
30
- "@alfe.ai/integrations": "^0.6.6",
30
+ "@alfe.ai/integrations": "^0.6.8",
31
31
  "@alfe.ai/mcp-bundler": "^0.4.2"
32
32
  },
33
33
  "license": "UNLICENSED",