@runuai/host 0.8.9 → 0.8.11

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,11 +3,18 @@
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
20
  * - token-command (Claude): `claude setup-token` prints a token to stdout;
@@ -17,6 +24,21 @@
17
24
  * writes a config file; success = that file appearing.
18
25
  * - api-key (Cursor): no command — persist a pasted CURSOR_API_KEY.
19
26
  *
27
+ * Besides its primary mode, an engine can accept a pasted API key as an
28
+ * alternative (`apiKeyHint`/`apiKeyUrl` in the catalog drive the UI):
29
+ * - claude → ANTHROPIC_API_KEY (the adapter already forwards it; a pasted
30
+ * `sk-ant-oat…` still lands in CLAUDE_CODE_OAUTH_TOKEN — classified by
31
+ * prefix, so either credential works in either paste box).
32
+ * - codex → ~/.codex/auth.json `{"OPENAI_API_KEY": …}`, the same plaintext
33
+ * file `codex login --with-api-key` writes. Written directly (not via the
34
+ * CLI): task-up docker-cp's that FILE into containers, so a keychain-backed
35
+ * CLI login would break task auth, and this works with no host CLI at all.
36
+ * - grok → XAI_API_KEY (documented pay-as-you-go fallback of the grok CLI;
37
+ * the adapter forwards it into task containers).
38
+ * - kimi → none: Kimi Code reads keys only from its config.toml via the
39
+ * interactive `/login`, never from the environment — so we don't offer a
40
+ * paste box we can't honor.
41
+ *
20
42
  * Detection mirrors each adapter's `available()`: env/`.env.local` for
21
43
  * claude/cursor, a config file under the owner home for codex/kimi/grok.
22
44
  *
@@ -25,7 +47,11 @@
25
47
  * the real filesystem or spawning anything.
26
48
  */
27
49
 
