@alfe.ai/gateway 0.10.0 → 0.10.1

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, i as CaptureThrottle, m as __require, o as STRICT_ERROR_CAPTURE_PROFILE, p as __commonJSMin, 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
  }
@@ -8162,6 +8196,250 @@ async function startIpcServer(socketPath, requestHandler, daemonVersion) {
8162
8196
  //#endregion
8163
8197
  //#region src/mcp-handlers.ts
8164
8198
  /**
8199
+ * Aggregate text budget for one MCP tool call. An order of magnitude under
8200
+ * MAX_IPC_MESSAGE_BYTES because JSON escaping inflates text: quotes and
8201
+ * newlines cost 2 bytes, control characters 6. The serialized fence below
8202
+ * makes the guarantee absolute; this is the budget the model sees.
8203
+ */
8204
+ const MAX_MCP_RESULT_TEXT_BYTES = 256 * 1024;
8205
+ /**
8206
+ * Per-text-item cap. The consuming plugin (`@alfe.ai/openclaw-mcp-bundler`,
8207
+ * `plugin.ts` `MAX_JSON_STRING_LENGTH = 65_536`) rejects the WHOLE result as
8208
+ * "malformed" if any single string exceeds 65,536 UTF-16 code units, so a
8209
+ * kept item must never exceed that. UTF-8 bytes ≥ UTF-16 units, so capping
8210
+ * bytes at 64 KiB is safe for every already-deployed bundler.
8211
+ */
8212
+ const MAX_MCP_RESULT_ITEM_TEXT_BYTES = 64 * 1024;
8213
+ /** Worst-case JSON wrapper per content item (`{"type":"text","text":""},`) for the fast-path estimate. */
8214
+ const ITEM_ENVELOPE_BYTES = 64;
8215
+ function isTextItem(item) {
8216
+ return typeof item === "object" && item !== null && item.type === "text" && typeof item.text === "string";
8217
+ }
8218
+ function serializedBytes(value) {
8219
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
8220
+ }
8221
+ /**
8222
+ * Longest string value anywhere inside `value`, in UTF-8 bytes. The bundler
8223
+ * plugin's parser rejects the WHOLE result when any string at any depth is
8224
+ * over its cap (counted in UTF-16 units; UTF-8 bytes are never fewer), so
8225
+ * this is what a non-text item (image data, resource text) is measured by.
8226
+ */
8227
+ /**
8228
+ * The bundler plugin's `sanitizeJsonRecord` limits, mirrored so the daemon
8229
+ * never hands it an item it will reject (which nulls the WHOLE result).
8230
+ * Keep in sync with `packages/openclaw-mcp-bundler/src/plugin.ts`.
8231
+ */
8232
+ const PLUGIN_MAX_JSON_NODES = 1e4;
8233
+ const PLUGIN_MAX_JSON_DEPTH = 32;
8234
+ const PLUGIN_MAX_JSON_COLLECTION_ENTRIES = 2048;
8235
+ const PLUGIN_MAX_KEY_LENGTH = 512;
8236
+ /**
8237
+ * True when a content item stays inside the plugin parser's structural limits
8238
+ * (node budget per item, nesting depth, entries per collection, key length).
8239
+ * String length is bounded separately because `text` can be cut to fit; a
8240
+ * structural violation cannot, so the item is dropped.
8241
+ */
8242
+ function withinPluginStructure(item) {
8243
+ const budget = { nodes: 0 };
8244
+ const walk = (value, depth) => {
8245
+ budget.nodes += 1;
8246
+ if (budget.nodes > PLUGIN_MAX_JSON_NODES || depth > PLUGIN_MAX_JSON_DEPTH) return false;
8247
+ if (Array.isArray(value)) {
8248
+ if (value.length > PLUGIN_MAX_JSON_COLLECTION_ENTRIES) return false;
8249
+ for (const entry of value) if (!walk(entry, depth + 1)) return false;
8250
+ return true;
8251
+ }
8252
+ if (typeof value === "object" && value !== null) {
8253
+ const entries = Object.entries(value);
8254
+ if (entries.length > PLUGIN_MAX_JSON_COLLECTION_ENTRIES) return false;
8255
+ return entries.every(([key, entry]) => key.length <= PLUGIN_MAX_KEY_LENGTH && walk(entry, depth + 1));
8256
+ }
8257
+ return true;
8258
+ };
8259
+ return walk(item, 0);
8260
+ }
8261
+ function longestStringBytes(value) {
8262
+ if (typeof value === "string") return Buffer.byteLength(value, "utf8");
8263
+ if (Array.isArray(value)) {
8264
+ let max = 0;
8265
+ for (const entry of value) max = Math.max(max, longestStringBytes(entry));
8266
+ return max;
8267
+ }
8268
+ if (typeof value === "object" && value !== null) {
8269
+ let max = 0;
8270
+ for (const entry of Object.values(value)) max = Math.max(max, longestStringBytes(entry));
8271
+ return max;
8272
+ }
8273
+ return 0;
8274
+ }
8275
+ /** Cut at a UTF-8 boundary; drop a replacement char introduced by the cut. */
8276
+ function truncateUtf8(text, maxBytes) {
8277
+ const buf = Buffer.from(text, "utf8");
8278
+ if (buf.byteLength <= maxBytes) return text;
8279
+ const out = buf.subarray(0, maxBytes).toString("utf8");
8280
+ return out.endsWith("�") && !text.endsWith("�") ? out.slice(0, -1) : out;
8281
+ }
8282
+ /**
8283
+ * Model-visible notice. Deliberately names no bare tool: MCP hosts prefix tool
8284
+ * names, so a bare name is a dead end (packages/DEVELOPING.md, "Model-visible
8285
+ * errors carry the fix").
8286
+ */
8287
+ function truncationNotice(args) {
8288
+ const dropped = [];
8289
+ if (args.droppedItems > 0) dropped.push(`${String(args.droppedItems)} further content item(s)`);
8290
+ if (args.droppedStructured) dropped.push("the structured-content block");
8291
+ return [
8292
+ "[alfe] This tool result was truncated to fit the agent transport limit.",
8293
+ `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 ")}.` : ""),
8294
+ "Do NOT retry this call unchanged; the same request will truncate again.",
8295
+ "Narrow it instead: add a date range or status filter, lower the page size",
8296
+ "(top / $top / limit), and page with skip / $skip. If this data source offers a",
8297
+ "summary or report form of the same question, prefer that over listing raw records."
8298
+ ].join("\n");
8299
+ }
8300
+ /**
8301
+ * Bound a bundler tool result so the daemon never writes an IPC frame the
8302
+ * plugin will reject. In-budget results are returned by reference (no clone).
8303
+ * Over budget: items are kept front-to-back until the budget (text bytes plus
8304
+ * a per-item envelope allowance) is spent. Text items are cut at the per-item
8305
+ * cap on a UTF-8 boundary; a non-text item carrying any string over that cap
8306
+ * (image data, resource text) is dropped outright because the plugin would
8307
+ * reject the whole result on its account and it cannot be cut meaningfully.
8308
+ * `structuredContent` is dropped, `isError` is preserved verbatim, and a
8309
+ * model-visible notice is appended. If the serialized result is still over
8310
+ * the fence, only the notice is returned.
8311
+ */
8312
+ function boundMcpToolResult(result) {
8313
+ if (!Array.isArray(result.content)) throw new Error("MCP tool result content must be an array");
8314
+ const content = result.content;
8315
+ let textBytes = 0;
8316
+ let maxItemBytes = 0;
8317
+ let onlyBareText = true;
8318
+ let structurallyValid = true;
8319
+ for (const item of content) {
8320
+ if (isTextItem(item)) {
8321
+ textBytes += Buffer.byteLength(item.text, "utf8");
8322
+ if (onlyBareText && Object.keys(item).some((key) => key !== "type" && key !== "text")) onlyBareText = false;
8323
+ } else onlyBareText = false;
8324
+ maxItemBytes = Math.max(maxItemBytes, longestStringBytes(item));
8325
+ if (structurallyValid && !withinPluginStructure(item)) structurallyValid = false;
8326
+ }
8327
+ const structured = result.structuredContent;
8328
+ const hasExtraKeys = Object.keys(result).some((key) => key !== "content" && key !== "isError" && key !== "structuredContent");
8329
+ if (structurallyValid && textBytes <= 262144 && maxItemBytes <= 65536) {
8330
+ const worstCase = textBytes * 6 + content.length * ITEM_ENVELOPE_BYTES + 256;
8331
+ if (onlyBareText && structured === void 0 && !hasExtraKeys && worstCase <= 524288) return result;
8332
+ if (serializedBytes(result) <= 524288) return result;
8333
+ }
8334
+ const sourceBytes = serializedBytes(result);
8335
+ const { structuredContent: _dropped, ...withExtras } = result;
8336
+ const rest = hasExtraKeys && serializedBytes(withExtras) - serializedBytes(content) > 524288 / 2 ? {
8337
+ content,
8338
+ ...result.isError === void 0 ? {} : { isError: result.isError }
8339
+ } : withExtras;
8340
+ for (const textBudget of [
8341
+ MAX_MCP_RESULT_TEXT_BYTES,
8342
+ MAX_MCP_RESULT_TEXT_BYTES / 2,
8343
+ MAX_MCP_RESULT_TEXT_BYTES / 4,
8344
+ MAX_MCP_RESULT_TEXT_BYTES / 8
8345
+ ]) {
8346
+ const candidate = boundWithBudget(content, rest, textBudget, sourceBytes, structured !== void 0);
8347
+ if (serializedBytes(candidate) <= 524288) return candidate;
8348
+ }
8349
+ logger.warn({ sourceBytes }, "MCP tool result still over the serialized fence after text truncation; returning notice only");
8350
+ const notice = truncationNotice({
8351
+ sourceBytes,
8352
+ keptBytes: 0,
8353
+ truncatedItems: 0,
8354
+ droppedItems: content.length,
8355
+ droppedStructured: structured !== void 0
8356
+ });
8357
+ return {
8358
+ ...rest,
8359
+ content: [{
8360
+ type: "text",
8361
+ text: notice
8362
+ }]
8363
+ };
8364
+ }
8365
+ function boundWithBudget(content, rest, textBudget, sourceBytes, droppedStructured) {
8366
+ let remaining = textBudget;
8367
+ let kept = 0;
8368
+ let truncatedItems = 0;
8369
+ let droppedItems = 0;
8370
+ let exhausted = false;
8371
+ const bounded = [];
8372
+ for (const item of content) {
8373
+ if (exhausted) {
8374
+ droppedItems += 1;
8375
+ continue;
8376
+ }
8377
+ if (!withinPluginStructure(item)) {
8378
+ droppedItems += 1;
8379
+ continue;
8380
+ }
8381
+ if (!isTextItem(item)) {
8382
+ if (longestStringBytes(item) > 65536) {
8383
+ droppedItems += 1;
8384
+ continue;
8385
+ }
8386
+ const size = serializedBytes(item) + ITEM_ENVELOPE_BYTES;
8387
+ if (size > remaining) {
8388
+ droppedItems += 1;
8389
+ exhausted = true;
8390
+ continue;
8391
+ }
8392
+ remaining -= size;
8393
+ bounded.push(item);
8394
+ continue;
8395
+ }
8396
+ if (longestStringBytes({
8397
+ ...item,
8398
+ text: ""
8399
+ }) > 65536) {
8400
+ droppedItems += 1;
8401
+ continue;
8402
+ }
8403
+ const size = Buffer.byteLength(item.text, "utf8");
8404
+ const allow = Math.min(remaining - ITEM_ENVELOPE_BYTES, MAX_MCP_RESULT_ITEM_TEXT_BYTES);
8405
+ if (allow <= 0) {
8406
+ droppedItems += 1;
8407
+ exhausted = true;
8408
+ continue;
8409
+ }
8410
+ if (size <= allow) {
8411
+ remaining -= size + ITEM_ENVELOPE_BYTES;
8412
+ kept += size;
8413
+ bounded.push(item);
8414
+ continue;
8415
+ }
8416
+ const cut = truncateUtf8(item.text, allow);
8417
+ const cutBytes = Buffer.byteLength(cut, "utf8");
8418
+ kept += cutBytes;
8419
+ remaining -= cutBytes + ITEM_ENVELOPE_BYTES;
8420
+ truncatedItems += 1;
8421
+ bounded.push({
8422
+ ...item,
8423
+ text: cut
8424
+ });
8425
+ if (remaining <= 0) exhausted = true;
8426
+ }
8427
+ const notice = {
8428
+ type: "text",
8429
+ text: truncationNotice({
8430
+ sourceBytes,
8431
+ keptBytes: kept,
8432
+ truncatedItems,
8433
+ droppedItems,
8434
+ droppedStructured
8435
+ })
8436
+ };
8437
+ return {
8438
+ ...rest,
8439
+ content: [...bounded, notice]
8440
+ };
8441
+ }
8442
+ /**
8165
8443
  * Return the daemon-hosted MCP bundler's current namespaced tool catalog.
8166
8444
  * Called by the openclaw-mcp-bundler plugin on every tool-factory invocation;
8167
8445
  * cheap (in-memory snapshot, no I/O).
@@ -8429,7 +8707,7 @@ async function handleMcpCallTool(bundler, params) {
8429
8707
  try {
8430
8708
  return {
8431
8709
  ok: true,
8432
- payload: await bundler.callTool(name, args)
8710
+ payload: boundMcpToolResult(await bundler.callTool(name, args))
8433
8711
  };
8434
8712
  } catch (err) {
8435
8713
  const message = err instanceof Error ? err.message : String(err);
@@ -9695,7 +9973,7 @@ async function executeCloudCommand(command) {
9695
9973
  };
9696
9974
  const payload = command.payload;
9697
9975
  const runtime = config.runtime;
9698
- const version = payload?.version ?? (runtime === "openclaw" ? "2026.8.1" : runtime === "claude-code" ? "0.1.6" : void 0);
9976
+ const version = payload?.version ?? (runtime === "openclaw" ? "2026.9.5" : runtime === "claude-code" ? "0.1.6" : void 0);
9699
9977
  upgradingRuntime = true;
9700
9978
  setTimeout(() => {
9701
9979
  (async () => {
@@ -9703,6 +9981,7 @@ async function executeCloudCommand(command) {
9703
9981
  const { upgradeRuntime } = await import("./runtime-upgrade.js");
9704
9982
  const result = await upgradeRuntime(runtime, version, runtimeProcess, {
9705
9983
  ...runtime === "openclaw" && openclawCliLockRef ? { cliLock: openclawCliLockRef } : {},
9984
+ ...runtime === "openclaw" && isManagedMode() && process.env.ALFE_PRIVATE_DATA_PLANE !== "true" ? { nodeRemediation: {} } : {},
9706
9985
  ...runtime === "openclaw" && runtimeGateRef ? { gate: runtimeGateRef } : {}
9707
9986
  });
9708
9987
  if (result.success || result.runtimeChanged) {
@@ -9714,7 +9993,8 @@ async function executeCloudCommand(command) {
9714
9993
  else {
9715
9994
  logger.info({
9716
9995
  runtime,
9717
- runtimeVersion: resolvedRuntimeVersion
9996
+ runtimeVersion: resolvedRuntimeVersion,
9997
+ nodeUpgraded: result.nodeUpgraded ?? false
9718
9998
  }, "Runtime version updated after upgrade");
9719
9999
  cloudClient.updateRuntimeVersionAndReRegister(resolvedRuntimeVersion);
9720
10000
  }
@@ -9722,6 +10002,7 @@ async function executeCloudCommand(command) {
9722
10002
  if (!result.success) logger.error({
9723
10003
  runtime,
9724
10004
  version,
10005
+ nodeUpgraded: result.nodeUpgraded ?? false,
9725
10006
  error: result.error
9726
10007
  }, "Runtime upgrade did not complete cleanly");
9727
10008
  } catch (err) {
@@ -0,0 +1,163 @@
1
+ import { f 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,45 @@ 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).
61
71
  */
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";
72
+ const OPENCLAW_NODE_REQUIREMENT = ">=24.16.0 <25 || >=26.1.0";
65
73
  /**
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`).
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.
77
+ */
78
+ const OPENCLAW_MANAGED_NODE_MAJOR = 24;
79
+ /**
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;
79
92
  }
80
93
  //#endregion
81
94
  //#region ../../node_modules/.pnpm/dotenv@17.2.2/node_modules/dotenv/package.json
@@ -15389,11 +15402,25 @@ const DETECTOR_FLUSH_DEBOUNCE_MS = 1500;
15389
15402
  const BACKOFF_INITIAL_MS = 1e3;
15390
15403
  const BACKOFF_MAX_MS = 3e4;
15391
15404
  const STABLE_UPTIME_MS = 6e4;
15392
- /** Alfe owns supervision and version rollout; OpenClaw must not self-manage. */
15405
+ /**
15406
+ * Alfe owns supervision and version rollout; OpenClaw must not self-manage.
15407
+ *
15408
+ * `OPENCLAW_NODE_UPDATE_RESPAWNED=1` also disables 2026.9.x's node-runtime
15409
+ * recovery, which would otherwise re-exec the CLI under whatever other Node it
15410
+ * finds on PATH when the current one fails engine admission. Alfe chooses the
15411
+ * Node binary (managed remediation in `node-remediation.ts`); an admission
15412
+ * failure must surface as a clean exit the daemon can report, not a silent
15413
+ * respawn under an unmanaged interpreter.
15414
+ *
15415
+ * `OPENCLAW_CONFIG_READONLY` must never be set on the child or on upgrade
15416
+ * commands: since 2026.9.x it makes openclaw.json immutable and blocks setup,
15417
+ * doctor repairs and plugin installs.
15418
+ */
15393
15419
  const OPENCLAW_EXTERNAL_SUPERVISOR_ENV = {
15394
15420
  OPENCLAW_SUPERVISOR_MODE: "external",
15395
15421
  OPENCLAW_SERVICE_REPAIR_POLICY: "external",
15396
- OPENCLAW_NO_AUTO_UPDATE: "1"
15422
+ OPENCLAW_NO_AUTO_UPDATE: "1",
15423
+ OPENCLAW_NODE_UPDATE_RESPAWNED: "1"
15397
15424
  };
