@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/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/codex-auth.ts CHANGED
@@ -9,12 +9,24 @@
9
9
  * re-login otherwise only helps NEW tasks. This re-copies the fresh `~/.codex`
10
10
  * into the running task containers so live tasks self-heal.
11
11
  *
12
- * Two triggers, covering both re-login paths:
12
+ * Three triggers, covering every path:
13
13
  * - {@link watchCodexAuth} — fs.watch on `~/.codex`, for `codex login` run
14
14
  * from a terminal while the host stays up.
15
15
  * - {@link reinjectCodexRunningTasks} at host start — for the desktop
16
16
  * "Connect Codex" flow, which writes `~/.codex` then restarts the host
17
17
  * (durable sessions reattach to containers that still hold the old creds).
18
+ * Sequenced AFTER boot recovery (main.ts awaits recoveryComplete()).
19
+ * - {@link injectCodexIntoContainer} — recovery calls it right after
20
+ * `docker start` of an exited container, before agents can respawn.
21
+ *
22
+ * Copies go ONLY into containers docker confirms are RUNNING. A DB-active row
23
+ * can be a stopped container (boot, pre-recovery), and `docker cp` into a
24
+ * stopped container SUCCEEDS while the `docker exec … chown` that follows
25
+ * cannot run — stranding auth.json/config.toml mode 0600 owned by the host
26
+ * uid (macOS 501:20), unreadable to the container's `node` (1000:1000), and
27
+ * Codex dies at startup. Every docker step's exit status is checked; failures
28
+ * surface as actionable errors (never credential contents — only file NAMES
29
+ * and docker stderr are logged).
18
30
  *