28
- import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
50
+ import {
51
+ execFile as nodeExecFile,
52
+ spawn as nodeSpawn,
53
+ type ChildProcess,
54
+ } from "node:child_process";
29
55
  import {
30
56
  chmodSync,
31
57
  existsSync,
@@ -51,6 +77,10 @@ export interface EngineCatalogEntry {
51
77
  notes: string | null;
52
78
  /** Where to mint an API key (api-key mode only). */
53
79
  getKeyUrl: string | null;
80
+ /** Placeholder for the pasted-API-key alternative; null = not supported. */
81
+ apiKeyHint: string | null;
82
+ /** Where to mint a key for the pasted-API-key alternative. */
83
+ apiKeyUrl: string | null;
54
84
  }
55
85
 
56
86
  interface EngineDescriptor {
@@ -58,6 +88,9 @@ interface EngineDescriptor {
58
88
  authMode: EngineAuthMode;
59
89
  notes: string;
60
90
  getKeyUrl?: string;
91
+ /** Pasted-API-key alternative (absent = login/token only, e.g. kimi). */
92
+ apiKeyHint?: string;
93
+ apiKeyUrl?: string;
61
94
  }
62
95
 
63
96
  /** Static descriptor table — the single source of truth for engine metadata. */
@@ -67,11 +100,15 @@ const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
67
100
  authMode: "token-command",
68
101
  notes:
69
102
  "Opens your browser to authorize Claude, then captures the token automatically.",
103
+ apiKeyHint: "sk-ant-…",
104
+ apiKeyUrl: "https://console.anthropic.com/settings/keys",
70
105
  },
71
106
  codex: {
72
107
  label: "Codex",
73
108
  authMode: "login-command",
74
109
  notes: "Opens your browser to sign in to your OpenAI Codex account.",
110
+ apiKeyHint: "sk-…",
111
+ apiKeyUrl: "https://platform.openai.com/api-keys",
75
112
  },
76
113
  kimi: {
77
114
  label: "Kimi Code",
@@ -84,6 +121,8 @@ const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
84
121
  authMode: "login-command",
85
122
  notes:
86
123
  "Sign in with your xAI Grok subscription. Activates after the next image rebuild.",
124
+ apiKeyHint: "xai-…",
125
+ apiKeyUrl: "https://console.x.ai",
87
126
  },
88
127
  cursor: {
89
128
  label: "Cursor",
@@ -101,7 +140,11 @@ const ORDER: EngineKind[] = ["claude", "codex", "kimi", "grok", "cursor"];
101
140
  // ---------------------------------------------------------------------------
102
141
 
103
142
  /** How a login/token command is spawned. Tests provide a fake. */
104
- export type EngineSpawn = (command: string, args: string[]) => ChildProcess;
143
+ export type EngineSpawn = (
144
+ command: string,
145
+ args: string[],
146
+ env: NodeJS.ProcessEnv,
147
+ ) => ChildProcess;
105
148
 
106
149
  export interface EngineSeams {
107
150
  /** Spawn a CLI, piping stdout/stderr. */
@@ -112,18 +155,53 @@ export interface EngineSeams {
112
155
  ownerHome: () => string;
113
156
  /** The live process env (adapters + task-up read creds from here). */
114
157
  procEnv: NodeJS.ProcessEnv;
158
+ /** PATH as the owner's login shell sees it (null when unprobeable). */
159
+ loginShellPath: () => Promise<string | null>;
160
+ /** System-wide bin dirs probed for engine CLIs (tests pin to []). */
161
+ systemBinDirs: string[];
115
162
  }
116
163
 
117
164
  function defaultSeams(): EngineSeams {
118
165
  return {
119
- spawn: (command, args) =>
120
- nodeSpawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }),
166
+ spawn: (command, args, spawnEnv) =>
167
+ nodeSpawn(command, args, {
168
+ stdio: ["ignore", "pipe", "pipe"],
169
+ env: spawnEnv,
170
+ }),
121
171
  envLocalPath: () => join(env.uaiHome, ".env.local"),
122
172
  ownerHome: () => process.env.UAI_OWNER_HOME?.trim() || homedir(),
123
173
  procEnv: process.env,
174
+ loginShellPath: probeLoginShellPath,
175
+ systemBinDirs: ["/opt/homebrew/bin", "/usr/local/bin"],
124
176
  };
125
177
  }
126
178
 
179
+ /**
180
+ * The PATH the owner's login shell exports. Runs `/usr/bin/env` (not
181
+ * `echo $PATH`) so the value is the real colon-joined exported PATH under any
182
+ * shell — fish's `$PATH` is a list and would echo space-joined — and takes the
183
+ * LAST `PATH=` line so profile echo noise can't spoof it. Null on any failure;
184
+ * callers fall back to the inherited PATH.
185
+ */
186
+ function probeLoginShellPath(): Promise<string | null> {
187
+ return new Promise((resolve) => {
188
+ const shell = process.env.SHELL?.trim() || "/bin/zsh";
189
+ nodeExecFile(
190
+ shell,
191
+ ["-lc", "/usr/bin/env"],
192
+ { timeout: 8_000 },
193
+ (err, stdout) => {
194
+ if (err) return resolve(null);
195
+ const lines = String(stdout)
196
+ .split(/\r?\n/)
197
+ .filter((l) => l.startsWith("PATH="));
198
+ const last = lines[lines.length - 1];
199
+ resolve(last ? last.slice("PATH=".length).trim() || null : null);
200
+ },
201
+ );
202
+ });
203
+ }
204
+
127
205
  function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
128
206
  return { ...defaultSeams(), ...seams };
129
207
  }
@@ -132,7 +210,7 @@ function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
132
210
  // Public catalog / status.
133
211
  // ---------------------------------------------------------------------------
134
212
 
135
- /** The static engine catalog (kind, label, authMode, notes, getKeyUrl). */
213
+ /** The static engine catalog (kind, label, authMode, notes, key URLs). */
136
214
  export function engineCatalog(): EngineCatalogEntry[] {
137
215
  return ORDER.map((kind) => {
138
216
  const d = DESCRIPTORS[kind];
@@ -142,6 +220,8 @@ export function engineCatalog(): EngineCatalogEntry[] {
142
220
  authMode: d.authMode,
143
221
  notes: d.notes,
144
222
  getKeyUrl: d.getKeyUrl ?? null,
223
+ apiKeyHint: d.apiKeyHint ?? null,
224
+ apiKeyUrl: d.apiKeyUrl ?? null,
145
225
  };
146
226
  });
147
227
  }
