@runuai/host 0.8.12 → 0.8.16

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/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 {
@@ -184,8 +190,17 @@ function defaultSeams(): EngineSeams {
184
190
  return {
185
191
  spawn: (command, args, spawnEnv) =>
186
192
  nodeSpawn(command, args, {
187
- // stdin stays open (pipe): the token flow answers "Press Enter" gates.
188
- stdio: ["pipe", "pipe", "pipe"],
193
+ // macOS: stdin MUST be /dev/null ("ignore"), never a node pipe. Node's
194
+ // child pipes are SOCKETPAIRS, and Apple's BSD script(1) hard-exits on
195
+ // a socket stdin ("tcgetattr/ioctl: Operation not supported on
196
+ // socket") while tolerating /dev/null (ENOTTY → notty mode). The
197
+ // "Press Enter" auto-ack is therefore Linux-only; the real token flow
198
+ // needs no input before the token renders (verified empirically).
199
+ stdio: [
200
+ process.platform === "darwin" ? "ignore" : "pipe",
201
+ "pipe",
202
+ "pipe",
203
+ ],
189
204
  env: spawnEnv,
190
205
  }),
191
206
  envLocalPath: () => join(env.uaiHome, ".env.local"),
@@ -231,18 +246,43 @@ function defaultPtyWrap(bin: string, args: string[]): PtyCommand | null {
231
246
  }
232
247
 
233
248
  /**
234
- * 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
235
276
  * `echo $PATH`) so the value is the real colon-joined exported PATH under any
236
- * shell — fish's `$PATH` is a list and would echo space-joined — and takes the
237
- * LAST `PATH=` line so profile echo noise can't spoof it. Null on any failure;
238
- * 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.
239
279
  */
240
- function probeLoginShellPath(): Promise<string | null> {
280
+ function shellPath(flags: string): Promise<string | null> {
241
281
  return new Promise((resolve) => {
242
282
  const shell = process.env.SHELL?.trim() || "/bin/zsh";
243
283
  nodeExecFile(
244
284
  shell,
245
- ["-lc", "/usr/bin/env"],
285
+ [flags, "/usr/bin/env"],
246
286
  { timeout: 8_000 },
247
287
  (err, stdout) => {
248
288
  if (err) return resolve(null);
@@ -276,10 +316,22 @@ export function engineCatalog(): EngineCatalogEntry[] {
276
316
  getKeyUrl: d.getKeyUrl ?? null,
277
317
  apiKeyHint: d.apiKeyHint ?? null,
278
318
  apiKeyUrl: d.apiKeyUrl ?? null,
319
+ installHint:
320
+ kind === "codex" ? CODEX_INSTALL_HINT : (INSTALLERS[kind] ?? null),
279
321
  };
280
322
  });
281
323
  }
282
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
+
283
335
  /** kind → connected, for every engine. */
284
336
  export function engineStatuses(
285
337
  seams: Partial<EngineSeams> = {},
@@ -465,6 +517,209 @@ export function disconnectEngine(
465
517
  }
466
518
  }
467
519
 
520
+ // ---------------------------------------------------------------------------
521
+ // Install a missing engine CLI (ADR-072).
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
+
468
723
  /**
469
724
  * Flatten a PTY byte stream into plain text lines. Strips OSC sequences
470
725
  * (incl. OSC-8 hyperlink wrappers, keeping their visible text), CSI
@@ -562,14 +817,21 @@ function configPath(kind: EngineKind, s: EngineSeams): string {
562
817
  */
563
818
  function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
564
819
  const home = s.ownerHome();
565
- const common = [join(home, ".local", "bin"), ...s.systemBinDirs];
820
+ const common = [
821
+ join(home, ".local", "bin"),
822
+ ...nodeManagerBinDirs(home),
823
+ ...s.systemBinDirs,
824
+ ];
566
825
  switch (kind) {
567
826
  case "claude":
568
827
  // Native installer symlinks ~/.local/bin/claude (preferred); the older
569
828
  // "local install" migration keeps a wrapper at ~/.claude/local/claude.
570
- return [join(home, ".local", "bin"), join(home, ".claude", "local"), ...s.systemBinDirs].map(
571
- (d) => join(d, "claude"),
572
- );
829
+ return [
830
+ join(home, ".local", "bin"),
831
+ join(home, ".claude", "local"),
832
+ ...nodeManagerBinDirs(home),
833
+ ...s.systemBinDirs,
834
+ ].map((d) => join(d, "claude"));
573
835
  case "codex":
574
836
  return common.map((d) => join(d, "codex"));
575
837
  case "kimi":
@@ -583,6 +845,50 @@ function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
583
845
  }
584
846
  }
585
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
+
586
892
  interface ResolvedBin {
587
893
  /** Command to spawn — absolute when found, else the bare name. */
588
894
  bin: string;
@@ -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.12",
3
+ "version": "0.8.16",
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>",
@@ -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
package/src/main.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  markReconnecting,
24
24
  } from "../lib/cloud-state";
25
25
  import { getHostTask } from "../lib/runtime-state";
26
- import { getOrchestrator } from "../lib/orchestrator";
26
+ import { getOrchestrator, recoveryComplete } from "../lib/orchestrator";
27
27
  import {
28
28
  connectedUserIds,
29
29
  isTransientGithubError,
@@ -159,7 +159,13 @@ void ensureStandardImage();
159
159
  // token → `codex login`) otherwise reaches only NEW tasks — re-copy into
160
160
  // running tasks on start (covers the desktop "Connect Codex", which restarts
161
161
  // the host) and watch ~/.codex for future logins (a terminal `codex login`).
162
- void reinjectCodexRunningTasks();
162
+ // Sequenced AFTER boot recovery: reinjecting while recovery was still
163
+ // `docker start`ing exited containers raced — `docker cp` into a stopped
164
+ // container succeeds but the ownership-fix exec can't run, stranding 0600
165
+ // files owned by the host uid (macOS 501) that the container's node (1000)
166
+ // can't read, and Codex died at startup. The post-recovery sweep also
167
+ // self-heals such containers: it re-copies and chowns every running task.
168
+ void recoveryComplete().then(() => reinjectCodexRunningTasks());
163
169
  watchCodexAuth();
164
170
  connect();
165
171
  // Local browser UI (ADR-028) — same single process, alongside the WSS client.
package/src/ui/server.ts CHANGED
@@ -30,6 +30,8 @@ import { dockerCli } from "../../lib/docker-exec";
30
30
  import {
31
31
  connectEngine,
32
32
  disconnectEngine,
33
+ engineCliStatuses,
34
+ installEngineCli,
33
35
  engineCatalog,
34
36
  engineStatuses,
35
37
  isEngineKind,
@@ -151,6 +153,8 @@ async function handle(
151
153
  switch (path) {
152
154
  case "/api/engines/connect":
153
155
  return await handleEngineConnect(req, res, opts);
156
+ case "/api/engines/install":
157
+ return await handleEngineInstall(req, res);
154
158
  case "/api/engines/disconnect":
155
159
  return await handleEngineDisconnect(req, res, opts);
156
160
  case "/api/tasks/stop":
@@ -181,7 +185,7 @@ async function handle(
181
185
  case "/api/users":
182
186
  return sendJson(res, UsersResponse, usersBody(opts));
183
187
  case "/api/engines":
184
- return sendJson(res, EnginesResponse, enginesBody());
188
+ return sendJson(res, EnginesResponse, await enginesBody());
185
189
  }
186
190
  if (path.startsWith("/api/")) {
187
191
  return sendError(res, 404, `no such endpoint: ${path}`);
@@ -194,8 +198,47 @@ async function handle(
194
198
 
195
199
  // --- engines ----------------------------------------------------------------
196
200
 
197
- function enginesBody(): EnginesResponse {
198
- return { catalog: engineCatalog(), statuses: engineStatuses() };
201
+ async function enginesBody(): Promise<EnginesResponse> {
202
+ return {
203
+ catalog: engineCatalog(),
204
+ statuses: engineStatuses(),
205
+ cli: await engineCliStatuses(),
206
+ };
207
+ }
208
+
209
+ /**
210
+ * POST /api/engines/install `{kind}` — run the engine's pinned installer,
211
+ * streaming NDJSON like connect: `{"line":…}` frames, then `{done,ok,message}`.
212
+ * No re-advertise or image rebuild here: installing a CLI connects nothing;
213
+ * that happens on the connect that follows.
214
+ */
215
+ async function handleEngineInstall(
216
+ req: IncomingMessage,
217
+ res: ServerResponse,
218
+ ): Promise<void> {
219
+ const body = await readJsonBody(req);
220
+ const kind = body?.kind;
221
+ if (!isEngineKind(kind)) {
222
+ return sendError(res, 400, "unknown or missing engine kind");
223
+ }
224
+ res.writeHead(200, {
225
+ "content-type": "application/x-ndjson; charset=utf-8",
226
+ "cache-control": "no-store",
227
+ });
228
+ const emit = (obj: unknown): void => {
229
+ res.write(`${JSON.stringify(obj)}\n`);
230
+ };
231
+ let result: { ok: boolean; message: string };
232
+ try {
233
+ result = await installEngineCli(kind, (line) => emit({ line }));
234
+ } catch (err) {
235
+ result = {
236
+ ok: false,
237
+ message: err instanceof Error ? err.message : "install failed",
238
+ };
239
+ }
240
+ emit({ done: true, ok: result.ok, message: result.message });
241
+ res.end();
199
242
  }
200
243
 
201
244
  /**
package/src/ui/types.ts CHANGED
@@ -84,12 +84,15 @@ export const EngineCatalogEntry = z.object({
84
84
  // Pasted-API-key alternative to the login/token flow (null = not offered).
85
85
  apiKeyHint: z.string().nullable(),
86
86
  apiKeyUrl: z.string().nullable(),
87
+ // Install command shown when the CLI is missing (null = nothing to install).
88
+ installHint: z.string().nullable(),
87
89
  });
88
90
  export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
89
91
 
90
92
  export const EnginesResponse = z.object({
91
93
  catalog: z.array(EngineCatalogEntry),
92
94
  statuses: z.record(z.boolean()), // kind → connected
95
+ cli: z.record(z.boolean()), // kind → CLI resolvable on this host
93
96
  });
94
97
  export type EnginesResponse = z.infer<typeof EnginesResponse>;
95
98
 
package/ui/app.js CHANGED
@@ -483,6 +483,11 @@ function commandForm(e) {
483
483
  log.className = "log";
484
484
  log.hidden = true;
485
485
 
486
+ const pushLine = (line) => {
487
+ log.textContent += (log.textContent ? "\n" : "") + line;
488
+ log.scrollTop = log.scrollHeight;
489
+ };
490
+
486
491
  const actions = document.createElement("div");
487
492
  actions.className = "setup-actions";
488
493
  const connect = document.createElement("button");
@@ -499,10 +504,7 @@ function commandForm(e) {
499
504
  : "Your browser will open to sign in…";
500
505
  log.hidden = false;
501
506
  log.textContent = "";
502
- const result = await runConnect({ kind: e.kind }, (line) => {
503
- log.textContent += (log.textContent ? "\n" : "") + line;
504
- log.scrollTop = log.scrollHeight;
505
- });
507
+ const result = await runConnect({ kind: e.kind }, pushLine);
506
508
  if (result.ok) {
507
509
  await poll();
508
510
  closeModal();
@@ -514,6 +516,47 @@ function commandForm(e) {
514
516
  }
515
517
  });
516
518
  actions.append(connect);
519
+
520
+ // CLI missing → offer the pinned installer first, chaining into connect on
521
+ // success. The command is shown verbatim; its output streams into the log.
522
+ const cliMissing =
523
+ !!latestEngines?.cli && latestEngines.cli[e.kind] === false && !!e.installHint;
524
+ if (cliMissing) {
525
+ connect.hidden = true;
526
+ const installNote = document.createElement("p");
527
+ installNote.className = "setup-note";
528
+ installNote.textContent = `The ${e.label} CLI isn't installed on this Mac. Install it with:`;
529
+ const installCmd = document.createElement("pre");
530
+ installCmd.className = "log";
531
+ installCmd.textContent = e.installHint;
532
+ const install = document.createElement("button");
533
+ install.className = "btn";
534
+ install.type = "button";
535
+ install.textContent = "Install & connect";
536
+ install.addEventListener("click", async () => {
537
+ install.disabled = true;
538
+ install.textContent = "Installing…";
539
+ status.className = "setup-status";
540
+ status.textContent = "Running the installer…";
541
+ log.hidden = false;
542
+ log.textContent = "";
543
+ const result = await runInstall({ kind: e.kind }, pushLine);
544
+ if (result.ok) {
545
+ await poll();
546
+ install.hidden = true;
547
+ connect.hidden = false;
548
+ connect.click();
549
+ } else {
550
+ install.disabled = false;
551
+ install.textContent = "Try again";
552
+ status.className = "setup-status err";
553
+ status.textContent = result.message || "Install failed.";
554
+ }
555
+ });
556
+ actions.prepend(install);
557
+ form.append(installNote, installCmd);
558
+ }
559
+
517
560
  form.append(actions, status, log);
518
561
 
519
562
  // Manual paste fallback for when the browser flow isn't possible: Claude
@@ -590,14 +633,24 @@ function commandForm(e) {
590
633
  return form;
591
634
  }
592
635
 
636
+ /** POST /api/engines/connect and consume the NDJSON stream. */
637
+ function runConnect(body, onLine) {
638
+ return runStream("/api/engines/connect", body, onLine);
639
+ }
640
+
641
+ /** POST /api/engines/install and consume the NDJSON stream. */
642
+ function runInstall(body, onLine) {
643
+ return runStream("/api/engines/install", body, onLine);
644
+ }
645
+
593
646
  /**
594
- * POST /api/engines/connect and consume the NDJSON stream: `{line}` frames feed
595
- * onLine; the final `{done,ok,message}` frame is the result.
647
+ * POST an NDJSON-streaming endpoint: `{line}` frames feed onLine; the final
648
+ * `{done,ok,message}` frame is the result.
596
649
  */
597
- async function runConnect(body, onLine) {
650
+ async function runStream(path, body, onLine) {
598
651
  let res;
599
652
  try {
600
- res = await fetch("/api/engines/connect", {
653
+ res = await fetch(path, {
601
654
  method: "POST",
602
655
  headers: { "content-type": "application/json" },
603
656
  body: JSON.stringify(body),
@@ -606,7 +659,7 @@ async function runConnect(body, onLine) {
606
659
  return { ok: false, message: String(err) };
607
660
  }
608
661
  if (!res.ok || !res.body) {
609
- return { ok: false, message: `connect failed (${res.status})` };
662
+ return { ok: false, message: `request failed (${res.status})` };
610
663
  }
611
664
  const reader = res.body.getReader();
612
665
  const dec = new TextDecoder();