19
31
  * Codex auth is host-wide (the operator's single `~/.codex`, not per-user), so
20
32
  * this targets ALL running tasks — matching task-up, which copies it into every
@@ -43,8 +55,11 @@ const EXEC_TIMEOUT_MS = 15_000;
43
55
  /** Injectable seams (defaults hit the real DB / docker / fs) so the copy logic
44
56
  * is testable without either. */
45
57
  export interface CodexDeps {
46
- exec?: (args: string[]) => Promise<{ status: number | null; stderr: string }>;
47
- runningContainers?: () => string[];
58
+ exec?: (
59
+ args: string[],
60
+ ) => Promise<{ status: number | null; stdout: string; stderr: string }>;
61
+ /** DB-active task containers — may include stopped ones; docker decides. */
62
+ activeTaskContainers?: () => string[];
48
63
  fileExists?: (path: string) => boolean;
49
64
  }
50
65
 
@@ -54,10 +69,10 @@ function ownerCodexDir(): string {
54
69
 
55
70
  const defaultExec: NonNullable<CodexDeps["exec"]> = async (args) => {
56
71
  const res = await dockerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
57
- return { status: res.status, stderr: res.stderr };
72
+ return { status: res.status, stdout: res.stdout, stderr: res.stderr };
58
73
  };
59
74
 
60
- function defaultRunningContainers(): string[] {
75
+ function defaultActiveTaskContainers(): string[] {
61
76
  return getDb()
62
77
  .select({ taskId: schema.hostTasks.taskId })
63
78
  .from(schema.hostTasks)
@@ -66,50 +81,119 @@ function defaultRunningContainers(): string[] {
66
81
  .map((r) => `task-${r.taskId}-app-1`);
67
82
  }
68
83
 
84
+ /**
85
+ * The containers docker reports as RUNNING right now, or null when docker
86
+ * itself didn't answer — the caller must then skip injection rather than
87
+ * docker-cp into containers of unknown state.
88
+ */
89
+ async function dockerRunningNames(
90
+ exec: NonNullable<CodexDeps["exec"]>,
91
+ ): Promise<Set<string> | null> {
92
+ const res = await exec(["ps", "--filter", "status=running", "--format", "{{.Names}}"]);
93
+ if (res.status !== 0) return null;
94
+ return new Set(
95
+ res.stdout
96
+ .split("\n")
97
+ .map((l) => l.trim())
98
+ .filter(Boolean),
99
+ );
100
+ }
101
+
69
102
  /**
70
103
  * Re-copy `~/.codex/<items>` into one container — mirrors task-up.sh exactly:
71
- * ensure the dir (root), cp each item that exists, chown back to node. Best
72
- * effort; a stopped/removed container makes the exec fail and is caught by the
73
- * caller.
104
+ * ensure the dir (root), cp each item that exists, chown back to node:node
105
+ * (docker cp preserves the HOST uid/gid, and 0600 files owned by macOS
106
+ * 501:20 are unreadable to the container's node). Any nonzero step throws
107
+ * with the step name and docker's stderr — never file contents.
74
108
  */
75
109
  async function copyCodexInto(container: string, deps: CodexDeps): Promise<void> {
76
110
  const exec = deps.exec ?? defaultExec;
77
111
  const exists = deps.fileExists ?? existsSync;
78
112
  const dir = ownerCodexDir();
79
- await exec(["exec", "-u", "root", container, "mkdir", "-p", "/home/node/.codex"]);
113
+ const must = async (args: string[], step: string): Promise<void> => {
114
+ const res = await exec(args);
115
+ if (res.status !== 0) {
116
+ const detail = res.stderr.trim().slice(0, 200);
117
+ throw new Error(
118
+ `${step} failed (${res.status === null ? "docker timeout" : `exit ${res.status}`})` +
119
+ (detail ? `: ${detail}` : ""),
120
+ );
121
+ }
122
+ };
123
+ await must(
124
+ ["exec", "-u", "root", container, "mkdir", "-p", "/home/node/.codex"],
125
+ "mkdir /home/node/.codex",
126
+ );
80
127
  for (const item of CODEX_ITEMS) {
81
128
  const src = join(dir, item);
82
129
  if (!exists(src)) continue;
83
- await exec(["cp", src, `${container}:/home/node/.codex/`]);
130
+ await must(["cp", src, `${container}:/home/node/.codex/`], `docker cp ${item}`);
84
131
  }
85
- await exec([
86
- "exec",
87
- "-u",
88
- "root",
89
- container,
90
- "chown",
91
- "-R",
92
- "node:node",
93
- "/home/node/.codex",
94
- ]);
132
+ await must(
133
+ ["exec", "-u", "root", container, "chown", "-R", "node:node", "/home/node/.codex"],
134
+ "chown -R node:node /home/node/.codex",
135
+ );
95
136
  }
96
137
 
97
138
  /**
98
- * Re-copy the freshly (re)logged-in `~/.codex` into every running task
99
- * container on this host. No-op when there is no `~/.codex/auth.json` (nothing
100
- * to inject) or no running tasks. Best-effort per container.
139
+ * Inject the owner's `~/.codex` into ONE container known to be running.
140
+ * Recovery calls this immediately after `docker start`, BEFORE uai-init and
141
+ * any agent respawn, so a recovered container never runs Codex against
142
+ * host-uid-owned credentials. Returns false after logging an actionable
143
+ * error (no secrets) — recovery of the task itself must not fail over Codex.
144
+ */
145
+ export async function injectCodexIntoContainer(
146
+ container: string,
147
+ deps: CodexDeps = {},
148
+ ): Promise<boolean> {
149
+ const exists = deps.fileExists ?? existsSync;
150
+ if (!exists(join(ownerCodexDir(), "auth.json"))) return true; // nothing to inject
151
+ try {
152
+ await copyCodexInto(container, deps);
153
+ return true;
154
+ } catch (err) {
155
+ console.error(
156
+ `[codex] inject into ${container}: ${err instanceof Error ? err.message : err} — ` +
157
+ `Codex in this task will fail until fixed (run: docker exec -u root ${container} ` +
158
+ `chown -R node:node /home/node/.codex, or restart the host)`,
159
+ );
160
+ return false;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Re-copy the freshly (re)logged-in `~/.codex` into every task container that
166
+ * is both DB-active AND confirmed running by docker. No-op when there is no
167
+ * `~/.codex/auth.json` or no active tasks; skipped entirely (with a warning)
168
+ * when docker doesn't answer. Per-container failures are surfaced and don't
169
+ * stop the sweep.
101
170
  */
102
171
  export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<void> {
103
172
  const exists = deps.fileExists ?? existsSync;
104
173
  if (!exists(join(ownerCodexDir(), "auth.json"))) return;
105
- const containers = (deps.runningContainers ?? defaultRunningContainers)();
106
- if (containers.length === 0) return;
107
- console.log(`[codex] re-copying ~/.codex into ${containers.length} running task(s)`);
108
- for (const container of containers) {
174
+ const active = (deps.activeTaskContainers ?? defaultActiveTaskContainers)();
175
+ if (active.length === 0) return;
176
+ const running = await dockerRunningNames(deps.exec ?? defaultExec);
177
+ if (running === null) {
178
+ console.warn(
179
+ "[codex] reinject skipped — docker didn't answer `docker ps`; will retry on the next trigger",
180
+ );
181
+ return;
182
+ }
183
+ const targets = active.filter((c) => running.has(c));
184
+ const stopped = active.length - targets.length;
185
+ if (stopped > 0) {
186
+ console.log(
187
+ `[codex] ${stopped} active task(s) not running — skipped (recovery injects them at docker start)`,
188
+ );
189
+ }
190
+ if (targets.length === 0) return;
191
+ console.log(`[codex] re-copying ~/.codex into ${targets.length} running task(s)`);
192
+ for (const container of targets) {
109
193
  try {
110
194
  await copyCodexInto(container, deps);
111
195
  } catch (err) {
112
- console.warn(
196
+ console.error(
113
197
  `[codex] reinject into ${container} failed: ${err instanceof Error ? err.message : err}`,
114
198
  );
115
199
  }
package/lib/engines.ts CHANGED
@@ -63,6 +63,7 @@ import {
63
63
  chmodSync,
64
64
  existsSync,
65
65
  mkdirSync,
66
+ readdirSync,
66
67
  readFileSync,
67
68
  rmSync,
68
69
  writeFileSync,
@@ -88,6 +89,11 @@ export interface EngineCatalogEntry {
88
89
  apiKeyHint: string | null;
89
90
  /** Where to mint a key for the pasted-API-key alternative. */
90
91
  apiKeyUrl: string | null;
92
+ /**
93
+ * Human-readable install command the UI shows next to "Install & connect"
94
+ * when the CLI is missing (null = nothing to install, e.g. Cursor).
95
+ */
96
+ installHint: string | null;
91
97
  }
92
98
 
93
99
  interface EngineDescriptor {
@@ -240,18 +246,43 @@ function defaultPtyWrap(bin: string, args: string[]): PtyCommand | null {
240
246
  }
241
247
 
242
248
  /**
243
- * The PATH the owner's login shell exports. Runs `/usr/bin/env` (not
249
+ * The PATH the owner's shell exports. Probes an INTERACTIVE login shell
250
+ * first: nvm/asdf/fnm put their PATH setup in `.zshrc`/`.bashrc`, which a
251
+ * plain login shell (`-lc`) never sources — that gap hid `npm i -g` installs
252
+ * from the probe. Falls back to `-lc` for profiles that misbehave when run
253
+ * interactively without a terminal. Null on failure of both; callers fall
254
+ * back to the inherited PATH.
255
+ *
256
+ * Cached (module-level, 5 min): the CLI-found statuses probe on every
257
+ * /api/engines poll and shelling out each time would be rude. Staleness is
258
+ * harmless — every installer this module runs lands in a candidate dir,
259
+ * which is checked before the probe.
260
+ */
261
+ async function probeLoginShellPath(): Promise<string | null> {
262
+ const now = Date.now();
263
+ if (loginPathCache && now - loginPathCache.at < LOGIN_PATH_CACHE_MS) {
264
+ return loginPathCache.value;
265
+ }
266
+ const value = (await shellPath("-lic")) ?? (await shellPath("-lc"));
267
+ loginPathCache = { value, at: now };
268
+ return value;
269
+ }
270
+
271
+ let loginPathCache: { value: string | null; at: number } | null = null;
272
+ const LOGIN_PATH_CACHE_MS = 5 * 60_000;
273
+
274
+ /**
275
+ * Run `$SHELL <flags> /usr/bin/env` and pull PATH out. `/usr/bin/env` (not
244
276
  * `echo $PATH`) so the value is the real colon-joined exported PATH under any
245
- * shell — fish's `$PATH` is a list and would echo space-joined — and takes the
246
- * LAST `PATH=` line so profile echo noise can't spoof it. Null on any failure;
247
- * callers fall back to the inherited PATH.
277
+ * shell — fish's `$PATH` is a list and would echo space-joined — and the
278
+ * LAST `PATH=` line wins so profile echo noise can't spoof it.
248
279
  */
249
- function probeLoginShellPath(): Promise<string | null> {
280
+ function shellPath(flags: string): Promise<string | null> {
250
281
  return new Promise((resolve) => {
251
282
  const shell = process.env.SHELL?.trim() || "/bin/zsh";
252
283
  nodeExecFile(
253
284
  shell,
254
- ["-lc", "/usr/bin/env"],
285
+ [flags, "/usr/bin/env"],
255
286
  { timeout: 8_000 },
256
287
  (err, stdout) => {
257
288
  if (err) return resolve(null);
@@ -285,10 +316,22 @@ export function engineCatalog(): EngineCatalogEntry[] {
285
316
  getKeyUrl: d.getKeyUrl ?? null,
286
317
  apiKeyHint: d.apiKeyHint ?? null,
287
318
  apiKeyUrl: d.apiKeyUrl ?? null,
319
+ installHint:
320
+ kind === "codex" ? CODEX_INSTALL_HINT : (INSTALLERS[kind] ?? null),
288
321
  };
289
322
  });
290
323
  }
291
324
 
325
+ /** kind → whether the engine's CLI is resolvable on this host right now. */
326
+ export async function engineCliStatuses(
327
+ seams: Partial<EngineSeams> = {},
328
+ ): Promise<Record<EngineKind, boolean>> {
329
+ const s = withDefaults(seams);
330
+ const out = {} as Record<EngineKind, boolean>;
331
+ for (const kind of ORDER) out[kind] = await engineCliFound(kind, s);
332
+ return out;
333
+ }
334
+
292
335
  /** kind → connected, for every engine. */
293
336
  export function engineStatuses(
294
337
  seams: Partial<EngineSeams> = {},
@@ -474,6 +517,209 @@ export function disconnectEngine(
474
517
  }
475
518
  }
476
519
 
520
+ // ---------------------------------------------------------------------------
521
+ // Install a missing engine CLI (ADR-074).
522
+ // ---------------------------------------------------------------------------
523
+
524
+ /**
525
+ * Pinned installer one-liners — the same vendor installers the standard task
526
+ * image already trusts (host-agent/images/standard/Dockerfile), shown
527
+ * VERBATIM in the UI before running. Every one lands its binary in a
528
+ * candidateBins dir, so the next resolve finds it with no restart:
529
+ * claude → ~/.local/bin, kimi → ~/.kimi-code/bin, grok → ~/.grok/bin.
530
+ * Codex has no vendor script — resolved at run time (brew, then npm).
531
+ */
532
+ const INSTALLERS: Partial<Record<EngineKind, string>> = {
533
+ claude: "curl -fsSL https://claude.ai/install.sh | bash",
534
+ kimi: "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
535
+ grok: "curl -fsSL https://x.ai/cli/install.sh | bash",
536
+ };
537
+
538
+ const CODEX_INSTALL_HINT = "brew install codex — or: npm i -g @openai/codex";
539
+
540
+ /** Installers can compile/download; give them real time. */
541
+ const INSTALL_TIMEOUT_MS = 15 * 60_000;
542
+
543
+ export interface InstallResult {
544
+ ok: boolean;
545
+ message: string;
546
+ }
547
+
548
+ /**
549
+ * Install an engine's CLI with its pinned installer, streaming output to
550
+ * `onLog`. Refuses when there's nothing to install (api-key engines) and
551
+ * short-circuits when the CLI is already resolvable. Success = the installer
552
+ * exits 0 AND the CLI resolves afterwards.
553
+ */
554
+ export async function installEngineCli(
555
+ kind: EngineKind,
556
+ onLog: (line: string) => void,
557
+ seams: Partial<EngineSeams> = {},
558
+ ): Promise<InstallResult> {
559
+ const s = withDefaults(seams);
560
+ const d = DESCRIPTORS[kind];
561
+ if (d.authMode === "api-key") {
562
+ return {
563
+ ok: false,
564
+ message: `${d.label} doesn't use a CLI on the host — paste an API key instead.`,
565
+ };
566
+ }
567
+ if (await engineCliFound(kind, s)) {
568
+ return { ok: true, message: `${d.label} CLI is already installed.` };
569
+ }
570
+ const inv = await resolveInstaller(kind, s);
571
+ if (!inv) {
572
+ return {
573
+ ok: false,
574
+ message:
575
+ "No way to install Codex automatically: install Homebrew or Node.js first, then `brew install codex` or `npm i -g @openai/codex`.",
576
+ };
577
+ }
578
+ let child: ChildProcess;
579
+ try {
580
+ child = s.spawn(inv.command, inv.args, {
581
+ ...s.procEnv,
582
+ ...(inv.path ? { PATH: inv.path } : {}),
583
+ });
584
+ } catch (err) {
585
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
586
+ }
587
+ return new Promise((resolve) => {
588
+ let settled = false;
589
+ let timer: NodeJS.Timeout | undefined;
590
+ const done = (r: InstallResult, kill = false): void => {
591
+ if (settled) return;
592
+ settled = true;
593
+ clearTimeout(timer);
594
+ if (kill) {
595
+ try {
596
+ child.kill();
597
+ } catch {
598
+ /* already gone */
599
+ }
600
+ }
601
+ resolve(r);
602
+ };
603
+ const relay = createLineRelay(onLog);
604
+ child.stdout?.on("data", relay);
605
+ child.stderr?.on("data", relay);
606
+ child.on("error", (err: NodeJS.ErrnoException) =>
607
+ done({ ok: false, message: err.message }),
608
+ );
609
+ child.on("exit", (code) => {
610
+ void (async () => {
611
+ if (code !== 0) {
612
+ done({
613
+ ok: false,
614
+ message: `Install failed (exit ${code ?? "?"}) — see the log above.`,
615
+ });
616
+ return;
617
+ }
618
+ done(
619
+ (await engineCliFound(kind, s))
620
+ ? { ok: true, message: `${d.label} CLI installed.` }
621
+ : {
622
+ ok: false,
623
+ message:
624
+ "The installer finished but the CLI still isn't findable — install it manually, then try Connect.",
625
+ },
626
+ );
627
+ })();
628
+ });
629
+ timer = setTimeout(
630
+ () =>
631
+ done(
632
+ { ok: false, message: "The installer timed out — see the log above." },
633
+ true,
634
+ ),
635
+ INSTALL_TIMEOUT_MS,
636
+ );
637
+ });
638
+ }
639
+
640
+ interface InstallInvocation {
641
+ command: string;
642
+ args: string[];
643
+ /** Child PATH override (npm needs its own dir leading so node resolves). */
644
+ path?: string;
645
+ }
646
+
647
+ /** Vendor script via sh for most engines; brew-then-npm for Codex. */
648
+ async function resolveInstaller(
649
+ kind: EngineKind,
650
+ s: EngineSeams,
651
+ ): Promise<InstallInvocation | null> {
652
+ if (kind !== "codex") {
653
+ const script = INSTALLERS[kind];
654
+ return script ? { command: "/bin/sh", args: ["-c", script] } : null;
655
+ }
656
+ const brew = s.systemBinDirs.map((dir) => join(dir, "brew")).find(existsSync);
657
+ if (brew) return { command: brew, args: ["install", "codex"] };
658
+ const npm = await findBinNamed("npm", s);
659
+ if (npm) {
660
+ return {
661
+ command: npm,
662
+ args: ["install", "-g", "@openai/codex"],
663
+ path: prependPath([dirname(npm)], s.procEnv.PATH ?? ""),
664
+ };
665
+ }
666
+ return null;
667
+ }
668
+
669
+ /** Find an arbitrary tool the same way engine CLIs are found. */
670
+ async function findBinNamed(
671
+ name: string,
672
+ s: EngineSeams,
673
+ ): Promise<string | null> {
674
+ const home = s.ownerHome();
675
+ const dirs = [
676
+ join(home, ".local", "bin"),
677
+ ...nodeManagerBinDirs(home),
678
+ ...s.systemBinDirs,
679
+ ];
680
+ for (const dir of dirs) {
681
+ const file = join(dir, name);
682
+ if (existsSync(file)) return file;
683
+ }
684
+ const login = await s.loginShellPath();
685
+ const dir = login?.split(":").find((p) => p && existsSync(join(p, name)));
686
+ return dir ? join(dir, name) : null;
687
+ }
688
+
689
+ /** Whether an engine's CLI resolves right now (candidates, then login PATH). */
690
+ async function engineCliFound(
691
+ kind: EngineKind,
692
+ s: EngineSeams,
693
+ ): Promise<boolean> {
694
+ if (DESCRIPTORS[kind].authMode === "api-key") return true; // never spawned
695
+ if (candidateBins(kind, s).some(existsSync)) return true;
696
+ const login = await s.loginShellPath();
697
+ return !!login
698
+ ?.split(":")
699
+ .some((dir) => dir.length > 0 && existsSync(join(dir, kind)));
700
+ }
701
+
702
+ /**
703
+ * Accumulate a child's output and forward each distinct, meaningful,
704
+ * COMPLETE line to `onLog` once — same discipline as the token flow's
705
+ * relay, for plain (non-PTY) installer output with \r progress noise.
706
+ */
707
+ function createLineRelay(onLog: (line: string) => void): (b: Buffer) => void {
708
+ let raw = "";
709
+ const forwarded = new Set<string>();
710
+ return (b) => {
711
+ raw += b.toString("utf8");
712
+ if (raw.length > 262_144) raw = raw.slice(-131_072);
713
+ const text = sanitizeTerminalOutput(raw);
714
+ for (const line of significantLines(text.slice(0, text.lastIndexOf("\n") + 1))) {
715
+ if (!forwarded.has(line)) {
716
+ forwarded.add(line);
717
+ onLog(line);
718
+ }
719
+ }
720
+ };
721
+ }
722
+
477
723
  /**
478
724
  * Flatten a PTY byte stream into plain text lines. Strips OSC sequences
479
725
  * (incl. OSC-8 hyperlink wrappers, keeping their visible text), CSI
@@ -571,14 +817,21 @@ function configPath(kind: EngineKind, s: EngineSeams): string {
571
817
  */
572
818
  function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
573
819
  const home = s.ownerHome();
574
- const common = [join(home, ".local", "bin"), ...s.systemBinDirs];
820
+ const common = [
821
+ join(home, ".local", "bin"),
822
+ ...nodeManagerBinDirs(home),
823
+ ...s.systemBinDirs,
824
+ ];
575
825
  switch (kind) {
576
826
  case "claude":
577
827
  // Native installer symlinks ~/.local/bin/claude (preferred); the older
578
828
  // "local install" migration keeps a wrapper at ~/.claude/local/claude.
579
- return [join(home, ".local", "bin"), join(home, ".claude", "local"), ...s.systemBinDirs].map(
580
- (d) => join(d, "claude"),
581
- );
829
+ return [
830
+ join(home, ".local", "bin"),
831
+ join(home, ".claude", "local"),
832
+ ...nodeManagerBinDirs(home),
833
+ ...s.systemBinDirs,
834
+ ].map((d) => join(d, "claude"));
582
835
  case "codex":
583
836
  return common.map((d) => join(d, "codex"));
584
837
  case "kimi":
@@ -592,6 +845,50 @@ function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
592
845
  }
593
846
  }
594
847
 
848
+ /**
849
+ * Bin dirs of the common node version managers — `npm i -g` lands CLIs here,
850
+ * and none of these are on a GUI PATH or even in a NON-interactive login
851
+ * shell's PATH (their init lives in .zshrc/.bashrc), which is exactly how an
852
+ * installed `codex` still read "not found". Deterministic dirs beat shell
853
+ * probing: no subprocess, no profile side effects.
854
+ */
855
+ function nodeManagerBinDirs(home: string): string[] {
856
+ const dirs = [
857
+ join(home, ".asdf", "shims"), // shims exec asdf by absolute path
858
+ join(home, ".volta", "bin"),
859
+ join(home, ".bun", "bin"),
860
+ join(home, ".npm-global", "bin"),
861
+ // fnm's stable default-alias bin — the per-shell multishell paths its
862
+ // shell init exports are ephemeral. macOS, XDG, and legacy layouts.
863
+ join(home, "Library", "Application Support", "fnm", "aliases", "default", "bin"),
864
+ join(home, ".local", "share", "fnm", "aliases", "default", "bin"),
865
+ join(home, ".fnm", "aliases", "default", "bin"),
866
+ ];
867
+ // nvm has no stable "current" symlink — enumerate installed versions,
868
+ // newest first (numeric compare: lexicographic would put v9 over v22).
869
+ // A version dir's bin also holds its `node`, so the shebang of whatever
870
+ // we find there resolves against the same dir once it leads PATH.
871
+ try {
872
+ const versions = join(home, ".nvm", "versions", "node");
873
+ dirs.push(
874
+ ...readdirSync(versions)
875
+ .sort(compareVersionDesc)
876
+ .map((v) => join(versions, v, "bin")),
877
+ );
878
+ } catch {
879
+ /* no nvm */
880
+ }
881
+ return dirs;
882
+ }
883
+
884
+ function compareVersionDesc(a: string, b: string): number {
885
+ const parts = (s: string): number[] =>
886
+ s.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
887
+ const [a1 = 0, a2 = 0, a3 = 0] = parts(a);
888
+ const [b1 = 0, b2 = 0, b3 = 0] = parts(b);
889
+ return b1 - a1 || b2 - a2 || b3 - a3;
890
+ }
891
+
595
892
  interface ResolvedBin {
596
893
  /** Command to spawn — absolute when found, else the bare name. */
597
894
  bin: string;
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);