@runuai/host 0.8.10 → 0.8.12

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.
@@ -148,6 +148,11 @@ export class GrokSession implements AgentSession {
148
148
 
149
149
  private buildArgs(prompt: string): string[] {
150
150
  const args = ["exec", "-i", "-u", "node"];
151
+ // Host-resident API key (pasted in the engines panel, ADR-015): forward
152
+ // name-only — docker takes the value from the client env, keeping the
153
+ // secret out of argv. Subscription logins need nothing here (task-up
154
+ // copies ~/.grok into the container instead).
155
+ if (process.env.XAI_API_KEY) args.push("-e", "XAI_API_KEY");
151
156
  for (const [k, v] of Object.entries(this.agentEnv)) {
152
157
  args.push("-e", `${k}=${v}`);
153
158
  }
@@ -249,7 +254,8 @@ register({
249
254
  supportedModels: () => [...GROK_MODELS],
250
255
  defaultModel: GROK_DEFAULT_MODEL,
251
256
  supportedEfforts: () => [...GROK_EFFORTS],
252
- available: () => existsSync(grokAuthPath()),
257
+ available: () =>
258
+ existsSync(grokAuthPath()) || Boolean(process.env.XAI_API_KEY),
253
259
  create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
254
260
  new GrokSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
255
261
  });
package/lib/engines.ts CHANGED
@@ -3,20 +3,49 @@
3
3
  *
4
4
  * A single descriptor table for the AI coding engines the host can run
5
5
  * (Claude, Codex, Kimi, Grok, Cursor) plus the connect/disconnect/status logic
6
- * the local UI drives. This is the host-side twin of the desktop's
7
- * `apps/host-desktop/src/llm.ts`, but it writes the `.env.local` the RUNNING
8
- * launchd/npm host reads (UAI_HOME) and sets `process.env` so a connect takes
9
- * effect WITHOUT a host restart — the adapters' `available()` and task-up's env
10
- * injection both read `process.env`.
6
+ * the local UI drives (the desktop app renders that same UI). It writes the
7
+ * `.env.local` the RUNNING launchd/npm host reads (UAI_HOME) and sets
8
+ * `process.env` so a connect takes effect WITHOUT a host restart — the
9
+ * adapters' `available()` and task-up's env injection both read `process.env`.
10
+ *
11
+ * CLI resolution: a host supervised by the desktop app (or launchd) inherits a
12
+ * minimal GUI PATH that lacks wherever the engine CLIs live (~/.local/bin,
13
+ * npm/nvm/asdf prefixes, Homebrew), so spawning a bare `claude`/`codex` would
14
+ * ENOENT even though the CLI is installed. We resolve binaries the way the
15
+ * owner's terminal would — well-known install dirs first, then the login
16
+ * shell's PATH (same trick as the desktop's docker.ts) — and spawn with that
17
+ * PATH so `#!/usr/bin/env node` shebangs resolve too.
11
18
  *
12
19
  * Three auth modes:
13
- * - token-command (Claude): `claude setup-token` prints a token to stdout;
14
- * we capture it (an `sk-ant-oat…` value) and persist it as
20
+ * - token-command (Claude): `claude setup-token` shows a token; we run the
21
+ * CLI under a PTY (script(1)), capture the `sk-ant-oat…`
22
+ * value live from the terminal stream and persist it as
15
23
  * CLAUDE_CODE_OAUTH_TOKEN. A pasted token is accepted too.
24
+ * The PTY is load-bearing: the CLI's Ink UI buffers ALL
25
+ * output until unmount when stdout isn't a TTY and its
26
+ * final screens wait for a keypress — under plain pipes it
27
+ * emits nothing and never exits (the eternal "Connecting…"
28
+ * of 0.8.11). We resolve as soon as the token appears and
29
+ * kill the CLI rather than wait for it to end on its own.
16
30
  * - login-command (Codex/Kimi/Grok): `<cli> login` runs a browser OAuth and
17
31
  * writes a config file; success = that file appearing.
18
32
  * - api-key (Cursor): no command — persist a pasted CURSOR_API_KEY.
19
33
  *
34
+ * Besides its primary mode, an engine can accept a pasted API key as an
35
+ * alternative (`apiKeyHint`/`apiKeyUrl` in the catalog drive the UI):
36
+ * - claude → ANTHROPIC_API_KEY (the adapter already forwards it; a pasted
37
+ * `sk-ant-oat…` still lands in CLAUDE_CODE_OAUTH_TOKEN — classified by
38
+ * prefix, so either credential works in either paste box).
39
+ * - codex → ~/.codex/auth.json `{"OPENAI_API_KEY": …}`, the same plaintext
40
+ * file `codex login --with-api-key` writes. Written directly (not via the
41
+ * CLI): task-up docker-cp's that FILE into containers, so a keychain-backed
42
+ * CLI login would break task auth, and this works with no host CLI at all.
43
+ * - grok → XAI_API_KEY (documented pay-as-you-go fallback of the grok CLI;
44
+ * the adapter forwards it into task containers).
45
+ * - kimi → none: Kimi Code reads keys only from its config.toml via the
46
+ * interactive `/login`, never from the environment — so we don't offer a
47
+ * paste box we can't honor.
48
+ *
20
49
  * Detection mirrors each adapter's `available()`: env/`.env.local` for
21
50
  * claude/cursor, a config file under the owner home for codex/kimi/grok.
22
51
  *
@@ -25,7 +54,11 @@
25
54
  * the real filesystem or spawning anything.
26
55
  */
27
56
 
28
- import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
57
+ import {
58
+ execFile as nodeExecFile,
59
+ spawn as nodeSpawn,
60
+ type ChildProcess,
61
+ } from "node:child_process";
29
62
  import {
30
63
  chmodSync,
31
64
  existsSync,
@@ -51,6 +84,10 @@ export interface EngineCatalogEntry {
51
84
  notes: string | null;
52
85
  /** Where to mint an API key (api-key mode only). */
53
86
  getKeyUrl: string | null;
87
+ /** Placeholder for the pasted-API-key alternative; null = not supported. */
88
+ apiKeyHint: string | null;
89
+ /** Where to mint a key for the pasted-API-key alternative. */
90
+ apiKeyUrl: string | null;
54
91
  }
55
92
 
56
93
  interface EngineDescriptor {
@@ -58,6 +95,9 @@ interface EngineDescriptor {
58
95
  authMode: EngineAuthMode;
59
96
  notes: string;
60
97
  getKeyUrl?: string;
98
+ /** Pasted-API-key alternative (absent = login/token only, e.g. kimi). */
99
+ apiKeyHint?: string;
100
+ apiKeyUrl?: string;
61
101
  }
62
102
 
63
103
  /** Static descriptor table — the single source of truth for engine metadata. */
@@ -67,11 +107,15 @@ const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
67
107
  authMode: "token-command",
68
108
  notes:
69
109
  "Opens your browser to authorize Claude, then captures the token automatically.",
110
+ apiKeyHint: "sk-ant-…",
111
+ apiKeyUrl: "https://console.anthropic.com/settings/keys",
70
112
  },
71
113
  codex: {
72
114
  label: "Codex",
73
115
  authMode: "login-command",
74
116
  notes: "Opens your browser to sign in to your OpenAI Codex account.",
117
+ apiKeyHint: "sk-…",
118
+ apiKeyUrl: "https://platform.openai.com/api-keys",
75
119
  },
76
120
  kimi: {
77
121
  label: "Kimi Code",
@@ -84,6 +128,8 @@ const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
84
128
  authMode: "login-command",
85
129
  notes:
86
130
  "Sign in with your xAI Grok subscription. Activates after the next image rebuild.",
131
+ apiKeyHint: "xai-…",
132
+ apiKeyUrl: "https://console.x.ai",
87
133
  },
88
134
  cursor: {
89
135
  label: "Cursor",
@@ -101,10 +147,14 @@ const ORDER: EngineKind[] = ["claude", "codex", "kimi", "grok", "cursor"];
101
147
  // ---------------------------------------------------------------------------
102
148
 
103
149
  /** How a login/token command is spawned. Tests provide a fake. */
104
- export type EngineSpawn = (command: string, args: string[]) => ChildProcess;
150
+ export type EngineSpawn = (
151
+ command: string,
152
+ args: string[],
153
+ env: NodeJS.ProcessEnv,
154
+ ) => ChildProcess;
105
155
 
106
156
  export interface EngineSeams {
107
- /** Spawn a CLI, piping stdout/stderr. */
157
+ /** Spawn a CLI, piping stdin/stdout/stderr. */
108
158
  spawn: EngineSpawn;
109
159
  /** Absolute path to the `.env.local` the running host reads (UAI_HOME). */
110
160
  envLocalPath: () => string;
@@ -112,18 +162,100 @@ export interface EngineSeams {
112
162
  ownerHome: () => string;
113
163
  /** The live process env (adapters + task-up read creds from here). */
114
164
  procEnv: NodeJS.ProcessEnv;
165
+ /** PATH as the owner's login shell sees it (null when unprobeable). */
166
+ loginShellPath: () => Promise<string | null>;
167
+ /** System-wide bin dirs probed for engine CLIs (tests pin to []). */
168
+ systemBinDirs: string[];
169
+ /**
170
+ * Wrap a CLI invocation so the child gets a real PTY (script(1)), or null
171
+ * to spawn directly. Tests pin null; token-command needs the wrap (see
172
+ * defaultPtyWrap for why).
173
+ */
174
+ ptyWrap: ((bin: string, args: string[]) => PtyCommand | null) | null;
175
+ }
176
+
177
+ /** A wrapped invocation: what to actually spawn to run a CLI under a PTY. */
178
+ export interface PtyCommand {
179
+ command: string;
180
+ args: string[];
115
181
  }
116
182
 
117
183
  function defaultSeams(): EngineSeams {
118
184
  return {
119
- spawn: (command, args) =>
120
- nodeSpawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }),
185
+ spawn: (command, args, spawnEnv) =>
186
+ nodeSpawn(command, args, {
187
+ // stdin stays open (pipe): the token flow answers "Press Enter" gates.
188
+ stdio: ["pipe", "pipe", "pipe"],
189
+ env: spawnEnv,
190
+ }),
121
191
  envLocalPath: () => join(env.uaiHome, ".env.local"),
122
192
  ownerHome: () => process.env.UAI_OWNER_HOME?.trim() || homedir(),
123
193
  procEnv: process.env,
194
+ loginShellPath: probeLoginShellPath,
195
+ systemBinDirs: ["/opt/homebrew/bin", "/usr/local/bin"],
196
+ ptyWrap: defaultPtyWrap,
124
197
  };
125
198
  }
126
199
 
200
+ /**
201
+ * Lend a CLI a real PTY via script(1) — no native deps, ships with macOS
202
+ * (BSD) and Linux (util-linux; argument conventions differ, hence the
203
+ * branch). Ink-based CLIs (claude) render nothing under plain pipes: Ink
204
+ * detects a non-TTY stdout and buffers every frame until unmount, and the
205
+ * interactive screens can't complete without a terminal — so the child sat
206
+ * silent forever. The inner `stty cols 400` matters too: at the default 80
207
+ * columns the token line WRAPS and a wrapped token extracts truncated.
208
+ */
209
+ function defaultPtyWrap(bin: string, args: string[]): PtyCommand | null {
210
+ if (!existsSync("/usr/bin/script")) return null;
211
+ const stty = "stty cols 400 rows 100 2>/dev/null";
212
+ if (process.platform === "darwin") {
213
+ // BSD script: `script -q /dev/null command [args…]` — command is exec'd
214
+ // as an argv (no shell), so route through sh only to set the width.
215
+ return {
216
+ command: "/usr/bin/script",
217
+ args: ["-q", "/dev/null", "/bin/sh", "-c", `${stty}; exec "$0" "$@"`, bin, ...args],
218
+ };
219
+ }
220
+ if (process.platform === "linux") {
221
+ // util-linux script: the command is a single shell string (-c).
222
+ const quoted = [bin, ...args]
223
+ .map((a) => `'${a.replace(/'/g, `'\\''`)}'`)
224
+ .join(" ");
225
+ return {
226
+ command: "/usr/bin/script",
227
+ args: ["-qec", `${stty}; exec ${quoted}`, "/dev/null"],
228
+ };
229
+ }
230
+ return null;
231
+ }
232
+
233
+ /**
234
+ * The PATH the owner's login shell exports. Runs `/usr/bin/env` (not
235
+ * `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.
239
+ */
240
+ function probeLoginShellPath(): Promise<string | null> {
241
+ return new Promise((resolve) => {
242
+ const shell = process.env.SHELL?.trim() || "/bin/zsh";
243
+ nodeExecFile(
244
+ shell,
245
+ ["-lc", "/usr/bin/env"],
246
+ { timeout: 8_000 },
247
+ (err, stdout) => {
248
+ if (err) return resolve(null);
249
+ const lines = String(stdout)
250
+ .split(/\r?\n/)
251
+ .filter((l) => l.startsWith("PATH="));
252
+ const last = lines[lines.length - 1];
253
+ resolve(last ? last.slice("PATH=".length).trim() || null : null);
254
+ },
255
+ );
256
+ });
257
+ }
258
+
127
259
  function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
128
260
  return { ...defaultSeams(), ...seams };
129
261
  }
@@ -132,7 +264,7 @@ function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
132
264
  // Public catalog / status.
133
265
  // ---------------------------------------------------------------------------
134
266
 
135
- /** The static engine catalog (kind, label, authMode, notes, getKeyUrl). */
267
+ /** The static engine catalog (kind, label, authMode, notes, key URLs). */
136
268
  export function engineCatalog(): EngineCatalogEntry[] {
137
269
  return ORDER.map((kind) => {
138
270
  const d = DESCRIPTORS[kind];
@@ -142,6 +274,8 @@ export function engineCatalog(): EngineCatalogEntry[] {
142
274
  authMode: d.authMode,
143
275
  notes: d.notes,
144
276
  getKeyUrl: d.getKeyUrl ?? null,
277
+ apiKeyHint: d.apiKeyHint ?? null,
278
+ apiKeyUrl: d.apiKeyUrl ?? null,
145
279
  };
146
280
  });
147
281
  }
@@ -183,9 +317,9 @@ export function isEngineKind(value: unknown): value is EngineKind {
183
317
  // ---------------------------------------------------------------------------
184
318
 
185
319
  export interface ConnectOptions {
186
- /** api-key mode: the pasted key (Cursor). */
320
+ /** A pasted API key primary for Cursor, alternative for claude/codex/grok. */
187
321
  apiKey?: string;
188
- /** token-command manual fallback: a pasted token (Claude). */
322
+ /** token-command manual fallback: a pasted token or API key (Claude). */
189
323
  pastedToken?: string;
190
324
  }
191
325
 
@@ -207,21 +341,14 @@ export async function connectEngine(
207
341
  const s = withDefaults(seams);
208
342
  const d = DESCRIPTORS[kind];
209
343
 
210
- if (d.authMode === "api-key") {
211
- const key = (opts.apiKey ?? "").trim();
212
- if (!key) return { ok: false, message: `Paste your ${d.label} API key.` };
213
- if (/\s/.test(key) || key.length < 8) {
214
- return {
215
- ok: false,
216
- message: "That doesn't look like an API key. Paste just the value.",
217
- };
218
- }
219
- upsertEnvLocal("CURSOR_API_KEY", key, s);
220
- return { ok: true, message: `${d.label} connected.` };
344
+ // A pasted API key connects without spawning anything — the only flow for
345
+ // api-key engines (Cursor), an alternative for the others.
346
+ if (d.authMode === "api-key" || opts.apiKey !== undefined) {
347
+ return saveApiKey(kind, d, opts.apiKey ?? "", s);
221
348
  }
222
349
 
223
350
  if (d.authMode === "token-command") {
224
- // Manual paste fallback — accept a token without spawning the CLI.
351
+ // Manual paste fallback — accept a token (or API key) without spawning.
225
352
  if (opts.pastedToken !== undefined) {
226
353
  const token = opts.pastedToken.trim();
227
354
  if (!token) return { ok: false, message: "Paste a token." };
@@ -231,7 +358,15 @@ export async function connectEngine(
231
358
  message: "That doesn't look like a token. Paste just the value.",
232
359
  };
233
360
  }
234
- upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
361
+ // One paste box serves both credential kinds — classify by prefix so an
362
+ // `sk-ant-api…` platform key doesn't get stored as an OAuth token.
363
+ upsertEnvLocal(
364
+ token.startsWith("sk-ant-api")
365
+ ? "ANTHROPIC_API_KEY"
366
+ : "CLAUDE_CODE_OAUTH_TOKEN",
367
+ token,
368
+ s,
369
+ );
235
370
  return { ok: true, message: `${d.label} connected.` };
236
371
  }
237
372
  return runTokenCommand(kind, d.label, onLog, s);
@@ -240,6 +375,70 @@ export async function connectEngine(
240
375
  return runLoginCommand(kind, d.label, onLog, s);
241
376
  }
242
377
 
378
+ /** Persist a pasted API key where the engine's adapter actually reads it. */
379
+ function saveApiKey(
380
+ kind: EngineKind,
381
+ d: EngineDescriptor,
382
+ raw: string,
383
+ s: EngineSeams,
384
+ ): ConnectResult {
385
+ const key = raw.trim();
386
+ if (!key) return { ok: false, message: `Paste your ${d.label} API key.` };
387
+ if (/\s/.test(key) || key.length < 8) {
388
+ return {
389
+ ok: false,
390
+ message: "That doesn't look like an API key. Paste just the value.",
391
+ };
392
+ }
393
+ switch (kind) {
394
+ case "cursor":
395
+ upsertEnvLocal("CURSOR_API_KEY", key, s);
396
+ break;
397
+ case "claude":
398
+ // Accept either credential kind here too (see the paste fallback above).
399
+ upsertEnvLocal(
400
+ key.startsWith("sk-ant-oat") ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY",
401
+ key,
402
+ s,
403
+ );
404
+ break;
405
+ case "grok":
406
+ // The grok CLI's documented pay-as-you-go fallback; the adapter forwards
407
+ // it into task containers.
408
+ upsertEnvLocal("XAI_API_KEY", key, s);
409
+ break;
410
+ case "codex":
411
+ writeCodexAuthJson(key, s);
412
+ break;
413
+ case "kimi":
414
+ return {
415
+ ok: false,
416
+ message:
417
+ "Kimi Code authenticates via subscription sign-in — it doesn't read API keys from the environment.",
418
+ };
419
+ }
420
+ return { ok: true, message: `${d.label} connected.` };
421
+ }
422
+
423
+ /**
424
+ * Write `~/.codex/auth.json` the way `codex login --with-api-key` does. We
425
+ * write the plaintext file directly (not via the CLI) because task-up
426
+ * docker-cp's this exact FILE into containers — a keychain-backed CLI login
427
+ * would leave nothing to copy — and it must work without a host codex CLI.
428
+ */
429
+ function writeCodexAuthJson(key: string, s: EngineSeams): void {
430
+ const file = join(s.ownerHome(), ".codex", "auth.json");
431
+ mkdirSync(dirname(file), { recursive: true });
432
+ writeFileSync(file, `${JSON.stringify({ OPENAI_API_KEY: key }, null, 2)}\n`, {
433
+ mode: 0o600,
434
+ });
435
+ try {
436
+ chmodSync(file, 0o600);
437
+ } catch {
438
+ /* best effort */
439
+ }
440
+ }
441
+
243
442
  /** Disconnect an engine: forget its credential (env line and/or config file). */
244
443
  export function disconnectEngine(
245
444
  kind: EngineKind,
@@ -256,6 +455,8 @@ export function disconnectEngine(
256
455
  removeEnvLocal("ANTHROPIC_AUTH_TOKEN", s);
257
456
  return;
258
457
  }
458
+ // grok can be connected via login (auth.json) OR a pasted key — drop both.
459
+ if (kind === "grok") removeEnvLocal("XAI_API_KEY", s);
259
460
  // login-command engines: remove the config file the adapter detects.
260
461
  try {
261
462
  rmSync(configPath(kind, s), { force: true });
@@ -265,7 +466,32 @@ export function disconnectEngine(
265
466
  }
266
467
 
267
468
  /**
268
- * Extract a Claude OAuth token from `claude setup-token` stdout. The CLI prints
469
+ * Flatten a PTY byte stream into plain text lines. Strips OSC sequences
470
+ * (incl. OSC-8 hyperlink wrappers, keeping their visible text), CSI
471
+ * sequences (cursor-column moves become a space — Ink positions words with
472
+ * `ESC[<n>G` instead of writing spaces), stray ESC singles, and normalizes
473
+ * `\r` to `\n`. Identity on already-plain text apart from whitespace runs.
474
+ */
475
+ export function sanitizeTerminalOutput(raw: string): string {
476
+ return raw
477
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC …BEL / …ST
478
+ .replace(/\x1b\[[0-9;:?<=>]*[!-/]*[@-~]/g, " ") // CSI
479
+ .replace(/\x1b[()][0-9A-B]/g, "") // charset selects
480
+ .replace(/\x1b[78=>DEHM]/g, "") // save/restore cursor etc.
481
+ .replace(/\r\n?/g, "\n")
482
+ .replace(/[^\S\n]+/g, " ");
483
+ }
484
+
485
+ /** Lines worth showing a human: non-blank, not spinner or logo-art glyphs. */
486
+ function significantLines(text: string): string[] {
487
+ return text
488
+ .split("\n")
489
+ .map((l) => l.trim())
490
+ .filter((l) => l.length > 1 && !/^[·✢✳✶✻✽*+.…|/\\\-░▒▓█▄▀\s]+$/u.test(l));
491
+ }
492
+
493
+ /**
494
+ * Extract a Claude OAuth token from `claude setup-token` output. The CLI shows
269
495
  * the token (an `sk-ant-oat…` value) on its own line near the end; scan from the
270
496
  * bottom for it, falling back to a lone long token-charset line.
271
497
  */
@@ -299,9 +525,10 @@ function detect(kind: EngineKind, s: EngineSeams): boolean {
299
525
  );
300
526
  case "cursor":
301
527
  return envOrFileHas("CURSOR_API_KEY", s);
528
+ case "grok":
529
+ return existsSync(configPath(kind, s)) || envOrFileHas("XAI_API_KEY", s);
302
530
  case "codex":
303
531
  case "kimi":
304
- case "grok":
305
532
  return existsSync(configPath(kind, s));
306
533
  }
307
534
  }
@@ -327,48 +554,174 @@ function configPath(kind: EngineKind, s: EngineSeams): string {
327
554
  return join(home, ".grok", "auth.json");
328
555
  }
329
556
 
330
- /** Resolve the login/token CLI binary — absolute for kimi/grok, else on PATH. */
331
- function resolveBin(kind: EngineKind, s: EngineSeams): string {
332
- if (kind === "kimi") {
333
- const local = join(s.ownerHome(), ".kimi-code", "bin", "kimi");
334
- return existsSync(local) ? local : "kimi";
557
+ /**
558
+ * Well-known install locations for an engine CLI — the dirs a GUI-launched
559
+ * host's minimal PATH misses. Home-relative entries resolve against the OWNER
560
+ * home (seam-injectable). The binary name equals the engine kind for all four
561
+ * CLI engines.
562
+ */
563
+ function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
564
+ const home = s.ownerHome();
565
+ const common = [join(home, ".local", "bin"), ...s.systemBinDirs];
566
+ switch (kind) {
567
+ case "claude":
568
+ // Native installer symlinks ~/.local/bin/claude (preferred); the older
569
+ // "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
+ );
573
+ case "codex":
574
+ return common.map((d) => join(d, "codex"));
575
+ case "kimi":
576
+ return [join(home, ".kimi-code", "bin"), ...common].map((d) =>
577
+ join(d, "kimi"),
578
+ );
579
+ case "grok":
580
+ return [join(home, ".grok", "bin"), ...common].map((d) => join(d, "grok"));
581
+ case "cursor":
582
+ return []; // api-key mode — never spawned
335
583
  }
336
- if (kind === "grok") {
337
- const local = join(s.ownerHome(), ".grok", "bin", "grok");
338
- return existsSync(local) ? local : "grok";
584
+ }
585
+
586
+ interface ResolvedBin {
587
+ /** Command to spawn — absolute when found, else the bare name. */
588
+ bin: string;
589
+ /** PATH for the child, augmented so nested lookups (node, git) resolve. */
590
+ path: string;
591
+ }
592
+
593
+ /**
594
+ * Find the engine CLI the way the owner's terminal would: well-known install
595
+ * dirs first (fast, no subprocess), then the login shell's PATH (sources the
596
+ * owner's profile — covers nvm/asdf/npm prefixes), else the bare name against
597
+ * the inherited PATH so a genuinely missing CLI still surfaces the friendly
598
+ * ENOENT "install it first" message.
599
+ */
600
+ async function resolveEngineBin(
601
+ kind: EngineKind,
602
+ s: EngineSeams,
603
+ ): Promise<ResolvedBin> {
604
+ const inherited = s.procEnv.PATH ?? "";
605
+ for (const file of candidateBins(kind, s)) {
606
+ if (existsSync(file)) {
607
+ return { bin: file, path: prependPath([dirname(file)], inherited) };
608
+ }
339
609
  }
340
- // claude / codex are on PATH.
341
- return kind;
610
+ const login = await s.loginShellPath();
611
+ if (login) {
612
+ const dirs = login.split(":").filter(Boolean);
613
+ const dir = dirs.find((d) => existsSync(join(d, kind)));
614
+ // Found or not, hand the login PATH to the child — it is the environment
615
+ // where the CLI (and whatever it shells out to) is known to work.
616
+ return {
617
+ bin: dir ? join(dir, kind) : kind,
618
+ path: prependPath(dirs, inherited),
619
+ };
620
+ }
621
+ return { bin: kind, path: inherited };
622
+ }
623
+
624
+ /** Prepend dirs to a colon-joined PATH, deduped, order-preserving. */
625
+ function prependPath(prepend: string[], current: string): string {
626
+ const seen = new Set<string>();
627
+ return [...prepend, ...current.split(":")]
628
+ .filter((p) => p.length > 0 && !seen.has(p) && (seen.add(p), true))
629
+ .join(":");
342
630
  }
343
631
 
344
- function runTokenCommand(
632
+ /** Resolve the CLI and spawn it (optionally under a PTY) with the augmented PATH. */
633
+ async function spawnEngineCli(
634
+ kind: EngineKind,
635
+ args: string[],
636
+ s: EngineSeams,
637
+ pty = false,
638
+ ): Promise<ChildProcess> {
639
+ const { bin, path } = await resolveEngineBin(kind, s);
640
+ const wrapped = pty && s.ptyWrap ? s.ptyWrap(bin, args) : null;
641
+ const spawnEnv: NodeJS.ProcessEnv = { ...s.procEnv, PATH: path };
642
+ // A GUI-supervised host has no TERM; give the PTY child a sane one.
643
+ if (wrapped && !spawnEnv.TERM) spawnEnv.TERM = "xterm-256color";
644
+ return wrapped
645
+ ? s.spawn(wrapped.command, wrapped.args, spawnEnv)
646
+ : s.spawn(bin, args, spawnEnv);
647
+ }
648
+
649
+ /** Browser authorization is human-paced — give it real time before bailing. */
650
+ const TOKEN_FLOW_TIMEOUT_MS = 10 * 60_000;
651
+
652
+ const TOKEN_FLOW_FALLBACK =
653
+ "Try again, or run `claude setup-token` in your terminal and paste the token here.";
654
+
655
+ async function runTokenCommand(
345
656
  kind: EngineKind,
346
657
  label: string,
347
658
  onLog: (line: string) => void,
348
659
  s: EngineSeams,
349
660
  ): Promise<ConnectResult> {
661
+ let child: ChildProcess;
662
+ try {
663
+ child = await spawnEngineCli(kind, ["setup-token"], s, true);
664
+ } catch (err) {
665
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
666
+ }
350
667
  return new Promise((resolve) => {
351
668
  let settled = false;
352
- const done = (r: ConnectResult): void => {
353
- if (!settled) {
354
- settled = true;
355
- resolve(r);
669
+ let timer: NodeJS.Timeout | undefined;
670
+ const done = (r: ConnectResult, kill = false): void => {
671
+ if (settled) return;
672
+ settled = true;
673
+ clearTimeout(timer);
674
+ if (kill) {
675
+ try {
676
+ child.kill();
677
+ } catch {
678
+ /* already gone */
679
+ }
356
680
  }
681
+ resolve(r);
357
682
  };
358
- let child: ChildProcess;
359
- try {
360
- child = s.spawn(resolveBin(kind, s), ["setup-token"]);
361
- } catch (err) {
362
- done({ ok: false, message: err instanceof Error ? err.message : String(err) });
363
- return;
364
- }
365
- let stdout = "";
366
- child.stdout?.on("data", (b: Buffer) => {
367
- const text = b.toString("utf8");
368
- stdout += text;
369
- relay(text, onLog);
370
- });
371
- child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
683
+ let raw = "";
684
+ const forwarded = new Set<string>();
685
+ const acked = new Set<string>();
686
+ const saveToken = (token: string): void => {
687
+ upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
688
+ done({ ok: true, message: `${label} connected.` }, true);
689
+ };
690
+ const onChunk = (b: Buffer): void => {
691
+ raw += b.toString("utf8");
692
+ // A pending spinner redraws for minutes — keep only the tail (the token
693
+ // and final screens are at the end) so re-sanitizing stays cheap.
694
+ if (raw.length > 262_144) raw = raw.slice(-131_072);
695
+ const text = sanitizeTerminalOutput(raw);
696
+ // Act only on COMPLETE lines: a trailing partial may still be missing
697
+ // the rest of an escape sequence — or the rest of the TOKEN, and the
698
+ // extractor would happily return a truncated prefix.
699
+ const complete = text.slice(0, text.lastIndexOf("\n") + 1);
700
+ for (const line of significantLines(complete)) {
701
+ // Each distinct line once — Ink redraws the same screen constantly
702
+ // and would flood the UI log.
703
+ if (!forwarded.has(line)) {
704
+ forwarded.add(line);
705
+ onLog(line);
706
+ }
707
+ // Auto-acknowledge keypress gates (once per distinct screen text) —
708
+ // with a PTY the CLI accepts input, and nobody is at its keyboard.
709
+ if (/press enter/i.test(line) && !acked.has(line)) {
710
+ acked.add(line);
711
+ try {
712
+ child.stdin?.write("\r");
713
+ } catch {
714
+ /* best effort */
715
+ }
716
+ }
717
+ }
718
+ // The whole point: capture the token the moment it renders. The CLI's
719
+ // final screen lingers for a keypress; we don't wait for exit.
720
+ const token = extractClaudeToken(complete);
721
+ if (token) saveToken(token);
722
+ };
723
+ child.stdout?.on("data", onChunk);
724
+ child.stderr?.on("data", onChunk);
372
725
  child.on("error", (err: NodeJS.ErrnoException) =>
373
726
  done({
374
727
  ok: false,
@@ -379,27 +732,42 @@ function runTokenCommand(
379
732
  }),
380
733
  );
381
734
  child.on("exit", () => {
382
- const token = extractClaudeToken(stdout);
735
+ const token = extractClaudeToken(sanitizeTerminalOutput(raw));
383
736
  if (!token) {
384
737
  done({
385
738
  ok: false,
386
- message:
387
- "Couldn't read a token from the CLI output. Try again, or paste the token manually.",
739
+ message: `The sign-in didn't finish. ${TOKEN_FLOW_FALLBACK}`,
388
740
  });
389
741
  return;
390
742
  }
391
- upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
392
- done({ ok: true, message: `${label} connected.` });
743
+ saveToken(token);
393
744
  });
745
+ timer = setTimeout(
746
+ () =>
747
+ done(
748
+ {
749
+ ok: false,
750
+ message: `Timed out waiting for the browser authorization. ${TOKEN_FLOW_FALLBACK}`,
751
+ },
752
+ true,
753
+ ),
754
+ TOKEN_FLOW_TIMEOUT_MS,
755
+ );
394
756
  });
395
757
  }
396
758
 
397
- function runLoginCommand(
759
+ async function runLoginCommand(
398
760
  kind: EngineKind,
399
761
  label: string,
400
762
  onLog: (line: string) => void,
401
763
  s: EngineSeams,
402
764
  ): Promise<ConnectResult> {
765
+ let child: ChildProcess;
766
+ try {
767
+ child = await spawnEngineCli(kind, ["login"], s);
768
+ } catch (err) {
769
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
770
+ }
403
771
  return new Promise((resolve) => {
404
772
  let settled = false;
405
773
  const done = (r: ConnectResult): void => {
@@ -408,13 +776,6 @@ function runLoginCommand(
408
776
  resolve(r);
409
777
  }
410
778
  };
411
- let child: ChildProcess;
412
- try {
413
- child = s.spawn(resolveBin(kind, s), ["login"]);
414
- } catch (err) {
415
- done({ ok: false, message: err instanceof Error ? err.message : String(err) });
416
- return;
417
- }
418
779
  child.stdout?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
419
780
  child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
420
781
  child.on("error", (err: NodeJS.ErrnoException) =>
@@ -18,12 +18,12 @@
18
18
 
19
19
  import { spawn } from "node:child_process";
20
20
  import { createHash } from "node:crypto";
21
- import { existsSync } from "node:fs";
22
21
  import { readdir, readFile } from "node:fs/promises";
23
- import { homedir } from "node:os";
24
22
  import { dirname, join, resolve } from "node:path";
25
23
  import { fileURLToPath } from "node:url";
26
24
 
25
+ import { detectEngine } from "./engines";
26
+
27
27
  /** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
28
28
  export const STANDARD_IMAGE_TAG = "uai-standard:dev";
29
29
  export const ASDF_DATA_VOLUME = "uai-asdf-data";
@@ -71,23 +71,21 @@ const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
71
71
  */
72
72
  /**
73
73
  * Which OPTIONAL agent CLIs the operator has actually configured — so the
74
- * image installs only those, not every engine on every host. Keyed on the
75
- * same credential each adapter's `available()` checks (kimi/grok are copied
76
- * into containers at task-up; claude/codex are always baked). Folded into the
77
- * build hash below, so logging into a new engine triggers a rebuild.
74
+ * image installs only those, not every engine on every host. Delegates to
75
+ * engines.ts detection so every connect flavor counts: kimi's copied cred
76
+ * file, grok's login file OR a pasted XAI_API_KEY, cursor's CURSOR_API_KEY
77
+ * (claude/codex are always baked). Folded into the build hash below, so
78
+ * connecting a new engine triggers a rebuild.
78
79
  */
79
80
  export function configuredOptionalEngines(): {
80
81
  kimi: boolean;
81
82
  grok: boolean;
82
83
  cursor: boolean;
83
84
  } {
84
- const home = process.env.UAI_OWNER_HOME?.trim() || homedir();
85
85
  return {
86
- kimi: existsSync(join(home, ".kimi-code", "credentials", "kimi-code.json")),
87
- grok: existsSync(join(home, ".grok", "auth.json")),
88
- // Cursor auths via CURSOR_API_KEY (env, loaded from .env.local), not a
89
- // config file — install it when the key is present.
90
- cursor: Boolean(process.env.CURSOR_API_KEY),
86
+ kimi: detectEngine("kimi"),
87
+ grok: detectEngine("grok"),
88
+ cursor: detectEngine("cursor"),
91
89
  };
92
90
  }
93
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
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>",
@@ -365,6 +365,11 @@ fi
365
365
  # claude and the code-server terminal's interactive claude authenticate.
366
366
  printf ' %s: "${%s:-}"\n' \
367
367
  "CLAUDE_CODE_OAUTH_TOKEN" "CLAUDE_CODE_OAUTH_TOKEN"
368
+ # Pasted engine API keys (ADR-070) ride the same at-up interpolation, so the
369
+ # terminal CLIs authenticate like the adapters' exec-time env forwarding.
370
+ printf ' %s: "${%s:-}"\n' "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEY"
371
+ printf ' %s: "${%s:-}"\n' "ANTHROPIC_AUTH_TOKEN" "ANTHROPIC_AUTH_TOKEN"
372
+ printf ' %s: "${%s:-}"\n' "XAI_API_KEY" "XAI_API_KEY"
368
373
  # ADR-053: point Playwright at the shared browser cache volume.
369
374
  printf ' PLAYWRIGHT_BROWSERS_PATH: "/opt/pw-browsers"\n'
370
375
  # No GH_TOKEN/GITHUB_TOKEN in the container env (ADR-027). `gh` prefers such
package/src/ui/types.ts CHANGED
@@ -81,6 +81,9 @@ export const EngineCatalogEntry = z.object({
81
81
  authMode: z.enum(["token-command", "login-command", "api-key"]),
82
82
  notes: z.string().nullable(),
83
83
  getKeyUrl: z.string().nullable(),
84
+ // Pasted-API-key alternative to the login/token flow (null = not offered).
85
+ apiKeyHint: z.string().nullable(),
86
+ apiKeyUrl: z.string().nullable(),
84
87
  });
85
88
  export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
86
89
 
package/ui/app.js CHANGED
@@ -516,19 +516,25 @@ function commandForm(e) {
516
516
  actions.append(connect);
517
517
  form.append(actions, status, log);
518
518
 
519
- // Claude: a manual paste fallback for when the browser flow isn't possible.
520
- if (e.authMode === "token-command") {
519
+ // Manual paste fallback for when the browser flow isn't possible: Claude
520
+ // takes a token or API key (one box, classified server-side); login-command
521
+ // engines with an apiKeyHint (Codex, Grok) take an API key.
522
+ const isToken = e.authMode === "token-command";
523
+ if (isToken || e.apiKeyHint) {
524
+ const saveLabel = isToken ? "Save token" : "Save key";
521
525
  const pasteToggle = document.createElement("button");
522
526
  pasteToggle.className = "link-btn";
523
527
  pasteToggle.type = "button";
524
- pasteToggle.textContent = "Paste a token instead";
528
+ pasteToggle.textContent = isToken
529
+ ? "Paste a token or API key instead"
530
+ : "Use an API key instead";
525
531
  const pasteWrap = document.createElement("div");
526
532
  pasteWrap.className = "engine-setup";
527
533
  pasteWrap.hidden = true;
528
534
  const pInput = document.createElement("input");
529
535
  pInput.type = "password";
530
536
  pInput.className = "text-input";
531
- pInput.placeholder = "sk-ant-oat…";
537
+ pInput.placeholder = e.apiKeyHint || "sk-ant-…";
532
538
  pInput.autocomplete = "off";
533
539
  pInput.spellcheck = false;
534
540
  const pField = document.createElement("div");
@@ -539,30 +545,42 @@ function commandForm(e) {
539
545
  const pSave = document.createElement("button");
540
546
  pSave.className = "btn";
541
547
  pSave.type = "button";
542
- pSave.textContent = "Save token";
548
+ pSave.textContent = saveLabel;
543
549
  pSave.addEventListener("click", async () => {
544
- const pastedToken = pInput.value.trim();
545
- if (!pastedToken) {
550
+ const value = pInput.value.trim();
551
+ if (!value) {
546
552
  pStatus.className = "setup-status err";
547
- pStatus.textContent = "Paste a token first.";
553
+ pStatus.textContent = isToken ? "Paste a token first." : "Paste a key first.";
548
554
  return;
549
555
  }
550
556
  pSave.disabled = true;
551
557
  pSave.textContent = "Saving…";
552
- const result = await runConnect({ kind: e.kind, pastedToken });
558
+ const body = isToken
559
+ ? { kind: e.kind, pastedToken: value }
560
+ : { kind: e.kind, apiKey: value };
561
+ const result = await runConnect(body);
553
562
  if (result.ok) {
554
563
  await poll();
555
564
  closeModal();
556
565
  } else {
557
566
  pSave.disabled = false;
558
- pSave.textContent = "Save token";
567
+ pSave.textContent = saveLabel;
559
568
  pStatus.className = "setup-status err";
560
- pStatus.textContent = result.message || "Couldn't save the token.";
569
+ pStatus.textContent = result.message || "Couldn't save it.";
561
570
  }
562
571
  });
563
572
  const pActions = document.createElement("div");
564
573
  pActions.className = "setup-actions";
565
574
  pActions.append(pSave);
575
+ if (e.apiKeyUrl) {
576
+ const link = document.createElement("a");
577
+ link.className = "link-btn";
578
+ link.href = e.apiKeyUrl;
579
+ link.target = "_blank";
580
+ link.rel = "noreferrer";
581
+ link.textContent = "Get a key";
582
+ pActions.append(link);
583
+ }
566
584
  pasteWrap.append(pField, pActions, pStatus);
567
585
  pasteToggle.addEventListener("click", () => {
568
586
  pasteWrap.hidden = !pasteWrap.hidden;