@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/bin/gateway.js +1 -1
- package/dist/health.js +215 -401
- package/dist/sentry.js +386 -0
- package/dist/src/index.d.ts +0 -6
- package/dist/src/index.js +2 -1
- package/dist/upgrade.js +154 -5
- package/package.json +4 -4
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 };
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
16
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
30
|
-
"@alfe.ai/integrations": "^0.
|
|
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",
|