@@ -183,9 +263,9 @@ export function isEngineKind(value: unknown): value is EngineKind {
183
263
  // ---------------------------------------------------------------------------
184
264
 
185
265
  export interface ConnectOptions {
186
- /** api-key mode: the pasted key (Cursor). */
266
+ /** A pasted API key primary for Cursor, alternative for claude/codex/grok. */
187
267
  apiKey?: string;
188
- /** token-command manual fallback: a pasted token (Claude). */
268
+ /** token-command manual fallback: a pasted token or API key (Claude). */
189
269
  pastedToken?: string;
190
270
  }
191
271
 
@@ -207,21 +287,14 @@ export async function connectEngine(
207
287
  const s = withDefaults(seams);
208
288
  const d = DESCRIPTORS[kind];
209
289
 
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.` };
290
+ // A pasted API key connects without spawning anything — the only flow for
291
+ // api-key engines (Cursor), an alternative for the others.
292
+ if (d.authMode === "api-key" || opts.apiKey !== undefined) {
293
+ return saveApiKey(kind, d, opts.apiKey ?? "", s);
221
294
  }
222
295
 
223
296
  if (d.authMode === "token-command") {
224
- // Manual paste fallback — accept a token without spawning the CLI.
297
+ // Manual paste fallback — accept a token (or API key) without spawning.
225
298
  if (opts.pastedToken !== undefined) {
226
299
  const token = opts.pastedToken.trim();
227
300
  if (!token) return { ok: false, message: "Paste a token." };
@@ -231,7 +304,15 @@ export async function connectEngine(
231
304
  message: "That doesn't look like a token. Paste just the value.",
232
305
  };
233
306
  }
234
- upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
307
+ // One paste box serves both credential kinds — classify by prefix so an
308
+ // `sk-ant-api…` platform key doesn't get stored as an OAuth token.
309
+ upsertEnvLocal(
310
+ token.startsWith("sk-ant-api")
311
+ ? "ANTHROPIC_API_KEY"
312
+ : "CLAUDE_CODE_OAUTH_TOKEN",
313
+ token,
314
+ s,
315
+ );
235
316
  return { ok: true, message: `${d.label} connected.` };
236
317
  }
237
318
  return runTokenCommand(kind, d.label, onLog, s);
@@ -240,6 +321,70 @@ export async function connectEngine(
240
321
  return runLoginCommand(kind, d.label, onLog, s);
241
322
  }
242
323
 
324
+ /** Persist a pasted API key where the engine's adapter actually reads it. */
325
+ function saveApiKey(
326
+ kind: EngineKind,
327
+ d: EngineDescriptor,
328
+ raw: string,
329
+ s: EngineSeams,
330
+ ): ConnectResult {
331
+ const key = raw.trim();
332
+ if (!key) return { ok: false, message: `Paste your ${d.label} API key.` };
333
+ if (/\s/.test(key) || key.length < 8) {
334
+ return {
335
+ ok: false,
336
+ message: "That doesn't look like an API key. Paste just the value.",
337
+ };
338
+ }
339
+ switch (kind) {
340
+ case "cursor":
341
+ upsertEnvLocal("CURSOR_API_KEY", key, s);
342
+ break;
343
+ case "claude":
344
+ // Accept either credential kind here too (see the paste fallback above).
345
+ upsertEnvLocal(
346
+ key.startsWith("sk-ant-oat") ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY",
347
+ key,
348
+ s,
349
+ );
350
+ break;
351
+ case "grok":
352
+ // The grok CLI's documented pay-as-you-go fallback; the adapter forwards
353
+ // it into task containers.
354
+ upsertEnvLocal("XAI_API_KEY", key, s);
355
+ break;
356
+ case "codex":
357
+ writeCodexAuthJson(key, s);
358
+ break;
359
+ case "kimi":
360
+ return {
361
+ ok: false,
362
+ message:
363
+ "Kimi Code authenticates via subscription sign-in — it doesn't read API keys from the environment.",
364
+ };
365
+ }
366
+ return { ok: true, message: `${d.label} connected.` };
367
+ }
368
+
369
+ /**
370
+ * Write `~/.codex/auth.json` the way `codex login --with-api-key` does. We
371
+ * write the plaintext file directly (not via the CLI) because task-up
372
+ * docker-cp's this exact FILE into containers — a keychain-backed CLI login
373
+ * would leave nothing to copy — and it must work without a host codex CLI.
374
+ */
375
+ function writeCodexAuthJson(key: string, s: EngineSeams): void {
376
+ const file = join(s.ownerHome(), ".codex", "auth.json");
377
+ mkdirSync(dirname(file), { recursive: true });
378
+ writeFileSync(file, `${JSON.stringify({ OPENAI_API_KEY: key }, null, 2)}\n`, {
379
+ mode: 0o600,
380
+ });
381
+ try {
382
+ chmodSync(file, 0o600);
383
+ } catch {
384
+ /* best effort */
385
+ }
386
+ }
387
+
243
388
  /** Disconnect an engine: forget its credential (env line and/or config file). */
244
389
  export function disconnectEngine(
245
390
  kind: EngineKind,
@@ -256,6 +401,8 @@ export function disconnectEngine(
256
401
  removeEnvLocal("ANTHROPIC_AUTH_TOKEN", s);
257
402
  return;
258
403
  }
404
+ // grok can be connected via login (auth.json) OR a pasted key — drop both.
405
+ if (kind === "grok") removeEnvLocal("XAI_API_KEY", s);
259
406
  // login-command engines: remove the config file the adapter detects.
260
407
  try {
261
408
  rmSync(configPath(kind, s), { force: true });
@@ -299,9 +446,10 @@ function detect(kind: EngineKind, s: EngineSeams): boolean {
299
446
  );
300
447
  case "cursor":
301
448
  return envOrFileHas("CURSOR_API_KEY", s);
449
+ case "grok":
450
+ return existsSync(configPath(kind, s)) || envOrFileHas("XAI_API_KEY", s);
302
451
  case "codex":
303
452
  case "kimi":
304
- case "grok":
305
453
  return existsSync(configPath(kind, s));
306
454
  }
307
455
  }
@@ -327,26 +475,103 @@ function configPath(kind: EngineKind, s: EngineSeams): string {
327
475
  return join(home, ".grok", "auth.json");
328
476
  }
329
477
 
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";
478
+ /**
479
+ * Well-known install locations for an engine CLI — the dirs a GUI-launched
480
+ * host's minimal PATH misses. Home-relative entries resolve against the OWNER
481
+ * home (seam-injectable). The binary name equals the engine kind for all four
482
+ * CLI engines.
483
+ */
484
+ function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
485
+ const home = s.ownerHome();
486
+ const common = [join(home, ".local", "bin"), ...s.systemBinDirs];
487
+ switch (kind) {
488
+ case "claude":
489
+ // Native installer symlinks ~/.local/bin/claude (preferred); the older
490
+ // "local install" migration keeps a wrapper at ~/.claude/local/claude.
491
+ return [join(home, ".local", "bin"), join(home, ".claude", "local"), ...s.systemBinDirs].map(
492
+ (d) => join(d, "claude"),
493
+ );
494
+ case "codex":
495
+ return common.map((d) => join(d, "codex"));
496
+ case "kimi":
497
+ return [join(home, ".kimi-code", "bin"), ...common].map((d) =>
498
+ join(d, "kimi"),
499
+ );
500
+ case "grok":
501
+ return [join(home, ".grok", "bin"), ...common].map((d) => join(d, "grok"));
502
+ case "cursor":
503
+ return []; // api-key mode — never spawned
504
+ }
505
+ }
506
+
507
+ interface ResolvedBin {
508
+ /** Command to spawn — absolute when found, else the bare name. */
509
+ bin: string;
510
+ /** PATH for the child, augmented so nested lookups (node, git) resolve. */
511
+ path: string;
512
+ }
513
+
514
+ /**
515
+ * Find the engine CLI the way the owner's terminal would: well-known install
516
+ * dirs first (fast, no subprocess), then the login shell's PATH (sources the
517
+ * owner's profile — covers nvm/asdf/npm prefixes), else the bare name against
518
+ * the inherited PATH so a genuinely missing CLI still surfaces the friendly
519
+ * ENOENT "install it first" message.
520
+ */
521
+ async function resolveEngineBin(
522
+ kind: EngineKind,
523
+ s: EngineSeams,
524
+ ): Promise<ResolvedBin> {
525
+ const inherited = s.procEnv.PATH ?? "";
526
+ for (const file of candidateBins(kind, s)) {
527
+ if (existsSync(file)) {
528
+ return { bin: file, path: prependPath([dirname(file)], inherited) };
529
+ }
335
530
  }
336
- if (kind === "grok") {
337
- const local = join(s.ownerHome(), ".grok", "bin", "grok");
338
- return existsSync(local) ? local : "grok";
531
+ const login = await s.loginShellPath();
532
+ if (login) {
533
+ const dirs = login.split(":").filter(Boolean);
534
+ const dir = dirs.find((d) => existsSync(join(d, kind)));
535
+ // Found or not, hand the login PATH to the child — it is the environment
536
+ // where the CLI (and whatever it shells out to) is known to work.
537
+ return {
538
+ bin: dir ? join(dir, kind) : kind,
539
+ path: prependPath(dirs, inherited),
540
+ };
339
541
  }
340
- // claude / codex are on PATH.
341
- return kind;
542
+ return { bin: kind, path: inherited };
543
+ }
544
+
545
+ /** Prepend dirs to a colon-joined PATH, deduped, order-preserving. */
546
+ function prependPath(prepend: string[], current: string): string {
547
+ const seen = new Set<string>();
548
+ return [...prepend, ...current.split(":")]
549
+ .filter((p) => p.length > 0 && !seen.has(p) && (seen.add(p), true))
550
+ .join(":");
342
551
  }
343
552
 
344
- function runTokenCommand(
553
+ /** Resolve the CLI and spawn it with the augmented PATH. */
554
+ async function spawnEngineCli(
555
+ kind: EngineKind,
556
+ args: string[],
557
+ s: EngineSeams,
558
+ ): Promise<ChildProcess> {
559
+ const { bin, path } = await resolveEngineBin(kind, s);
560
+ return s.spawn(bin, args, { ...s.procEnv, PATH: path });
561
+ }
562
+
563
+ async function runTokenCommand(
345
564
  kind: EngineKind,
346
565
  label: string,
347
566
  onLog: (line: string) => void,
348
567
  s: EngineSeams,
349
568
  ): Promise<ConnectResult> {
569
+ let child: ChildProcess;
570
+ try {
571
+ child = await spawnEngineCli(kind, ["setup-token"], s);
572
+ } catch (err) {
573
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
574
+ }
350
575
  return new Promise((resolve) => {
351
576
  let settled = false;
352
577
  const done = (r: ConnectResult): void => {
@@ -355,13 +580,6 @@ function runTokenCommand(
355
580
  resolve(r);
356
581
  }
357
582
  };
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
583
  let stdout = "";
366
584
  child.stdout?.on("data", (b: Buffer) => {
367
585
  const text = b.toString("utf8");
@@ -394,12 +612,18 @@ function runTokenCommand(
394
612
  });
395
613
  }
396
614
 
397
- function runLoginCommand(
615
+ async function runLoginCommand(
398
616
  kind: EngineKind,
399
617
  label: string,
400
618
  onLog: (line: string) => void,
401
619
  s: EngineSeams,
402
620
  ): Promise<ConnectResult> {
621
+ let child: ChildProcess;
622
+ try {
623
+ child = await spawnEngineCli(kind, ["login"], s);
624
+ } catch (err) {
625
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
626
+ }
403
627
  return new Promise((resolve) => {
404
628
  let settled = false;
405
629
  const done = (r: ConnectResult): void => {
@@ -408,13 +632,6 @@ function runLoginCommand(
408
632
  resolve(r);
409
633
  }
410
634
  };
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
635
  child.stdout?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
419
636
  child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
420
637
  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.9",
3
+ "version": "0.8.11",
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;