@runuai/host 0.8.16 → 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/README.md CHANGED
@@ -89,3 +89,26 @@ no build step (ADR-032).
89
89
  - [docs/host-ui.md](https://github.com/runuai/uai/blob/main/docs/host-ui.md) — the local UI surface.
90
90
  - [docs/host-packaging.md](https://github.com/runuai/uai/blob/main/docs/host-packaging.md) — how this package is built and published.
91
91
  </content>
92
+
93
+ ## Telemetry
94
+
95
+ Installed hosts report **crashes only** to Sentry by default. Sent on a
96
+ crash: the error and stack trace (paths and secret-shaped strings
97
+ redacted), recent bridge-connection breadcrumbs, OS/runtime versions, the
98
+ package version, and a pseudonymous host id. No sessions, no usage pings,
99
+ and no request data — nothing is sent until something actually crashes.
100
+ Telemetry is designed to exclude chat, prompts, env values, and repo
101
+ content; error text is scrubbed for secret shapes, but redaction is
102
+ pattern-based, not perfect. Repo checkouts and dev/test runs never report,
103
+ and reporting only activates after this notice has been shown once (the
104
+ `install`/`pair`/`setup`/`start`/`restart` commands print it — a silently
105
+ upgraded service stays off until one of them runs).
106
+
107
+ Opt out any time — add to `$UAI_HOME/.env.local` (default
108
+ `~/.uai/.env.local`), then `uai-host restart`:
109
+
110
+ ```
111
+ UAI_TELEMETRY_DISABLED=1
112
+ ```
113
+
114
+ `UAI_SENTRY_DSN=<dsn>` redirects reports to your own Sentry instead.
package/lib/engines.ts CHANGED
@@ -518,7 +518,7 @@ export function disconnectEngine(
518
518
  }
519
519
 
520
520
  // ---------------------------------------------------------------------------
521
- // Install a missing engine CLI (ADR-072).
521
+ // Install a missing engine CLI (ADR-074).
522
522
  // ---------------------------------------------------------------------------
523
523
 
524
524
  /**
package/lib/env.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  * machine; cloud metadata arrives over the host protocol.
4
4
  */
5
5
 
6
- import { existsSync } from "node:fs";
6
+ import { existsSync, realpathSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
- import { dirname, resolve } from "node:path";
8
+ import { dirname, resolve, sep } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
11
  import { z } from "zod";
@@ -26,6 +26,12 @@ const schema = z.object({
26
26
  UAI_HOST_KEY_FILE: z.string().min(1).optional(),
27
27
  UAI_GH_PAT_FALLBACK: z.string().min(1).optional(),
28
28
  UAI_WORKSPACE_ROOT: z.string().min(1).default("~/.uai"),
29
+ // Crash reporting (ADR-071, docs/observability.md). Default-ON for
30
+ // installed hosts via the DSN baked into lib/obs.ts; UAI_SENTRY_DSN
31
+ // overrides it (self-hosted Sentry), UAI_TELEMETRY_DISABLED=1 turns it
32
+ // off entirely — zero telemetry bytes leave the machine.
33
+ UAI_SENTRY_DSN: z.string().min(1).optional(),
34
+ UAI_TELEMETRY_DISABLED: z.string().optional(),
29
35
  NODE_ENV: z
30
36
  .enum(["development", "production", "test"])
31
37
  .default("development"),
@@ -61,14 +67,27 @@ function findUp(start: string, pred: (dir: string) => boolean): string | null {
61
67
  }
62
68
 
63
69
  // Monorepo root iff we're running from a checkout: a dir holding BOTH
64
- // pnpm-workspace.yaml and a host-agent/ child. null when installed under
65
- // node_modules then state lives under ~/.uai, never inside the package.
66
- const repoRoot = findUp(
67
- here,
68
- (d) =>
69
- existsSync(resolve(d, "pnpm-workspace.yaml")) &&
70
- existsSync(resolve(d, "host-agent")),
71
- );
70
+ // pnpm-workspace.yaml and a host-agent/ child. The node_modules guard makes
71
+ // the "null when installed" comment TRUE (review finding: a tarball beneath
72
+ // a foreign pnpm workspace with a sibling host-agent/ resolved uaiHome — and
73
+ // the telemetry disclosure marker — into that workspace instead of ~/.uai).
74
+ // Real path first: pnpm reaches this file through symlinks. Keep in sync
75
+ // with src/load-env.ts, which applies the same rule.
76
+ const realHere = (() => {
77
+ try {
78
+ return realpathSync(here);
79
+ } catch {
80
+ return here;
81
+ }
82
+ })();
83
+ const repoRoot = realHere.split(sep).includes("node_modules")
84
+ ? null
85
+ : findUp(
86
+ realHere,
87
+ (d) =>
88
+ existsSync(resolve(d, "pnpm-workspace.yaml")) &&
89
+ existsSync(resolve(d, "host-agent")),
90
+ );
72
91
 
73
92
  // UAI_HOME: where .env.local + the data dir live. Explicit UAI_HOME wins; then
74
93
  // the parent of an explicit UAI_DATA_DIR; then the repo root (dev checkout);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.16",
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",
package/src/cli.ts CHANGED
@@ -27,6 +27,10 @@ import { dirname } from "node:path";
27
27
 
28
28
  import { ulid } from "ulid";
29
29
 
30
+ import {
31
+ persistTelemetryDisclosure,
32
+ telemetryNoticeIfActive,
33
+ } from "../lib/obs";
30
34
  import { envLocalPath, serviceLogPath, uaiHome, uiPortFilePath } from "./paths";
31
35
  import { CloudResponse, StatusResponse, TasksResponse } from "./ui/types";
32
36
  import type { InstallContext, Installer } from "../scripts/install/types";
@@ -322,6 +326,7 @@ async function cmdSetup(rest: string[]): Promise<void> {
322
326
  return;
323
327
  }
324
328
  console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
329
+ printTelemetryNotice();
325
330
  console.log("");
326
331
  console.log("Start the host:");
327
332
  console.log(cyan(" uai-host install && uai-host start") + dim(" (first time)"));
@@ -342,6 +347,7 @@ async function cmdPair(token: string | undefined): Promise<void> {
342
347
  upsertEnvLocal("UAI_HOST_TOKEN", token);
343
348
  console.log(green("paired") + dim(` — wrote UAI_HOST_TOKEN to ${envLocalPath()}`));
344
349
  console.log(dim("apply it: uai-host restart (or start the service)"));
350
+ printTelemetryNotice();
345
351
  }
346
352
 
347
353
  function upsertEnvLocal(key: string, value: string): void {
@@ -361,15 +367,41 @@ function upsertEnvLocal(key: string, value: string): void {
361
367
  // --- install dispatch -------------------------------------------------------
362
368
 
363
369
  async function cmdInstall(dryRun: boolean): Promise<void> {
370
+ // Disclosure BEFORE activation (review): install starts the service, and
371
+ // the notice must precede the first telemetry-armed run.
372
+ if (!dryRun) printTelemetryNotice();
364
373
  const installer = await loadInstaller();
365
374
  await installer.install(installContext(dryRun));
366
- if (!dryRun) console.log(green("installed") + dim(" — start it: uai-host start"));
375
+ if (!dryRun) {
376
+ console.log(green("installed") + dim(" — start it: uai-host start"));
377
+ }
378
+ }
379
+
380
+ /**
381
+ * ADR-071 default-on disclosure at the surfaces an operator actually SEES:
382
+ * the service's own start line only reaches host.out.log/journald, so the
383
+ * interactive commands (install/pair/setup/start/restart) print it — and
384
+ * PERSIST the shown-once marker that gates the baked default (a silently
385
+ * upgraded service stays off until one of these runs).
386
+ */
387
+ function printTelemetryNotice(): void {
388
+ const notice = telemetryNoticeIfActive();
389
+ if (notice) {
390
+ console.log(yellow("telemetry: ") + notice);
391
+ persistTelemetryDisclosure();
392
+ }
367
393
  }
368
394
 
369
395
  async function cmdInstaller(
370
396
  action: "uninstall" | "start" | "stop" | "restart",
371
397
  dryRun: boolean,
372
398
  ): Promise<void> {
399
+ // Upgrade path (review finding): operators of EXISTING installs meet
400
+ // default-on telemetry through start/restart — disclose BEFORE the
401
+ // service action activates anything, and persist the gate marker.
402
+ if (!dryRun && (action === "start" || action === "restart")) {
403
+ printTelemetryNotice();
404
+ }
373
405
  const installer = await loadInstaller();
374
406
  await installer[action](dryRun);
375
407
  if (!dryRun && action === "uninstall") console.log(green("uninstalled"));
package/src/load-env.ts CHANGED
@@ -3,16 +3,37 @@
3
3
  * read environment variables.
4
4
  */
5
5
 
6
- import { existsSync } from "node:fs";
6
+ import { existsSync, realpathSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
- import { dirname, join, resolve } from "node:path";
8
+ import { dirname, join, resolve, sep } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
+ import { config as dotenvConfig } from "dotenv";
12
+
11
13
  const proc = process as unknown as {
12
14
  loadEnvFile?: (path: string) => void;
13
15
  };
14
16
 
15
- const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/src
17
+ // Real path, not the import path: pnpm workspace links reach this file via
18
+ // symlinks — a repo checkout must resolve to the checkout, and an installed
19
+ // tarball to its true node_modules location.
20
+ const here = (() => {
21
+ const raw = dirname(fileURLToPath(import.meta.url)); // <pkg>/src
22
+ try {
23
+ return realpathSync(raw);
24
+ } catch {
25
+ return raw;
26
+ }
27
+ })();
28
+
29
+ // An INSTALLED copy always lives under a node_modules directory. Without
30
+ // this guard, a tarball installed beneath some user's pnpm workspace that
31
+ // happens to contain a sibling host-agent/ dir was mistaken for this source
32
+ // checkout — suppressing telemetry AND selecting the wrong UAI_HOME
33
+ // (review finding). Packaged desktop resources contain no node_modules
34
+ // segment and no workspace above them; the repo checkout contains neither
35
+ // problem.
36
+ const installedCopy = here.split(sep).includes("node_modules");
16
37
 
17
38
  // Resolve the same UAI_HOME as lib/env.ts, but independently: this module runs
18
39
  // FIRST (before lib/env is imported) so it can't depend on it. .env.local is
@@ -28,22 +49,44 @@ function findUp(start: string, pred: (dir: string) => boolean): string | null {
28
49
  }
29
50
  }
30
51
 
31
- const repoRoot = findUp(
32
- here,
33
- (d) =>
34
- existsSync(resolve(d, "pnpm-workspace.yaml")) &&
35
- existsSync(resolve(d, "host-agent")),
36
- );
52
+ const repoRoot = installedCopy
53
+ ? null
54
+ : findUp(
55
+ here,
56
+ (d) =>
57
+ existsSync(resolve(d, "pnpm-workspace.yaml")) &&
58
+ existsSync(resolve(d, "host-agent")),
59
+ );
37
60
 
38
61
  const uaiHome = process.env.UAI_HOME
39
62
  ? resolve(process.env.UAI_HOME.replace(/^~(?=\/|$)/, homedir()))
40
63
  : (repoRoot ?? resolve(homedir(), ".uai"));
41
64
 
65
+ // ADR-071: expose the repo-checkout detection computed above to the
66
+ // telemetry gate (lib/obs resolveHostDsn). `pnpm host-agent` from a fresh
67
+ // checkout and host-desktop's non-packaged dev launch set neither NODE_ENV
68
+ // nor UAI_AGENT_FROM_REPO — this marker is the signal tied to the REAL
69
+ // launch path, so dev runs never default-activate crash reporting. Packaged
70
+ // installs (npm tarball, desktop resources) have no workspace file above
71
+ // them and stay unmarked.
72
+ if (repoRoot && !process.env.UAI_LAUNCHED_FROM_REPO) {
73
+ process.env.UAI_LAUNCHED_FROM_REPO = "1";
74
+ }
75
+
42
76
  for (const file of [".env.local", ".env"]) {
43
77
  const path = join(uaiHome, file);
44
- if (!proc.loadEnvFile || !existsSync(path)) continue;
78
+ if (!existsSync(path)) continue;
45
79
  try {
46
- proc.loadEnvFile(path);
80
+ if (proc.loadEnvFile) {
81
+ proc.loadEnvFile(path);
82
+ } else {
83
+ // process.loadEnvFile landed in Node 20.12; the package supports
84
+ // >=20. Silently skipping .env.local on 20.0–20.11 would ignore
85
+ // UAI_TELEMETRY_DISABLED — the telemetry kill switch MUST load on
86
+ // every supported Node, so fall back to dotenv (already a dep).
87
+ // Neither loader overrides pre-set process.env — same semantics.
88
+ dotenvConfig({ path });
89
+ }
47
90
  } catch (err) {
48
91
  console.warn(
49
92
  `[host-agent] could not load ${file}: ${(err as Error).message}`,
package/src/main.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  } from "../lib/host-env";
46
46
  import { handleMcpOp } from "../lib/mcp-connections";
47
47
  import { startMcpGateway } from "../lib/mcp-gateway";
48
+ import { addHostBreadcrumb, initHostObs, setHostObsTag } from "../lib/obs";
48
49
  import {
49
50
  packageVersion,
50
51
  serviceLogPath,
@@ -89,6 +90,11 @@ import {
89
90
  FilesOpInput,
90
91
  } from "./protocol";
91
92
 
93
+ // Crash reporting (ADR-071) — default-ON for installed hosts via the baked
94
+ // DSN (lib/obs.ts); repo checkouts and dev/test never default-activate.
95
+ // UAI_TELEMETRY_DISABLED=1 turns it off; UAI_SENTRY_DSN overrides.
96
+ initHostObs({ version: packageVersion() });
97
+
92
98
  const PING_INTERVAL_MS = 15_000;
93
99
  const DEAD_AFTER_MS = 45_000;
94
100
  // Reconnect backoff, capped low (~15s) on purpose: cloud deploys drop the
@@ -268,6 +274,8 @@ function connect(): void {
268
274
  reconnectAttempt = 0;
269
275
  markConnected();
270
276
  console.log(`[host-agent] connected as ${hostId}`);
277
+ setHostObsTag(hostId);
278
+ addHostBreadcrumb("bridge", "connected");
271
279
 
272
280
  unsubscribe = hostEvents.subscribe((event) => {
273
281
  if (socket.readyState !== WebSocket.OPEN) return;
@@ -446,6 +454,11 @@ function connect(): void {
446
454
  console.warn(
447
455
  `[host-agent] disconnected${ready ? "" : " before auth"}; reconnecting in ${Math.round(delay)}ms`,
448
456
  );
457
+ addHostBreadcrumb("bridge", "disconnected", {
458
+ code,
459
+ authed: ready,
460
+ retryInMs: Math.round(delay),
461
+ });
449
462
  setTimeout(connect, delay);
450
463
  });
451
464
  }