@runuai/host 0.8.13 → 0.8.17

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/lib/obs.ts ADDED
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Host-agent crash reporting (ADR-071). Default-ON for installed hosts,
3
+ * with disclosure and a first-class opt-out — an installed product whose
4
+ * crash reporting requires hand-editing .env.local reports nothing from
5
+ * exactly the machines we need visibility into. The contract:
6
+ *
7
+ * - UAI_TELEMETRY_DISABLED=1 → nothing initializes, zero bytes leave the
8
+ * machine. The one switch, documented everywhere the DSN default is.
9
+ * - UAI_SENTRY_DSN=… → operator override (self-hosters pointing at
10
+ * their own Sentry).
11
+ * - otherwise → DEFAULT_HOST_DSN below. DSNs are public by
12
+ * design (every browser bundle ships one); this is errors-only crash
13
+ * reporting, content-scrubbed, console breadcrumbs disabled.
14
+ *
15
+ * Every start with the default DSN logs a disclosure line naming the
16
+ * opt-out, and the default only ACTIVATES after the persisted disclosure
17
+ * marker exists (see the gate below).
18
+ *
19
+ * Captures uncaught exceptions / unhandled rejections (then the process
20
+ * exits as it does today; launchd/systemd restarts it), plus
21
+ * bridge-lifecycle breadcrumbs for context. Console breadcrumbs are
22
+ * disabled: host console output carries third-party process text
23
+ * (git/docker stderr, agent lifecycle lines) no scrubber can bound.
24
+ *
25
+ * Errors only — tracesSampleRate 0. The WSS heartbeat shares this event loop
26
+ * (agent-notes: never block it); we add no per-event work.
27
+ *
28
+ * The scrub rules MIRROR lib/obs/scrub.ts on the cloud side, duplicated by
29
+ * hand because the cloud may not import host internals and vice versa
30
+ * (eslint import guard). Every behavior here — key lists, string patterns,
31
+ * depth cap ("[max-depth]", never raw pass-through), hostile-proxy fencing —
32
+ * must match the cloud scrubber; obs.test.ts holds the parity regressions
33
+ * and docs/observability.md holds the shared table. The hooks are exported
34
+ * so tests exercise exactly what Sentry runs.
35
+ */
36
+
37
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
38
+ import { homedir } from "node:os";
39
+ import { join } from "node:path";
40
+
41
+ import * as Sentry from "@sentry/node";
42
+
43
+ import { env } from "./env";
44
+
45
+ /**
46
+ * The uai-host Sentry project's DSN, baked into the shipped package (npm +
47
+ * desktop-vendored copy). Public by design — a DSN can only ingest events,
48
+ * never read them. Activation is still gated: persisted disclosure marker,
49
+ * never in repo checkouts/dev/test, UAI_TELEMETRY_DISABLED=1 kills it,
50
+ * UAI_SENTRY_DSN overrides it.
51
+ */
52
+ export const DEFAULT_HOST_DSN =
53
+ "https://f1b34b8b7df6f7ee519500fd06faa872@o4511765849178112.ingest.us.sentry.io/4511768446369792";
54
+
55
+ export type HostDsnSource = "disabled" | "explicit" | "default" | "none";
56
+
57
+ /**
58
+ * Pure resolution of the telemetry contract above — unit-tested. The baked
59
+ * DEFAULT applies only to INSTALLED hosts: a repo checkout
60
+ * (UAI_AGENT_FROM_REPO) or an explicit dev/test NODE_ENV never
61
+ * default-activates — the consent claim is "installed product telemetry",
62
+ * not "developing the repo phones home". An explicit UAI_SENTRY_DSN is
63
+ * always honored.
64
+ */
65
+ export function resolveHostDsn(env: {
66
+ UAI_TELEMETRY_DISABLED?: string;
67
+ UAI_SENTRY_DSN?: string;
68
+ NODE_ENV?: string;
69
+ UAI_AGENT_FROM_REPO?: string;
70
+ UAI_LAUNCHED_FROM_REPO?: string;
71
+ }): { dsn: string | null; source: HostDsnSource } {
72
+ if (env.UAI_TELEMETRY_DISABLED === "1") {
73
+ return { dsn: null, source: "disabled" };
74
+ }
75
+ if (env.UAI_SENTRY_DSN) {
76
+ return { dsn: env.UAI_SENTRY_DSN, source: "explicit" };
77
+ }
78
+ // UAI_LAUNCHED_FROM_REPO is stamped by src/load-env.ts from its own
79
+ // filesystem position (review finding: real dev launches — root
80
+ // `pnpm host-agent`, desktop non-packaged — set none of the explicit
81
+ // flags). The explicit flags remain as overrides/belt.
82
+ const repoOrDev =
83
+ env.NODE_ENV === "development" ||
84
+ env.NODE_ENV === "test" ||
85
+ env.UAI_AGENT_FROM_REPO === "1" ||
86
+ env.UAI_AGENT_FROM_REPO === "true" ||
87
+ env.UAI_LAUNCHED_FROM_REPO === "1";
88
+ if (DEFAULT_HOST_DSN && !repoOrDev) {
89
+ return { dsn: DEFAULT_HOST_DSN, source: "default" };
90
+ }
91
+ return { dsn: null, source: "none" };
92
+ }
93
+
94
+ /**
95
+ * The disclosure text, single-sourced: the service start log line and the
96
+ * `uai-host install` / `pair` / `setup` / `start` / `restart` stdout all
97
+ * print exactly this. Says what is TRUE, no more: crash reports carry a
98
+ * pseudonymous hostId tag ("anonymous" would overclaim), OS/runtime
99
+ * versions ride along, and error text is scrubbed for secret shapes but
100
+ * pattern scrubbing cannot recognize arbitrary prose — the residual-risk
101
+ * stance documented in docs/observability.md, mirrored here instead of a
102
+ * "never" the implementation can't fully guarantee.
103
+ */
104
+ export const TELEMETRY_NOTICE =
105
+ "crash reporting is ON by default for installed hosts. Sent on a crash " +
106
+ "only: the error and stack trace (paths + secret-shaped strings " +
107
+ "redacted), recent bridge-connection breadcrumbs, OS/runtime versions, " +
108
+ "agent version, and a pseudonymous host id — no sessions, no usage " +
109
+ "pings, no request data, and telemetry is designed to exclude chat, " +
110
+ "prompts, env values, and repo content (error text is scrubbed, but " +
111
+ "redaction is pattern-based, not perfect). Disable any time: " +
112
+ "UAI_TELEMETRY_DISABLED=1 in the host's .env.local.";
113
+
114
+ /**
115
+ * Persisted disclosure gate (review): the baked DEFAULT only activates
116
+ * after the notice has actually been SHOWN once on this machine — a service
117
+ * silently upgraded via reboot/KeepAlive never sees a terminal, so it stays
118
+ * off until the next interactive `uai-host` command (or the desktop
119
+ * first-run dialog, which writes the same marker) discloses and persists.
120
+ */
121
+ export function telemetryDisclosureMarkerPath(home: string = env.uaiHome): string {
122
+ return join(home, ".telemetry-disclosed");
123
+ }
124
+
125
+ export function hasSeenTelemetryDisclosure(home: string = env.uaiHome): boolean {
126
+ try {
127
+ return existsSync(telemetryDisclosureMarkerPath(home));
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /** Best-effort persist; called by the surfaces that PRINT the notice. */
134
+ export function persistTelemetryDisclosure(home: string = env.uaiHome): void {
135
+ try {
136
+ mkdirSync(home, { recursive: true });
137
+ writeFileSync(
138
+ telemetryDisclosureMarkerPath(home),
139
+ `${new Date().toISOString()}\n`,
140
+ );
141
+ } catch {
142
+ // Worst case: the notice prints again next time.
143
+ }
144
+ }
145
+
146
+ /** Pure gate — unit-tested: the default requires a persisted disclosure. */
147
+ export function defaultActivationAllowed(
148
+ source: HostDsnSource,
149
+ disclosureSeen: boolean,
150
+ ): boolean {
151
+ return source !== "default" || disclosureSeen;
152
+ }
153
+
154
+ /** The notice CLI commands print, or null when the default isn't active. */
155
+ export function telemetryNoticeIfActive(): string | null {
156
+ return resolveHostDsn(process.env).source === "default"
157
+ ? TELEMETRY_NOTICE
158
+ : null;
159
+ }
160
+
161
+ let initialized = false;
162
+
163
+ const SUBSTRING_KEY =
164
+ /(token|secret|password|passwd|credential|authorization|cookie|apikey|api[-_]key|private[-_]?key)/i;
165
+ const EXACT_KEY =
166
+ /^(prompt|initialprompt|globalcontext|defaultprompt|content|text|body|env|http\.query|url\.query|query|query_string|querystring|search|http\.fragment|url\.fragment|fragment|hash)$/i;
167
+
168
+ function isSensitiveKey(key: string): boolean {
169
+ return EXACT_KEY.test(key) || SUBSTRING_KEY.test(key);
170
+ }
171
+
172
+ const URL_CREDENTIALS_RE = /(\/\/)[^/\s@:]+:[^/\s@]+@/g;
173
+ // ?query OR #fragment tails (fragments carry OAuth implicit tokens) —
174
+ // mirrors lib/obs/scrub.ts.
175
+ const URL_TAIL_RE = /(https?:\/\/[^\s"'<>?#]+)([?#])[^\s"'<>]*/g;
176
+ const RELATIVE_URL_TAIL_RE =
177
+ /(^|[\s"'`(=])(\/[A-Za-z0-9_\-./~%[\]]*)([?#])[^\s"'<>]*/g;
178
+ const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
179
+ const TOKEN_RES: RegExp[] = [
180
+ /\b(?:sk|rk)-[A-Za-z0-9_-]{16,}\b/g,
181
+ /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{8,}\b/g,
182
+ /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
183
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
184
+ /\bxox[a-z]-[A-Za-z0-9-]{10,}\b/g,
185
+ /\bwhsec_[A-Za-z0-9]{8,}\b/g,
186
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*\b/g,
187
+ /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
188
+ ];
189
+
190
+ // Identity/repo-metadata rules beyond the cloud mirror — host strings carry
191
+ // operator PATHS: the home dir embeds the username, and task worktree paths
192
+ // embed project slugs (repo-identifying — "no repo data" must hold for
193
+ // stack frames too).
194
+ /**
195
+ * Separator-agnostic, case-insensitive home matcher (exported for injected-
196
+ * home tests): Sentry normalizes Windows ESM frame paths to FORWARD slashes,
197
+ * so an exact `C:\Users\Alice` match left `C:/Users/Alice` unredacted
198
+ * (review probe), and Windows paths are case-insensitive.
199
+ */
200
+ export function buildHomePathRe(home: string): RegExp {
201
+ const pattern = home
202
+ .split(/[\\/]+/)
203
+ .filter((seg) => seg !== "")
204
+ .map((seg) => seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
205
+ .join("[\\\\/]+");
206
+ // A POSIX home is rooted — the leading separator must be part of the
207
+ // match, or "/home/op" replaces as "/~". Windows homes ("C:\…") aren't.
208
+ const lead = /^[\\/]/.test(home) ? "[\\\\/]+" : "";
209
+ return new RegExp(lead + pattern, "gi");
210
+ }
211
+
212
+ const HOME_RE = buildHomePathRe(homedir());
213
+ // MCP gateway routes are BEARER-IN-PATH: /t/<token>/<connection-slug>
214
+ // (mcp-gateway.ts). A crash near the gateway put both verbatim into event
215
+ // strings (review receiver) — normalize the whole segment pair.
216
+ const MCP_GATEWAY_PATH_RE = /(\/t\/)[^/\s"'?#]+(\/[^/\s"'?#]+)?/g;
217
+ // Both separator styles: hosts run on Windows too, and a POSIX-only rule
218
+ // left repo slugs intact in win32 paths (review probe).
219
+ const TASK_WORKSPACE_SLUG_RE =
220
+ /([/\\]tasks[/\\][A-Za-z0-9_-]+[/\\]workspace[/\\])[^/\\\s"']+/g;
221
+
222
+ export function scrubHostString(value: string): string {
223
+ let out = value;
224
+ out = out.replace(URL_CREDENTIALS_RE, "$1[redacted]@");
225
+ out = out.replace(URL_TAIL_RE, "$1$2[redacted]");
226
+ out = out.replace(RELATIVE_URL_TAIL_RE, "$1$2$3[redacted]");
227
+ for (const re of TOKEN_RES) out = out.replace(re, "[redacted]");
228
+ out = out.replace(EMAIL_RE, "[email]");
229
+ out = out.replace(HOME_RE, "~");
230
+ out = out.replace(TASK_WORKSPACE_SLUG_RE, "$1[project]");
231
+ out = out.replace(MCP_GATEWAY_PATH_RE, "$1[redacted]");
232
+ return out;
233
+ }
234
+
235
+ /** URL field sanitizer: drop ?query/#fragment wholesale, then string-scrub. */
236
+ export function scrubHostUrl(url: string): string {
237
+ const q = url.indexOf("?");
238
+ const h = url.indexOf("#");
239
+ const cut = q === -1 ? h : h === -1 ? q : Math.min(q, h);
240
+ return scrubHostString(
241
+ cut === -1 ? url : `${url.slice(0, cut)}${url[cut]}[redacted]`,
242
+ );
243
+ }
244
+
245
+ /** Mirror of the cloud scrubValue: depth-capped, hostile-proxy-fenced. */
246
+ export function scrubHostValue(value: unknown, depth = 0): unknown {
247
+ if (typeof value === "string") return scrubHostString(value);
248
+ if (value === null || typeof value !== "object") return value;
249
+ if (depth > 8) return "[max-depth]";
250
+ try {
251
+ if (Array.isArray(value)) {
252
+ return value.map((v) => {
253
+ try {
254
+ return scrubHostValue(v, depth + 1);
255
+ } catch {
256
+ return "[unreadable]";
257
+ }
258
+ });
259
+ }
260
+ const out: Record<string, unknown> = {};
261
+ for (const k of Object.keys(value as Record<string, unknown>)) {
262
+ if (isSensitiveKey(k)) {
263
+ out[k] = "[scrubbed]";
264
+ continue;
265
+ }
266
+ try {
267
+ out[k] = scrubHostValue((value as Record<string, unknown>)[k], depth + 1);
268
+ } catch {
269
+ out[k] = "[unreadable]";
270
+ }
271
+ }
272
+ return out;
273
+ } catch {
274
+ return "[unreadable]";
275
+ }
276
+ }
277
+
278
+ interface HostScrubbableBreadcrumb {
279
+ category?: string;
280
+ message?: string;
281
+ data?: Record<string, unknown>;
282
+ }
283
+
284
+ interface HostScrubbableFrame {
285
+ filename?: string;
286
+ abs_path?: string;
287
+ module?: string;
288
+ }
289
+
290
+ interface HostScrubbableEvent {
291
+ message?: string;
292
+ server_name?: string;
293
+ transaction?: string;
294
+ request?: unknown;
295
+ exception?: {
296
+ values?: Array<{
297
+ type?: string;
298
+ value?: string;
299
+ stacktrace?: { frames?: HostScrubbableFrame[] };
300
+ }>;
301
+ };
302
+ extra?: Record<string, unknown>;
303
+ contexts?: Record<string, Record<string, unknown> | undefined>;
304
+ breadcrumbs?: HostScrubbableBreadcrumb[];
305
+ }
306
+
307
+ /** The exact beforeSend hook Sentry runs — exported for regression tests. */
308
+ export function scrubHostEvent<T extends HostScrubbableEvent>(event: T): T {
309
+ // The SDK stamps os.hostname() ("Diogos-Mac-Studio.local") as server_name
310
+ // — identifying, and exactly what the pseudonymous hostId tag replaces.
311
+ delete event.server_name;
312
+ // MINIMAL CONTRACT (review): request context and transaction names are
313
+ // DROPPED wholesale, not sanitized. The MCP gateway is bearer-in-path
314
+ // (/t/<token>/<slug>), and per-field scrubbing left the token verbatim in
315
+ // both `transaction` and `request.url`, plus mcp-session-id in headers —
316
+ // the disclosed payload is the error, not the route that raised it.
317
+ delete event.request;
318
+ delete event.transaction;
319
+ if (typeof event.message === "string") {
320
+ event.message = scrubHostString(event.message);
321
+ }
322
+ if (event.exception?.values) {
323
+ for (const ex of event.exception.values) {
324
+ if (typeof ex.value === "string") ex.value = scrubHostString(ex.value);
325
+ // Stack frame paths ride the operator's home dir (username) and task
326
+ // worktree slugs (repo names) — string-scrub them frame by frame.
327
+ for (const frame of ex.stacktrace?.frames ?? []) {
328
+ if (typeof frame.filename === "string") {
329
+ frame.filename = scrubHostString(frame.filename);
330
+ }
331
+ if (typeof frame.abs_path === "string") {
332
+ frame.abs_path = scrubHostString(frame.abs_path);
333
+ }
334
+ if (typeof frame.module === "string") {
335
+ frame.module = scrubHostString(frame.module);
336
+ }
337
+ }
338
+ }
339
+ }
340
+ if (event.extra) {
341
+ event.extra = scrubHostValue(event.extra) as Record<string, unknown>;
342
+ }
343
+ if (event.contexts) {
344
+ // Allow-list, not scrub: the SDK's environment-derived system context
345
+ // (device, app, culture, cloud resource) exceeds the disclosed "error +
346
+ // stack trace + host id + version". Keep only what crash triage needs.
347
+ const pruned: NonNullable<HostScrubbableEvent["contexts"]> = {};
348
+ const os = event.contexts.os;
349
+ if (os) pruned.os = { name: os.name, version: os.version };
350
+ const runtime = event.contexts.runtime;
351
+ if (runtime) {
352
+ pruned.runtime = { name: runtime.name, version: runtime.version };
353
+ }
354
+ if (event.contexts.trace) {
355
+ pruned.trace = scrubHostValue(event.contexts.trace) as Record<
356
+ string,
357
+ unknown
358
+ >;
359
+ }
360
+ event.contexts = pruned as T["contexts"];
361
+ }
362
+ if (Array.isArray(event.breadcrumbs)) {
363
+ // Bridge-only is ENFORCED here too, not just at beforeBreadcrumb: any
364
+ // crumb already sitting on the event (added before init hooks, or by a
365
+ // future integration) is allow-listed away (review probe: a native
366
+ // `http` crumb carrying a private repo URL shipped unchanged).
367
+ event.breadcrumbs = event.breadcrumbs
368
+ .filter((crumb) => crumb?.category === "bridge")
369
+ .map((crumb) => scrubHostBreadcrumb(crumb))
370
+ .filter((crumb): crumb is NonNullable<typeof crumb> => crumb !== null);
371
+ }
372
+ return event;
373
+ }
374
+
375
+ /**
376
+ * The exact beforeBreadcrumb hook Sentry runs — exported for tests.
377
+ * FAIL-CLOSED allow-list (review): only the curated `bridge` category
378
+ * exists in this telemetry; anything else — whatever integration or code
379
+ * path produced it — is dropped, then the survivor is scrubbed.
380
+ */
381
+ export function scrubHostBreadcrumb<T extends HostScrubbableBreadcrumb>(
382
+ crumb: T,
383
+ ): T | null {
384
+ if (crumb.category !== "bridge") return null;
385
+ if (typeof crumb.message === "string") {
386
+ crumb.message = scrubHostString(crumb.message);
387
+ }
388
+ if (crumb.data) {
389
+ crumb.data = scrubHostValue(crumb.data) as Record<string, unknown>;
390
+ }
391
+ return crumb;
392
+ }
393
+
394
+ /**
395
+ * The exact Sentry.init options — exported pure for tests, because "errors
396
+ * only" is a checkable claim, not a vibe: release-health sessions send an
397
+ * envelope AT INIT (review probe caught it) and client reports are another
398
+ * non-crash egress; both are off, so nothing leaves until a crash.
399
+ */
400
+ export function buildHostInitOptions(args: {
401
+ dsn: string;
402
+ version?: string;
403
+ }): Sentry.NodeOptions {
404
+ return {
405
+ dsn: args.dsn,
406
+ // A service install has no NODE_ENV — that's the PRODUCTION case for a
407
+ // host (review finding: installs were labelled "development"). Explicit
408
+ // dev/test envs still label themselves; SENTRY_ENVIRONMENT overrides.
409
+ environment:
410
+ process.env.SENTRY_ENVIRONMENT ||
411
+ (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test"
412
+ ? process.env.NODE_ENV
413
+ : "production"),
414
+ release: args.version,
415
+ tracesSampleRate: 0,
416
+ sendDefaultPii: false,
417
+ sendClientReports: false,
418
+ // With tracing off the SDK still injects sentry-trace/baggage headers
419
+ // (public key, release, environment) into ORDINARY outbound HTTP —
420
+ // metadata third parties the host talks to shouldn't receive.
421
+ tracePropagationTargets: [],
422
+ // "strict" preserves today's semantics: an unhandled rejection still
423
+ // takes the process down (service manager restarts) — Sentry's default
424
+ // "warn" mode would silently keep a possibly-wedged host alive.
425
+ // Console breadcrumbs are DROPPED (header comment) — host console
426
+ // carries unboundable third-party output. ProcessSession is DROPPED —
427
+ // it is the init-time session envelope that falsified "errors only".
428
+ // Modules is DROPPED — the full dependency inventory is data the
429
+ // disclosure doesn't cover and debugging doesn't need. ContextLines is
430
+ // DROPPED — the disclosure promises "error and stack trace", and
431
+ // source-line excerpts exceed that.
432
+ // Http is REMOVED ENTIRELY, not reconfigured. Every retained slice of it
433
+ // broke the minimal contract in review probes: default incoming-session
434
+ // tracking sent a `sessions` envelope on a plain GET; outbound
435
+ // breadcrumbs shipped completed request URLs; and — unfixable by hooks —
436
+ // inbound `sentry-trace`/`baggage` TRACE CONTINUATION copies
437
+ // attacker-controlled public_key/release/environment/user_segment/
438
+ // transaction into the ENVELOPE HEADER, which beforeSend never sees.
439
+ // With request/transaction context already discarded, http
440
+ // instrumentation carries no payload value for crash-only reporting.
441
+ // NodeFetch goes with it for the same minimal contract: it adds fetch
442
+ // breadcrumbs (outbound request data) and is the undici half of trace
443
+ // propagation. No http-layer instrumentation remains on either module.
444
+ integrations: (defaults) => [
445
+ ...defaults.filter(
446
+ (i) =>
447
+ i.name !== "Console" &&
448
+ i.name !== "ProcessSession" &&
449
+ i.name !== "Modules" &&
450
+ i.name !== "ContextLines" &&
451
+ i.name !== "Http" &&
452
+ i.name !== "NodeFetch",
453
+ ),
454
+ Sentry.onUnhandledRejectionIntegration({ mode: "strict" }),
455
+ ],
456
+ beforeSend: (event) => scrubHostEvent(event),
457
+ beforeBreadcrumb: (crumb) => scrubHostBreadcrumb(crumb),
458
+ };
459
+ }
460
+
461
+ export function initHostObs(opts: { version?: string } = {}): void {
462
+ if (initialized) return;
463
+ const { dsn, source } = resolveHostDsn(process.env);
464
+ if (!dsn) return;
465
+ if (!defaultActivationAllowed(source, hasSeenTelemetryDisclosure())) {
466
+ console.log(
467
+ "[host-agent] crash reporting is pending disclosure — it stays OFF until the notice has been shown (`uai-host install`/`pair`/`setup`/`start`/`restart`, or the desktop first run)",
468
+ );
469
+ return;
470
+ }
471
+
472
+ Sentry.init(buildHostInitOptions({ dsn, version: opts.version }));
473
+ initialized = true;
474
+ if (source === "default") {
475
+ // The disclosure half of default-on: every start says what leaves the
476
+ // machine and how to turn it off (same text the install/pair CLIs print).
477
+ console.log(`[host-agent] ${TELEMETRY_NOTICE}`);
478
+ } else {
479
+ console.log("[host-agent] sentry crash reporting enabled (UAI_SENTRY_DSN)");
480
+ }
481
+ }
482
+
483
+ export function hostObsEnabled(): boolean {
484
+ return initialized;
485
+ }
486
+
487
+ /** Tag every future event with this host's id (safe to call repeatedly). */
488
+ export function setHostObsTag(hostId: string): void {
489
+ if (!initialized) return;
490
+ Sentry.setTag("hostId", hostId);
491
+ }
492
+
493
+ /**
494
+ * Bridge-lifecycle context for the next crash. No-op when disabled. The
495
+ * category is NARROWED to the allow-listed literal — the hooks drop
496
+ * everything else, so a wider signature would only invite silent losses.
497
+ */
498
+ export function addHostBreadcrumb(
499
+ category: "bridge",
500
+ message: string,
501
+ data?: Record<string, unknown>,
502
+ ): void {
503
+ if (!initialized) return;
504
+ Sentry.addBreadcrumb({ category, message, data, level: "info" });
505
+ }
506
+
507
+ /** Explicit capture for caught-but-fatal paths. No-op when disabled. */
508
+ export function captureHostError(
509
+ err: unknown,
510
+ extra?: Record<string, unknown>,
511
+ ): void {
512
+ if (!initialized) return;
513
+ Sentry.captureException(err, extra ? { extra } : undefined);
514
+ }
@@ -51,6 +51,7 @@ import {
51
51
  writeAgentCli,
52
52
  } from "./agent-cli";
53
53
  import { setupBrowserTesting } from "./browser-testing";
54
+ import { injectCodexIntoContainer } from "./codex-auth";
54
55
  import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
55
56
  import { env } from "./env";
56
57
  import type {
@@ -1392,11 +1393,26 @@ export function getOrchestrator(): Orchestrator {
1392
1393
  if (!globalForOrchestrator.__uaiRecoverRan) {
1393
1394
  globalForOrchestrator.__uaiRecoverRan = true;
1394
1395
  // Fire-and-forget — the orchestrator is usable while recovery runs.
1395
- void recoverRunningTasks();
1396
+ // The promise is kept so boot steps that must NOT race recovery (the
1397
+ // Codex credential reinject) can sequence behind it via
1398
+ // recoveryComplete(). recoverRunningTasks never rejects.
1399
+ recoveryPromise = recoverRunningTasks();
1400
+ void recoveryPromise;
1396
1401
  }
1397
1402
  return globalForOrchestrator.__uaiOrchestrator;
1398
1403
  }
1399
1404
 
1405
+ let recoveryPromise: Promise<void> = Promise.resolve();
1406
+
1407
+ /**
1408
+ * Resolves once boot-time recovery has finished (starting the orchestrator —
1409
+ * and with it recovery — if that hasn't happened yet). Never rejects.
1410
+ */
1411
+ export function recoveryComplete(): Promise<void> {
1412
+ getOrchestrator();
1413
+ return recoveryPromise;
1414
+ }
1415
+
1400
1416
  // ---------------------------------------------------------------------------
1401
1417
  // Boot-time recovery
1402
1418
  //
@@ -1497,7 +1513,8 @@ async function dockerExec(
1497
1513
  return res.status === 0;
1498
1514
  }
1499
1515
 
1500
- async function recoverRunningTasks(): Promise<void> {
1516
+ /** Exported for tests; production entry is the getOrchestrator() boot guard. */
1517
+ export async function recoverRunningTasks(): Promise<void> {
1501
1518
  try {
1502
1519
  const db = getDb();
1503
1520
  const rows = db
@@ -1601,6 +1618,12 @@ async function recoverOneTask(
1601
1618
  });
1602
1619
  return;
1603
1620
  }
1621
+ // Correctly-owned Codex creds BEFORE uai-init / any agent respawn: the boot
1622
+ // reinject sweep only targets containers already running, so a container
1623
+ // recovered here would otherwise keep whatever it held when it exited —
1624
+ // possibly host-uid-owned 0600 files Codex can't read. Failure is surfaced
1625
+ // by the inject itself and must not abort the task's recovery.
1626
+ await injectCodexIntoContainer(containerName);
1604
1627
  // uai-init reinstalls workspace deps (pnpm/npm install) — minutes on a big
1605
1628
  // repo. dockerCli's 30s default would SIGKILL it mid-install.
1606
1629
  if (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.13",
3
+ "version": "0.8.17",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -66,6 +66,7 @@
66
66
  "uai-host": "tsx src/cli.ts"
67
67
  },
68
68
  "dependencies": {
69
+ "@sentry/node": "^10.66.0",
69
70
  "better-sqlite3": "^11.3.0",
70
71
  "dotenv": "^16.4.5",
71
72
  "drizzle-orm": "^0.36.0",
@@ -462,17 +462,25 @@ fi
462
462
  # Copy Codex credentials/config into a task-private /home/node/.codex.
463
463
  # Do not mount the host ~/.codex writable: Codex stores live SQLite state
464
464
  # there, and concurrent host/container access can corrupt it.
465
- docker exec -u root "$app_container" \
466
- mkdir -p /home/node/.codex >/dev/null 2>&1 || true
467
- for codex_item in auth.json config.toml AGENTS.md version.json installation_id rules; do
468
- if [ -e "$UAI_OWNER_HOME/.codex/$codex_item" ]; then
469
- docker cp "$UAI_OWNER_HOME/.codex/$codex_item" \
470
- "$app_container":/home/node/.codex/ >/dev/null 2>&1 \
471
- || log "warning: docker cp of .codex/$codex_item failed; codex may need re-login"
472
- fi
473
- done
474
- docker exec -u root "$app_container" \
475
- chown -R node:node /home/node/.codex >/dev/null 2>&1 || true
465
+ # Failures here are REAL errors (the container is running at task-up):
466
+ # docker cp preserves the host uid/gid, so a skipped chown leaves 0600
467
+ # files owned by e.g. macOS 501:20 that the container's node (1000:1000)
468
+ # cannot read Codex then dies at startup.
469
+ if docker exec -u root "$app_container" \
470
+ mkdir -p /home/node/.codex >/dev/null; then
471
+ for codex_item in auth.json config.toml AGENTS.md version.json installation_id rules; do
472
+ if [ -e "$UAI_OWNER_HOME/.codex/$codex_item" ]; then
473
+ docker cp "$UAI_OWNER_HOME/.codex/$codex_item" \
474
+ "$app_container":/home/node/.codex/ >/dev/null \
475
+ || log "ERROR: docker cp of .codex/$codex_item failed; Codex in this task may not authenticate"
476
+ fi
477
+ done
478
+ docker exec -u root "$app_container" \
479
+ chown -R node:node /home/node/.codex >/dev/null \
480
+ || log "ERROR: chown of /home/node/.codex failed — 0600 credentials remain unreadable by node. Fix: docker exec -u root $app_container chown -R node:node /home/node/.codex"
481
+ else
482
+ log "ERROR: mkdir /home/node/.codex failed in $app_container; Codex credentials were not injected"
483
+ fi
476
484
 
477
485
  # Copy Kimi Code subscription/config into a task-private /home/node/.kimi-code.
478
486
  # The Linux `kimi` binary is baked into the image; here we copy ONLY the