@alfe.ai/gateway 0.7.4 → 0.8.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.
package/dist/sentry.js ADDED
@@ -0,0 +1,386 @@
1
+ import { readConfig, resolveConfig } from "@alfe.ai/config";
2
+ //#region src/sentry.ts
3
+ /**
4
+ * Agent-side error reporting (Sentry) for the Alfe CLI + gateway daemon.
5
+ *
6
+ * This module is the single Sentry bootstrap shared by both published
7
+ * agent-side packages — `@alfe.ai/gateway` (the daemon) and `@alfe.ai/cli`
8
+ * (which re-imports these helpers from `@alfe.ai/gateway`). It exists so
9
+ * integration-installation failures on customer/managed VMs surface centrally
10
+ * instead of silently rotting until someone SSHes into the box.
11
+ *
12
+ * Design constraints (see packages/gateway/DEVELOPING.md → "Error reporting"):
13
+ * - Errors only. No tracing/profiling (`tracesSampleRate: 0`, `sampleRate: 1`).
14
+ * - `@sentry/node` is lazy-imported so `alfe --version` doesn't pay for it.
15
+ * - Sentry can NEVER break the CLI/daemon — every entry point is try/catch'd.
16
+ * - The DSNs are baked as constants: published tarballs run on agent VMs and
17
+ * cannot read the repo's `config/config.ts`. That file's `SENTRY_DSNS`
18
+ * registry (keys `agentDaemon` + `cli`) mirrors them as the human reference.
19
+ * - Secrets are scrubbed in `beforeSend`; request/env payloads are dropped.
20
+ * - Opt-out via `ALFE_ERROR_REPORTING=0|false` or `errorReporting = false`
21
+ * in `~/.alfe/config.toml`. Default ON.
22
+ */
23
+ /**
24
+ * Baked per-surface Sentry DSNs (org `alfe-ai`). DSNs are public by design
25
+ * (they only permit event ingestion). Mirrors of `config/config.ts` →
26
+ * `SENTRY_DSNS.agentDaemon` / `SENTRY_DSNS.cli`. If you rotate a DSN, update
27
+ * both places.
28
+ */
29
+ const AGENT_DAEMON_SENTRY_DSN = "https://a47f010d8d39fb4350f913492189f283@o4511008239452160.ingest.us.sentry.io/4511679448547328";
30
+ const CLI_SENTRY_DSN = "https://82dde4631336561f2fcc89d7531623de@o4511008239452160.ingest.us.sentry.io/4511679411191808";
31
+ /**
32
+ * Dedicated project for errors emitted BY the agent runtime child process
33
+ * (OpenClaw/Hermes) — crashes, spawn failures, and error-looking output. Kept
34
+ * separate from `agent-daemon` so runtime noise never drowns daemon issues.
35
+ * Mirrors `SENTRY_DSNS.agentRuntime` in `config/config.ts`.
36
+ */
37
+ const AGENT_RUNTIME_SENTRY_DSN = "https://feb7fb2e3b8e723aec518e74715bfa6d@o4511008239452160.ingest.us.sentry.io/4511683667558400";
38
+ /**
39
+ * MCP tool/server failures on agent VMs — reported by the daemon-hosted MCP
40
+ * bundler (tool errors, child crashes, stderr output). Dedicated `local-mcp`
41
+ * project for the locally-installed MCP surface: the cloud MCP Fly service
42
+ * keeps the `mcp` project (`SENTRY_DSNS.mcp`), so planned daemon restarts
43
+ * tearing down MCP children never pollute the remote service's signal.
44
+ * The `source: agent-daemon` + `agentId` tags are kept for continuity.
45
+ */
46
+ const AGENT_MCP_SENTRY_DSN = "https://b4c4b4e19135692206bacabcd12a7fd3@o4511008239452160.ingest.us.sentry.io/4511697094377472";
47
+ /** Surface → Sentry project: `cli` and `agent-daemon` respectively. */
48
+ const SURFACE_DSNS = {
49
+ cli: CLI_SENTRY_DSN,
50
+ daemon: AGENT_DAEMON_SENTRY_DSN
51
+ };
52
+ /** Held after a successful init so the capture helpers can run synchronously. */
53
+ let sentry = null;
54
+ /**
55
+ * Additional bound clients (daemon surface only). The default client keeps the
56
+ * daemon's own DSN; runtime child-process events and MCP failures are routed
57
+ * to their own projects via explicitly-bound Scopes — the documented
58
+ * multi-client pattern. `null` until `initAgentSentry({ surface: "daemon" })`
59
+ * succeeds.
60
+ */
61
+ let runtimeClient = null;
62
+ let runtimeScope = null;
63
+ let mcpClient = null;
64
+ let mcpScope = null;
65
+ /**
66
+ * Map the configured API URL to a coarse environment tag. Best-effort — returns
67
+ * `"unknown"` when no config is present yet (e.g. `alfe login` pre-setup).
68
+ */
69
+ function deriveEnvironment() {
70
+ try {
71
+ const { apiUrl } = resolveConfig();
72
+ if (apiUrl.includes("api.dev.alfe.ai") || apiUrl.includes("dev.alfe.ai")) return "dev";
73
+ if (apiUrl.includes("api.test.alfe.ai")) return "test";
74
+ if (apiUrl.includes("api.demo.alfe.ai")) return "demo";
75
+ if (apiUrl.includes("api.alfe.ai")) return "prod";
76
+ } catch {}
77
+ return "unknown";
78
+ }
79
+ /** Active runtime (`openclaw`/`hermes`) from config, or undefined if unknown. */
80
+ function deriveRuntime() {
81
+ try {
82
+ return resolveConfig().runtime;
83
+ } catch {
84
+ return;
85
+ }
86
+ }
87
+ /** True when the operator has opted out of error reporting. */
88
+ function errorReportingDisabled() {
89
+ const env = process.env.ALFE_ERROR_REPORTING?.trim().toLowerCase();
90
+ if (env === "0" || env === "false") return true;
91
+ try {
92
+ if (readConfig().errorReporting === false) return true;
93
+ } catch {}
94
+ return false;
95
+ }
96
+ const KV_SECRET_RE = /(?<![A-Za-z0-9])([A-Za-z0-9_-]*(?:authorization|api[_-]?key|apikey|token|secret|password|passwd|bearer|credential)s?)(\s*[:=]\s*|\s+)("?)([^\s"']+)\3/gi;
97
+ const URL_QUERY_SECRET_RE = /([?&](?:[A-Za-z0-9_-]*(?:token|secret|key|signature|credential|password)|sig|code|x-amz-[a-z-]+)=)[^&\s"']+/gi;
98
+ const MIN_BARE_SECRET_LEN = 16;
99
+ const ALFE_TOKEN_RE = /alfe_(?:dev|test|demo|live)_[A-Za-z0-9._-]+/g;
100
+ const BEARER_RE = /\bbearer\s+[A-Za-z0-9._\-+/=]+/gi;
101
+ function scrubString(value) {
102
+ return value.replace(BEARER_RE, "Bearer [REDACTED]").replace(URL_QUERY_SECRET_RE, "$1[REDACTED]").replace(KV_SECRET_RE, (match, label, sep, _q, secret) => {
103
+ if (!/[:=]/.test(sep) && secret.length < MIN_BARE_SECRET_LEN) return match;
104
+ return `${label}${sep}[REDACTED]`;
105
+ }).replace(ALFE_TOKEN_RE, "alfe_[REDACTED]");
106
+ }
107
+ /** Scrub secrets from a breadcrumb's message + string data values in place. */
108
+ function scrubBreadcrumb(b) {
109
+ if (typeof b.message === "string") b.message = scrubString(b.message);
110
+ const data = b.data;
111
+ if (data) for (const k of Object.keys(data)) {
112
+ const dv = data[k];
113
+ if (typeof dv === "string") data[k] = scrubString(dv);
114
+ }
115
+ return b;
116
+ }
117
+ /**
118
+ * Scrub secrets and drop request/env payloads before an event is sent.
119
+ * Generic so it preserves the caller's exact event subtype (`beforeSend`
120
+ * receives — and must return — an `ErrorEvent`, not a widened `Event`).
121
+ */
122
+ function scrubEvent(event) {
123
+ delete event.request;
124
+ if (event.contexts) delete event.contexts.runtime;
125
+ delete event.extra;
126
+ delete event.server_name;
127
+ if (typeof event.message === "string") event.message = scrubString(event.message);
128
+ const values = event.exception?.values;
129
+ if (values) {
130
+ for (const v of values) if (typeof v.value === "string") v.value = scrubString(v.value);
131
+ }
132
+ if (event.breadcrumbs) for (const b of event.breadcrumbs) scrubBreadcrumb(b);
133
+ return event;
134
+ }
135
+ /**
136
+ * Initialise Sentry for the agent-side CLI/daemon. Idempotent, best-effort, and
137
+ * a no-op when opted out or when the baked DSN is empty. Never throws.
138
+ */
139
+ async function initAgentSentry(options) {
140
+ try {
141
+ if (sentry) return;
142
+ if (errorReportingDisabled()) return;
143
+ const dsn = SURFACE_DSNS[options.surface].trim();
144
+ if (!dsn) return;
145
+ const mod = await import("@sentry/node");
146
+ if (options.surface === "daemon") {
147
+ const buildBoundScope = (dsn, extraTags) => {
148
+ try {
149
+ if (!dsn.trim()) return null;
150
+ const client = new mod.NodeClient({
151
+ dsn: dsn.trim(),
152
+ environment: deriveEnvironment(),
153
+ ...options.release ? { release: options.release } : {},
154
+ sampleRate: 1,
155
+ tracesSampleRate: 0,
156
+ sendDefaultPii: false,
157
+ transport: mod.makeNodeTransport,
158
+ stackParser: mod.defaultStackParser,
159
+ integrations: [],
160
+ beforeSend: (event) => scrubEvent(event)
161
+ });
162
+ client.init();
163
+ const scope = new mod.Scope();
164
+ scope.setClient(client);
165
+ const runtime = deriveRuntime();
166
+ if (runtime) scope.setTag("runtime", runtime);
167
+ for (const [k, v] of Object.entries(extraTags)) scope.setTag(k, v);
168
+ return {
169
+ client,
170
+ scope
171
+ };
172
+ } catch {
173
+ return null;
174
+ }
175
+ };
176
+ if (!runtimeScope) {
177
+ const bound = buildBoundScope(AGENT_RUNTIME_SENTRY_DSN, {});
178
+ runtimeClient = bound?.client ?? null;
179
+ runtimeScope = bound?.scope ?? null;
180
+ }
181
+ if (!mcpScope) {
182
+ const bound = buildBoundScope(AGENT_MCP_SENTRY_DSN, { source: "agent-daemon" });
183
+ mcpClient = bound?.client ?? null;
184
+ mcpScope = bound?.scope ?? null;
185
+ }
186
+ }
187
+ if (mod.getClient()) {
188
+ sentry = mod;
189
+ return;
190
+ }
191
+ mod.init({
192
+ dsn,
193
+ environment: deriveEnvironment(),
194
+ ...options.release ? { release: options.release } : {},
195
+ sampleRate: 1,
196
+ tracesSampleRate: 0,
197
+ sendDefaultPii: false,
198
+ ...options.surface === "daemon" ? { integrations: (defaults) => defaults.filter((i) => i.name !== "OnUncaughtException" && i.name !== "OnUnhandledRejection") } : {},
199
+ beforeSend: (event) => scrubEvent(event),
200
+ beforeBreadcrumb: (breadcrumb) => scrubBreadcrumb(breadcrumb)
201
+ });
202
+ sentry = mod;
203
+ mod.setTag("surface", options.surface);
204
+ const runtime = deriveRuntime();
205
+ if (runtime) mod.setTag("runtime", runtime);
206
+ } catch {
207
+ sentry = null;
208
+ }
209
+ }
210
+ /**
211
+ * Attach the resolved agent identity to all subsequent events. Called by the
212
+ * daemon once `loadDaemonConfig()` has resolved `agentId`/`runtime`.
213
+ */
214
+ function setAgentContext(context) {
215
+ if (!sentry) return;
216
+ try {
217
+ for (const scope of [
218
+ sentry,
219
+ runtimeScope,
220
+ mcpScope
221
+ ]) {
222
+ if (!scope) continue;
223
+ if (context.agentId) scope.setTag("agentId", context.agentId);
224
+ if (context.runtime) scope.setTag("runtime", context.runtime);
225
+ }
226
+ } catch {}
227
+ }
228
+ /**
229
+ * Capture an integration lifecycle failure (install/activate/reinstall/remove)
230
+ * with `{ integration, phase }` tags. Accepts an `Error` (captured with stack)
231
+ * or a plain message string (captured as an error-level message). No-op when
232
+ * Sentry is not initialised.
233
+ */
234
+ function captureIntegrationFailure(integration, phase, cause) {
235
+ if (!sentry) return;
236
+ try {
237
+ if (cause instanceof Error) sentry.captureException(cause, { tags: {
238
+ integration,
239
+ phase
240
+ } });
241
+ else sentry.captureMessage(String(cause), {
242
+ level: "error",
243
+ tags: {
244
+ integration,
245
+ phase
246
+ }
247
+ });
248
+ } catch {}
249
+ }
250
+ /**
251
+ * Capture a generic CLI failure with a `{ context }` tag. The CLI counterpart
252
+ * to `captureIntegrationFailure` — used by `exitWithError` at the handled-error
253
+ * `process.exit()` sites in `@alfe.ai/cli`'s commands, which otherwise report
254
+ * nothing (the error was caught, never thrown). Accepts an `Error` (captured
255
+ * with stack), a defined non-Error value (captured as an error-level message),
256
+ * or nothing (the `context` string itself becomes the message). No-op when
257
+ * Sentry is not initialised. Never throws.
258
+ */
259
+ function captureCliFailure(context, cause) {
260
+ if (!sentry) return;
261
+ try {
262
+ if (cause instanceof Error) sentry.captureException(cause, { tags: { context } });
263
+ else if (cause === void 0) sentry.captureMessage(context, { level: "error" });
264
+ else sentry.captureMessage(String(cause), {
265
+ level: "error",
266
+ tags: { context }
267
+ });
268
+ } catch {}
269
+ }
270
+ /**
271
+ * Capture a runtime child-process crash (non-graceful exit) or spawn failure
272
+ * into the dedicated `agent-runtime` project. Recent output travels as
273
+ * breadcrumbs — NEVER `extra`, which `scrubEvent` (the runtime client's
274
+ * `beforeSend`) deletes. Throttling is the caller's job (`CaptureThrottle` in
275
+ * `runtime-output-monitor.ts`). No-op when the runtime client is not
276
+ * initialised. Never throws.
277
+ */
278
+ function captureRuntimeCrash(opts) {
279
+ if (!runtimeScope) return;
280
+ try {
281
+ const scope = runtimeScope.clone();
282
+ for (const { stream, line } of opts.recentOutput) scope.addBreadcrumb({
283
+ category: `runtime.${stream}`,
284
+ level: stream === "stderr" ? "warning" : "info",
285
+ message: line
286
+ });
287
+ const crashKey = opts.spawnError ? `spawn:${opts.spawnError.code ?? "error"}` : String(opts.code ?? opts.signal ?? "unknown");
288
+ scope.setFingerprint([
289
+ "runtime-crash",
290
+ opts.runtime,
291
+ crashKey
292
+ ]);
293
+ scope.setTags({
294
+ runtime: opts.runtime,
295
+ exitCode: String(opts.code),
296
+ signal: String(opts.signal),
297
+ crashesSuppressed: String(opts.crashesSuppressed ?? 0)
298
+ });
299
+ if (opts.spawnError) scope.captureException(opts.spawnError);
300
+ else scope.captureMessage(`Runtime ${opts.runtime} crashed (code=${String(opts.code)}, signal=${String(opts.signal)})`, "error");
301
+ } catch {}
302
+ }
303
+ /**
304
+ * Capture an error-looking block of runtime output (stack trace, Python
305
+ * traceback, or ERROR-level log line) detected while the child is running.
306
+ * The block lines travel as breadcrumbs; the head line is the message.
307
+ * Throttling/dedupe is the caller's job. No-op when Sentry is not initialised.
308
+ * Never throws.
309
+ */
310
+ function captureRuntimeErrorOutput(opts) {
311
+ if (!runtimeScope) return;
312
+ try {
313
+ const scope = runtimeScope.clone();
314
+ for (const line of opts.lines) scope.addBreadcrumb({
315
+ category: `runtime.${opts.stream}`,
316
+ level: "error",
317
+ message: line
318
+ });
319
+ scope.setFingerprint([
320
+ "runtime-output",
321
+ opts.runtime,
322
+ opts.kind,
323
+ opts.fingerprintKey
324
+ ]);
325
+ scope.setTags({
326
+ runtime: opts.runtime,
327
+ stream: opts.stream,
328
+ kind: opts.kind,
329
+ suppressedCount: String(opts.suppressedCount ?? 0)
330
+ });
331
+ scope.captureMessage(opts.lines[0].slice(0, 300), "error");
332
+ } catch {}
333
+ }
334
+ /**
335
+ * Capture an MCP failure (tool error, server crash, or error-looking stderr
336
+ * output) into the `mcp` project via the daemon's bound MCP client. Throttling
337
+ * is the caller's job (`mcp-error-capture.ts`). No-op when the MCP client is
338
+ * not initialised. Never throws.
339
+ */
340
+ function captureMcpFailure(opts) {
341
+ if (!mcpScope) return;
342
+ try {
343
+ const scope = mcpScope.clone();
344
+ for (const line of opts.lines ?? []) scope.addBreadcrumb({
345
+ category: `mcp.${opts.server}`,
346
+ level: "error",
347
+ message: line
348
+ });
349
+ scope.setFingerprint([
350
+ "agent-mcp",
351
+ opts.server,
352
+ opts.kind,
353
+ ...opts.tool ? [opts.tool] : []
354
+ ]);
355
+ scope.setTags({
356
+ server: opts.server,
357
+ kind: opts.kind,
358
+ ...opts.tool ? { tool: opts.tool } : {},
359
+ suppressedCount: String(opts.suppressedCount ?? 0)
360
+ });
361
+ scope.captureMessage(`MCP ${opts.server}${opts.tool ? `.${opts.tool}` : ""} ${opts.kind}: ${opts.message.slice(0, 300)}`, "error");
362
+ } catch {}
363
+ }
364
+ /** Flush queued events (small timeout) so short-lived commands deliver them. */
365
+ async function flushSentry(timeoutMs = 2e3) {
366
+ for (const client of [runtimeClient, mcpClient]) try {
367
+ if (client) await client.flush(timeoutMs);
368
+ } catch {}
369
+ if (!sentry) return;
370
+ try {
371
+ await sentry.flush(timeoutMs);
372
+ } catch {}
373
+ }
374
+ /**
375
+ * Capture a fatal error — for the CLI's top-level catch. Does NOT flush: the
376
+ * caller's `finally { await flushSentry() }` delivers it (flushing here too
377
+ * doubled the worst-case exit delay on a failing command).
378
+ */
379
+ function captureFatal(cause) {
380
+ if (!sentry) return;
381
+ try {
382
+ sentry.captureException(cause);
383
+ } catch {}
384
+ }
385
+ //#endregion
386
+ export { captureFatal as a, captureRuntimeCrash as c, initAgentSentry as d, setAgentContext as f, captureCliFailure as i, captureRuntimeErrorOutput as l, AGENT_MCP_SENTRY_DSN as n, captureIntegrationFailure as o, AGENT_RUNTIME_SENTRY_DSN as r, captureMcpFailure as s, AGENT_DAEMON_SENTRY_DSN as t, flushSentry as u };
@@ -339,12 +339,6 @@ declare function formatHealthReport(health: DaemonHealth): string;
339
339
  declare const logger: pino.Logger<never, boolean>;
340
340
  //#endregion
341
341
  //#region src/process-manager.d.ts
342
- /**
343
- * Process management — launchd/systemd service installation.
344
- *
345
- * Generates and installs user-space service units for auto-start on boot.
346
- * No root required — uses user agents (Mac) or user units (Linux).
347
- */
348
342
  /**
349
343
  * Install the service unit for the current platform.
350
344
  */
package/dist/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
- import { C as PID_PATH, D as resolveAgentIdentity, E as loadDaemonConfig, O as PINNED_OPENCLAW_VERSION, S as ALFE_DIR, T as fetchAgentConfig, _ as captureRuntimeErrorOutput, a as installService, b as setAgentContext, c as uninstallService, d as AGENT_RUNTIME_SENTRY_DSN, f as captureCliFailure, g as captureRuntimeCrash, h as captureMcpFailure, i as checkExistingDaemon, l as AGENT_DAEMON_SENTRY_DSN, m as captureIntegrationFailure, n as queryDaemonHealth, o as startService, p as captureFatal, r as startDaemon, s as stopExistingDaemon, t as formatHealthReport, u as AGENT_MCP_SENTRY_DSN, v as flushSentry, w as SOCKET_PATH, x as PROTOCOL_VERSION, y as initAgentSentry } from "../health.js";
1
+ import { a as installService, c as uninstallService, d as PID_PATH, f as SOCKET_PATH, g as PINNED_OPENCLAW_VERSION, h as resolveAgentIdentity, i as checkExistingDaemon, l as PROTOCOL_VERSION, m as loadDaemonConfig, n as queryDaemonHealth, o as startService, p as fetchAgentConfig, r as startDaemon, s as stopExistingDaemon, t as formatHealthReport, u as ALFE_DIR } from "../health.js";
2
2
  import { n as logger } from "../logger.js";
3
+ import { a as captureFatal, c as captureRuntimeCrash, d as initAgentSentry, f as setAgentContext, i as captureCliFailure, l as captureRuntimeErrorOutput, 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";
3
4
  export { AGENT_DAEMON_SENTRY_DSN, AGENT_MCP_SENTRY_DSN, AGENT_RUNTIME_SENTRY_DSN, ALFE_DIR, PID_PATH, PINNED_OPENCLAW_VERSION, PROTOCOL_VERSION, SOCKET_PATH, captureCliFailure, captureFatal, captureIntegrationFailure, captureMcpFailure, captureRuntimeCrash, captureRuntimeErrorOutput, checkExistingDaemon, fetchAgentConfig, flushSentry, formatHealthReport, initAgentSentry, installService, loadDaemonConfig, logger, queryDaemonHealth, resolveAgentIdentity, setAgentContext, startDaemon, startService, stopExistingDaemon, uninstallService };
package/dist/upgrade.js CHANGED
@@ -1,6 +1,132 @@
1
1
  import { n as logger } from "./logger.js";
2
+ import { i as captureCliFailure } from "./sentry.js";
2
3
  import { execFile } from "node:child_process";
3
4
  import { promisify } from "node:util";
5
+ import { existsSync } from "node:fs";
6
+ //#region src/verify-cli-install.ts
7
+ /**
8
+ * Verify a global `@alfe.ai/cli` install is intact.
9
+ *
10
+ * Used by two crash-safety defenses (see `packages/cli/DEVELOPING.md` →
11
+ * "Crash-safe CLI self-update"):
12
+ *
13
+ * 1. Verify-before-exit in the daemon self-update path (`upgrade.ts`): after
14
+ * `npm install -g @alfe.ai/cli@<v>` returns, confirm the tree is intact
15
+ * BEFORE `process.exit(0)`. A bad tree (npm returned but produced no
16
+ * `dist/`, empty `package.json`) must NOT trigger the exit — the old,
17
+ * still-working daemon stays up instead of exiting into a 203/EXEC brick.
18
+ *
19
+ * 2. The boot-time self-heal guard (`alfe-cli-guard.sh`, installed by the CLI
20
+ * setup flow as an `ExecStartPre=` on the systemd unit) reimplements the
21
+ * SAME two checks in POSIX sh, because it must run OUTSIDE the (possibly
22
+ * broken) `alfe` binary. Keep the two in lockstep.
23
+ *
24
+ * The two checks:
25
+ * - the resolved CLI entry (`.../dist/index.js`, the symlink target of the
26
+ * `alfe` bin) exists on disk; and
27
+ * - running it (`node <entry> --version`, or `alfe --version`) exits 0 and
28
+ * prints the expected version.
29
+ *
30
+ * This module holds only pure decision logic + a thin exec wrapper so the
31
+ * decision can be unit-tested without a real install on disk.
32
+ */
33
+ const execFileAsync$1 = promisify(execFile);
34
+ /**
35
+ * Pure verification decision. Given a probe of the install on disk, decide
36
+ * whether it is intact. When `expectedVersion` is provided, the reported
37
+ * version must match exactly; otherwise any exit-0 version string passes.
38
+ *
39
+ * Kept side-effect-free so the healthy/broken decision is unit-testable.
40
+ */
41
+ function decideCliInstallOk(probe, expectedVersion) {
42
+ if (probe.entryPath !== void 0 && !probe.entryExists) return {
43
+ ok: false,
44
+ reason: `CLI entry missing at ${probe.entryPath}`,
45
+ probe
46
+ };
47
+ if (probe.reportedVersion === null) return {
48
+ ok: false,
49
+ reason: "CLI --version did not run cleanly",
50
+ probe
51
+ };
52
+ if (expectedVersion && probe.reportedVersion !== expectedVersion) return {
53
+ ok: false,
54
+ reason: `CLI reports ${probe.reportedVersion}, expected ${expectedVersion}`,
55
+ probe
56
+ };
57
+ return {
58
+ ok: true,
59
+ probe
60
+ };
61
+ }
62
+ /**
63
+ * Resolve the CLI entry file this daemon is running from. When the gateway is
64
+ * loaded as a dependency of the globally-installed CLI the layout is
65
+ * `.../@alfe.ai/cli/dist/index.js` with `.../@alfe.ai/gateway/dist/...`
66
+ * alongside — walk up to the sibling `@alfe.ai/cli/package.json` and derive its
67
+ * `bin.alfe` / main entry. Returns undefined when it can't be resolved (e.g.
68
+ * `alfe gateway daemon` run from a dev checkout); the caller then skips the
69
+ * file-exists check and relies on `alfe --version` alone.
70
+ */
71
+ async function resolveCliEntryPath(fromUrl) {
72
+ try {
73
+ const { fileURLToPath } = await import("node:url");
74
+ const { dirname, join } = await import("node:path");
75
+ const { readFile } = await import("node:fs/promises");
76
+ let dir = dirname(fileURLToPath(fromUrl));
77
+ for (let i = 0; i < 10; i++) {
78
+ const pkgPath = join(dir, "package.json");
79
+ try {
80
+ const raw = await readFile(pkgPath, "utf-8");
81
+ const pkg = JSON.parse(raw);
82
+ if (pkg.name === "@alfe.ai/cli") {
83
+ const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.alfe ?? pkg.main ?? "dist/index.js";
84
+ return join(dir, binRel);
85
+ }
86
+ } catch {}
87
+ const parent = dirname(dir);
88
+ if (parent === dir) break;
89
+ dir = parent;
90
+ }
91
+ } catch {}
92
+ }
93
+ /**
94
+ * Run `<cmd>` and return the trimmed stdout on exit 0, or null on any failure
95
+ * (non-zero exit, ENOENT, timeout). Never throws.
96
+ */
97
+ async function tryVersion(cmd, args) {
98
+ try {
99
+ const { stdout } = await execFileAsync$1(cmd, args, { timeout: 15e3 });
100
+ const out = stdout.trim();
101
+ return out.length > 0 ? out : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+ /**
107
+ * Probe + decide whether the on-disk global CLI install is intact.
108
+ *
109
+ * @param expectedVersion when set, the reported version must match exactly.
110
+ * @param entryPath the resolved `.../dist/index.js` (from
111
+ * `resolveCliEntryPath`). When provided and missing,
112
+ * the check fails fast. Also used to run the CLI
113
+ * directly via `node <entry> --version`, which is more
114
+ * reliable than a PATH lookup of `alfe`.
115
+ */
116
+ async function verifyCliInstall(expectedVersion, entryPath) {
117
+ const entryExists = entryPath !== void 0 ? existsSync(entryPath) : true;
118
+ if (entryPath !== void 0 && !entryExists) return decideCliInstallOk({
119
+ entryPath,
120
+ entryExists,
121
+ reportedVersion: null
122
+ });
123
+ return decideCliInstallOk({
124
+ entryPath,
125
+ entryExists,
126
+ reportedVersion: entryPath ? await tryVersion(process.execPath, [entryPath, "--version"]) : await tryVersion("alfe", ["--version"])
127
+ }, expectedVersion);
128
+ }
129
+ //#endregion
4
130
  //#region src/upgrade.ts
5
131
  /**
6
132
  * CLI upgrade — runs npm install inline then exits.
@@ -12,13 +138,25 @@ import { promisify } from "node:util";
12
138
  * Flow:
13
139
  * 1. Daemon ACKs the COMMAND (caller handles this before calling upgrade)
14
140
  * 2. npm install -g @alfe.ai/cli@{version} runs as a child process
15
- * 3. Daemon exits with code 0
16
- * 4. Systemd restarts the service new version boots
141
+ * 3. VERIFY the new install is intact (dist/index.js present + `--version`
142
+ * reports the target). Only then exit an interrupted/aborted npm
143
+ * install that returned a broken tree must NOT trigger the exit, or
144
+ * systemd relaunches a dangling binary and crash-loops with 203/EXEC.
145
+ * 4. Daemon exits with code 0
146
+ * 5. Systemd restarts the service → new version boots
147
+ *
148
+ * See `packages/cli/DEVELOPING.md` → "Crash-safe CLI self-update". This is
149
+ * defense #1 (verify-before-exit); the boot-time self-heal guard (defense #2,
150
+ * an `ExecStartPre=` on the systemd unit) covers the case where the process is
151
+ * KILLED mid-install (reboot) and this function never gets to run.
17
152
  */
18
153
  const execFileAsync = promisify(execFile);
19
154
  /**
20
- * Upgrade the CLI to a target version and exit.
21
- * Systemd will restart the daemon with the new version.
155
+ * Upgrade the CLI to a target version and exit — UNLESS the resulting install
156
+ * is broken, in which case we stay up on the current version.
157
+ *
158
+ * Returns `Promise<never>` on the success path (it exits). On a verification
159
+ * failure it returns normally so the caller (and the daemon) keep running.
22
160
  */
23
161
  async function upgradeAndExit(version) {
24
162
  logger.info({ version }, "Upgrading CLI...");
@@ -30,14 +168,25 @@ async function upgradeAndExit(version) {
30
168
  ], { timeout: 12e4 });
31
169
  if (stdout) logger.debug({ stdout: stdout.trim() }, "npm install stdout");
32
170
  if (stderr) logger.debug({ stderr: stderr.trim() }, "npm install stderr");
33
- logger.info({ version }, "CLI upgraded — exiting for systemd restart");
34
171
  } catch (err) {
35
172
  const message = err instanceof Error ? err.message : String(err);
36
173
  logger.error({
37
174
  err: message,
38
175
  version
39
176
  }, "CLI upgrade failed");
177
+ process.exit(0);
178
+ }
179
+ const result = await verifyCliInstall(void 0, await resolveCliEntryPath(import.meta.url));
180
+ if (!result.ok) {
181
+ logger.error({
182
+ version,
183
+ reason: result.reason,
184
+ probe: result.probe
185
+ }, "CLI upgrade verification FAILED — staying on current version instead of exiting into a broken binary");
186
+ captureCliFailure("upgrade.verify_failed", /* @__PURE__ */ new Error(`CLI upgrade to ${version} produced a broken install: ${result.reason ?? "unknown"}`));
187
+ return;
40
188
  }
189
+ logger.info({ version }, "CLI upgrade verified — exiting for systemd restart");
41
190
  process.exit(0);
42
191
  }
43
192
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/gateway",
3
- "version": "0.7.4",
3
+ "version": "0.8.1",
4
4
  "description": "Alfe local gateway daemon — persistent control plane for agent integrations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,11 +23,11 @@
23
23
  "pino-roll": "^1.2.0",
24
24
  "smol-toml": ">=1.6.1",
25
25
  "ws": "^8.18.0",
26
- "@alfe.ai/agent-api-client": "^0.10.0",
26
+ "@alfe.ai/agent-api-client": "^0.11.0",
27
27
  "@alfe.ai/ai-proxy-local": "^0.0.13",
28
28
  "@alfe.ai/config": "^0.3.0",
29
- "@alfe.ai/integration-manifest": "^0.3.1",
30
- "@alfe.ai/integrations": "^0.4.2",
29
+ "@alfe.ai/integration-manifest": "^0.3.2",
30
+ "@alfe.ai/integrations": "^0.5.1",
31
31
  "@alfe.ai/mcp-bundler": "^0.3.1"
32
32
  },
33
33
  "license": "UNLICENSED",