15398
15425
  /**
15399
15426
  * Max time a NON-crash restart waits for an in-flight chat turn to finish
@@ -15739,4 +15766,4 @@ var RuntimeProcess = class {
15739
15766
  }
15740
15767
  };
15741
15768
  //#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 };
15769
+ export { ErrorLineDetector as a, createLogger as c, PINNED_OPENCLAW_VERSION as d, supportsOpenClawNode as f, CaptureThrottle as i, OPENCLAW_MANAGED_NODE_MAJOR as l, __require as m, OPENCLAW_EXTERNAL_SUPERVISOR_ENV as n, STRICT_ERROR_CAPTURE_PROFILE as o, __commonJSMin 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 supportsOpenClawNode, n as OPENCLAW_EXTERNAL_SUPERVISOR_ENV, 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":
@@ -67,12 +82,13 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
67
82
  stderr: result.stderr
68
83
  };
69
84
  });
85
+ const openclawEnv = {
86
+ ...process.env,
87
+ ...OPENCLAW_EXTERNAL_SUPERVISOR_ENV
88
+ };
70
89
  const commandOptions = (timeout) => runtime === "openclaw" ? {
71
90
  timeout,
72
- env: {
73
- ...process.env,
74
- ...OPENCLAW_EXTERNAL_SUPERVISOR_ENV
75
- }
91
+ env: openclawEnv
76
92
  } : { timeout };
77
93
  const cmd = resolveUpgradeCommand(runtime, version);
78
94
  if (!cmd) {
@@ -89,24 +105,60 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
89
105
  };
90
106
  }
91
107
  let previousOpenClawVersion;
108
+ let nodeUpgraded = false;
92
109
  if (runtime === "openclaw") try {
93
110
  const [{ stdout: nodeOutput }, { stdout: openClawOutput }] = await Promise.all([runCommand("node", ["--version"], commandOptions(1e4)), runCommand("openclaw", ["--version"], commandOptions(1e4))]);
94
- const nodeVersion = nodeOutput.trim();
111
+ previousOpenClawVersion = parseOpenClawVersion(openClawOutput);
112
+ let nodeVersion = nodeOutput.trim();
95
113
  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({
114
+ if (!options.nodeRemediation) {
115
+ 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.`;
116
+ logger.error({
117
+ runtime,
118
+ version,
119
+ nodeVersion
120
+ }, message);
121
+ return {
122
+ success: false,
123
+ runtime,
124
+ version,
125
+ error: message
126
+ };
127
+ }
128
+ const fromNode = nodeVersion;
129
+ logger.warn({
98
130
  runtime,
99
131
  version,
132
+ nodeVersion,
133
+ requirement: OPENCLAW_NODE_REQUIREMENT
134
+ }, "Host Node.js does not satisfy OpenClaw; installing the managed Node channel first");
135
+ try {
136
+ nodeVersion = await remediateManagedNode(runCommand, openclawEnv, options.nodeRemediation);
137
+ } catch (err) {
138
+ const replaced = err instanceof NodeRemediationError && err.nodeReplaced;
139
+ const resolvedNode = err instanceof NodeRemediationError ? err.resolvedNode : void 0;
140
+ nodeUpgraded = replaced;
141
+ if (!(err instanceof NodeRemediationError && err.cooldownActive)) captureNodeRemediation({
142
+ outcome: "failure",
143
+ fromNode,
144
+ ...replaced && resolvedNode ? { toNode: resolvedNode } : {},
145
+ runtimeVersion: previousOpenClawVersion,
146
+ cause: err
147
+ });
148
+ throw err;
149
+ }
150
+ nodeUpgraded = true;
151
+ captureNodeRemediation({
152
+ outcome: "success",
153
+ fromNode,
154
+ toNode: nodeVersion,
155
+ runtimeVersion: previousOpenClawVersion
156
+ });
157
+ logger.info({
158
+ fromNode,
100
159
  nodeVersion
101
- }, message);
102
- return {
103
- success: false,
104
- runtime,
105
- version,
106
- error: message
107
- };
160
+ }, "Managed Node.js remediation complete");
108
161
  }
109
- previousOpenClawVersion = parseOpenClawVersion(openClawOutput);
110
162
  } catch (err) {
111
163
  const message = `OpenClaw upgrade preflight failed: ${err instanceof Error ? err.message : String(err)}`;
112
164
  logger.error({
@@ -117,6 +169,7 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
117
169
  success: false,
118
170
  runtime,
119
171
  version,
172
+ nodeUpgraded,
120
173
  error: message
121
174
  };
122
175
  }
@@ -155,6 +208,7 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
155
208
  const installedVersion = parseOpenClawVersion((await runCommand("openclaw", ["--version"], commandOptions(1e4))).stdout);
156
209
  if (installedVersion !== version) throw new Error(`OpenClaw package verification failed: requested ${version}, resolved ${installedVersion ?? "unknown"}`);
157
210
  runtimeChanged = installedVersion !== previousOpenClawVersion;
211
+ await runCommand("openclaw", [...OPENCLAW_ADMISSION_PROBE_ARGS], commandOptions(6e4));
158
212
  doctorStarted = true;
159
213
  await runCommand("openclaw", [
160
214
  "doctor",
@@ -195,13 +249,15 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
195
249
  else await performUpgrade();
196
250
  logger.info({
197
251
  runtime,
198
- version
252
+ version,
253
+ nodeUpgraded
199
254
  }, "Runtime upgraded successfully");
200
255
  return {
201
256
  success: true,
202
257
  runtime,
203
258
  version,
204
- runtimeChanged
259
+ runtimeChanged,
260
+ nodeUpgraded
205
261
  };
206
262
  } catch (err) {
207
263
  const message = err instanceof Error ? err.message : String(err);
@@ -215,6 +271,7 @@ async function upgradeRuntime(runtime, version, runtimeProcess, options = {}) {
215
271
  runtime,
216
272
  version,
217
273
  runtimeChanged,
274
+ nodeUpgraded,
218
275
  error: message
219
276
  };
220
277
  } 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,24 +44,83 @@ 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).
65
+ */
66
+ declare const OPENCLAW_NODE_REQUIREMENT = ">=24.16.0 <25 || >=26.1.0";
67
+ /**
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.
55
71
  */
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";
72
+ declare const OPENCLAW_MANAGED_NODE_MAJOR = 24;
59
73
  /**
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`).
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;
64
79
  //#endregion
80
+ //#region src/node-remediation.d.ts
81
+
82
+ type RemediationCommandRunner = (command: string, args: string[], options: {
83
+ timeout: number;
84
+ env?: NodeJS.ProcessEnv;
85
+ }) => Promise<{
86
+ stdout: string;
87
+ stderr: string;
88
+ }>;
89
+ interface RemediateManagedNodeOptions {
90
+ /**
91
+ * Prove the currently installed `openclaw` still boots under the new Node
92
+ * (default true — the daemon needs the old runtime for the pre-upgrade
93
+ * backup). `alfe setup` on a fresh VM passes false: nothing is installed yet.
94
+ */
95
+ verifyInstalledRuntime?: boolean;
96
+ /**
97
+ * Where the failure cooldown marker lives (default `~/.alfe`). Test seam;
98
+ * `null` disables the marker entirely.
99
+ */
100
+ stateDirectory?: string | null;
101
+ /** Test seam for the cooldown clock. */
102
+ now?: () => number;
103
+ }
104
+ /**
105
+ * Thrown when remediation fails. `nodeReplaced` is the load-bearing field: a
106
+ * failure AFTER apt has swapped the host Node (verification or the installed
107
+ * runtime's boot check failed) leaves the host on a new, unproven Node, which
108
+ * is the state that needs an operator — very different from "never touched".
109
+ */
110
+
111
+ /**
112
+ * Install the managed Node.js channel on a Debian/Ubuntu managed host and
113
+ * return the resolved `node --version`.
114
+ *
115
+ * Runs BEFORE any runtime gate suspend or backup: the live OpenClaw child keeps
116
+ * its already-open binary, and later `openclaw`/`npm` spawns resolve the new
117
+ * binary through PATH. Fails closed (with `nodeReplaced` set accurately) if the
118
+ * resulting Node still misses the range or if the currently installed OpenClaw
119
+ * no longer boots under it — in that case the caller must not proceed to a
120
+ * backup it could not produce.
121
+ */
122
+ declare function remediateManagedNode(runCommand: RemediationCommandRunner, baseEnv: NodeJS.ProcessEnv, options?: RemediateManagedNodeOptions): Promise<string>;
123
+ //#endregion
65
124
  //#region src/claude-code-host-version.d.ts
66
125
  /**
67
126
  * The `@alfe.ai/claude-code-host` version Alfe pins the claude-code runtime to —
@@ -164,6 +223,14 @@ declare function captureIntegrationFailure(integration: string, phase: string, c
164
223
  * Sentry is not initialised. Never throws.
165
224
  */
166
225
  declare function captureCliFailure(context: string, cause?: unknown): void;
226
+ /**
227
+ * Fleet-visible record of a managed Node.js remediation attempt (the in-place
228
+ * Node 22 → 24 move `runtime-upgrade.ts` performs before installing an OpenClaw
229
+ * that dropped Node 22). One event per host per attempt so the campaign can
230
+ * count remediated vs failed agents; the daemon's local log alone cannot answer
231
+ * that. No-op when Sentry is not initialised. Never throws.
232
+ */
233
+
167
234
  /** A line of runtime child-process output attached to a crash event. */
168
235
  interface RuntimeOutputLine {
169
236
  stream: "stdout" | "stderr";
@@ -414,4 +481,4 @@ declare function checkExistingDaemon(): Promise<number | null>;
414
481
  */
415
482
  declare function stopExistingDaemon(): Promise<boolean>;
416
483
  //#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 };
484
+ 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, f as supportsOpenClawNode, l as OPENCLAW_MANAGED_NODE_MAJOR, 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.1",
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.7",
31
31
  "@alfe.ai/mcp-bundler": "^0.4.2"
32
32
  },
33
33
  "license": "UNLICENSED",