@runuai/host 0.8.35 → 0.8.36

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.
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Engine accounts (ADR-076) — multiple credentials per engine kind, selected
3
+ * by ENV at each `docker exec`, rotated automatically with rate-limit failover.
4
+ *
5
+ * An account resolves to a small env map merged into the agent's exec:
6
+ * - Claude (env account): CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY.
7
+ * - Codex (config-dir account): CODEX_HOME=<per-account container dir>.
8
+ * - OpenCode (config-dir account): OPENCODE_DATA_DIR=<per-account container dir>.
9
+ *
10
+ * `accounts(kind) = [synthesized default] + [DB extras]`. The DEFAULT is the
11
+ * legacy single slot (`.env.local` var, or `~/.codex` / `~/.local/share/opencode`
12
+ * dir) — so with zero extras behavior is identical to before and `available()`
13
+ * is unchanged. EXTRA accounts live in `host_engine_accounts`, sealed with the
14
+ * host master key (env kind) or as an isolated host dir (config-dir kind).
15
+ *
16
+ * Isolation invariants (ADR-015/024) are preserved: accounts never leave the
17
+ * host, the operator's live dirs are never bind-mounted (extras are per-task
18
+ * writable copies), and every `docker cp` is followed by `chown -R node:node`.
19
+ *
20
+ * Rotation state (cooldown / last-used) is in-memory only; a host restart
21
+ * clears cooldowns (a restarted host retries every account, which is correct —
22
+ * caps reset over time).
23
+ */
24
+
25
+ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+
29
+ import { eq } from "drizzle-orm";
30
+
31
+ import { getDb, schema } from "./db";
32
+ import { dockerCli } from "./docker-exec";
33
+ import { openAesGcm, sealAesGcm } from "./secrets";
34
+ import { newId } from "./ulid";
35
+
36
+ const EXEC_TIMEOUT_MS = 15_000;
37
+
38
+ /** Engine kinds that participate in the account model. Other kinds resolve to
39
+ * no accounts (the orchestrator then injects no account env — old behavior). */
40
+ export type AccountAuthKind = "env" | "config-dir";
41
+
42
+ interface KindDescriptor {
43
+ authKind: AccountAuthKind;
44
+ /** Default (legacy-slot) host dir for config-dir kinds. */
45
+ defaultHostDir?: (owner: string) => string;
46
+ /** Default (legacy-slot) container dir for config-dir kinds. */
47
+ defaultContainerDir?: string;
48
+ /** Env var that selects a config-dir account's dir in-container. */
49
+ selectVar?: string;
50
+ /** Per-account container dir prefix for config-dir extras. */
51
+ containerDirPrefix?: string;
52
+ /** Per-account host dir builder for config-dir extras. */
53
+ extraHostDir?: (owner: string, id: string) => string;
54
+ }
55
+
56
+ const KINDS: Record<string, KindDescriptor> = {
57
+ claude: { authKind: "env" },
58
+ codex: {
59
+ authKind: "config-dir",
60
+ defaultHostDir: (owner) => join(owner, ".codex"),
61
+ defaultContainerDir: "/home/node/.codex",
62
+ selectVar: "CODEX_HOME",
63
+ containerDirPrefix: "/home/node/.codex-acct-",
64
+ extraHostDir: (owner, id) => join(owner, `.codex-acct-${id}`),
65
+ },
66
+ opencode: {
67
+ authKind: "config-dir",
68
+ defaultHostDir: (owner) => join(owner, ".local", "share", "opencode"),
69
+ defaultContainerDir: "/home/node/.local/share/opencode",
70
+ selectVar: "OPENCODE_DATA_DIR",
71
+ containerDirPrefix: "/home/node/.local/share/opencode-acct-",
72
+ extraHostDir: (owner, id) => join(owner, ".local", "share", `opencode-acct-${id}`),
73
+ },
74
+ };
75
+
76
+ /** Public (secret-free) account descriptor for the host UI. */
77
+ export interface EngineAccountInfo {
78
+ id: string;
79
+ kind: string;
80
+ label: string;
81
+ authKind: AccountAuthKind;
82
+ isDefault: boolean;
83
+ }
84
+
85
+ /** A fully-resolved account, ready to inject / provision (host-trusted; the
86
+ * execEnv holds decrypted secrets — never log it). */
87
+ export interface ResolvedEngineAccount {
88
+ id: string;
89
+ kind: string;
90
+ label: string;
91
+ isDefault: boolean;
92
+ authKind: AccountAuthKind;
93
+ /** Env merged into the agent's docker exec. Empty for the default env
94
+ * account (the ambient `.env.local` credential is account #1). */
95
+ execEnv: Record<string, string>;
96
+ /** config-dir accounts only: host dir to copy + container dir target. */
97
+ configDir?: { hostDir: string; containerDir: string };
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Seams (tests inject).
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export interface AccountSeams {
105
+ ownerHome: () => string;
106
+ procEnv: NodeJS.ProcessEnv;
107
+ fileExists: (p: string) => boolean;
108
+ }
109
+
110
+ function defaultSeams(): AccountSeams {
111
+ return {
112
+ ownerHome: () => process.env.UAI_OWNER_HOME?.trim() || homedir(),
113
+ procEnv: process.env,
114
+ fileExists: existsSync,
115
+ };
116
+ }
117
+
118
+ function withDefaults(seams: Partial<AccountSeams>): AccountSeams {
119
+ return { ...defaultSeams(), ...seams };
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Sealing (same pack format as host-env.ts).
124
+ // ---------------------------------------------------------------------------
125
+
126
+ function packSecret(value: string): string {
127
+ const sealed = sealAesGcm(value);
128
+ return `${sealed.ct.toString("base64")}.${sealed.nonce.toString("base64")}`;
129
+ }
130
+
131
+ function unpackSecret(enc: string): string {
132
+ const dot = enc.indexOf(".");
133
+ if (dot === -1) throw new Error("malformed secret_enc");
134
+ const ct = Buffer.from(enc.slice(0, dot), "base64");
135
+ const nonce = Buffer.from(enc.slice(dot + 1), "base64");
136
+ return openAesGcm(ct, nonce);
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Resolution.
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /** Whether the kind's DEFAULT (legacy) credential is present on this host. */
144
+ function defaultAvailable(kind: string, s: AccountSeams): boolean {
145
+ const d = KINDS[kind];
146
+ if (!d) return false;
147
+ if (kind === "claude") {
148
+ return Boolean(
149
+ s.procEnv.CLAUDE_CODE_OAUTH_TOKEN ||
150
+ s.procEnv.ANTHROPIC_API_KEY ||
151
+ s.procEnv.ANTHROPIC_AUTH_TOKEN,
152
+ );
153
+ }
154
+ if (d.authKind === "config-dir" && d.defaultHostDir) {
155
+ return s.fileExists(join(d.defaultHostDir(s.ownerHome()), "auth.json"));
156
+ }
157
+ return false;
158
+ }
159
+
160
+ function synthesizeDefault(
161
+ kind: string,
162
+ s: AccountSeams,
163
+ ): ResolvedEngineAccount | null {
164
+ const d = KINDS[kind];
165
+ if (!d || !defaultAvailable(kind, s)) return null;
166
+ if (d.authKind === "env") {
167
+ // Ambient: the token in .env.local/process.env is forwarded via passEnv,
168
+ // so the default account injects nothing.
169
+ return {
170
+ id: `default:${kind}`,
171
+ kind,
172
+ label: "Default",
173
+ isDefault: true,
174
+ authKind: "env",
175
+ execEnv: {},
176
+ };
177
+ }
178
+ const containerDir = d.defaultContainerDir as string;
179
+ return {
180
+ id: `default:${kind}`,
181
+ kind,
182
+ label: "Default",
183
+ isDefault: true,
184
+ authKind: "config-dir",
185
+ execEnv: d.selectVar ? { [d.selectVar]: containerDir } : {},
186
+ configDir: {
187
+ hostDir: (d.defaultHostDir as (o: string) => string)(s.ownerHome()),
188
+ containerDir,
189
+ },
190
+ };
191
+ }
192
+
193
+ /** DB extras for a kind, resolved into injectable form. */
194
+ function resolveExtras(kind: string, s: AccountSeams): ResolvedEngineAccount[] {
195
+ const d = KINDS[kind];
196
+ if (!d) return [];
197
+ const rows = getDb()
198
+ .select()
199
+ .from(schema.engineAccounts)
200
+ .all()
201
+ .filter((r) => r.kind === kind);
202
+ const out: ResolvedEngineAccount[] = [];
203
+ for (const row of rows) {
204
+ if (row.authKind === "env") {
205
+ let execEnv: Record<string, string> = {};
206
+ try {
207
+ execEnv = row.secretEnc
208
+ ? (JSON.parse(unpackSecret(row.secretEnc)) as Record<string, string>)
209
+ : {};
210
+ } catch (err) {
211
+ console.warn(
212
+ `[engine-accounts] skipping ${kind}/${row.id}: decrypt failed (${
213
+ err instanceof Error ? err.message : String(err)
214
+ })`,
215
+ );
216
+ continue;
217
+ }
218
+ out.push({
219
+ id: row.id,
220
+ kind,
221
+ label: row.label,
222
+ isDefault: false,
223
+ authKind: "env",
224
+ execEnv,
225
+ });
226
+ } else if (row.authKind === "config-dir" && row.configDir && d.selectVar) {
227
+ const containerDir = `${d.containerDirPrefix}${row.id}`;
228
+ out.push({
229
+ id: row.id,
230
+ kind,
231
+ label: row.label,
232
+ isDefault: false,
233
+ authKind: "config-dir",
234
+ execEnv: { [d.selectVar]: containerDir },
235
+ configDir: { hostDir: row.configDir, containerDir },
236
+ });
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /** All accounts for a kind: [default if present] + [DB extras]. */
243
+ export function resolveEngineAccounts(
244
+ kind: string,
245
+ seams: Partial<AccountSeams> = {},
246
+ ): ResolvedEngineAccount[] {
247
+ const s = withDefaults(seams);
248
+ const accounts: ResolvedEngineAccount[] = [];
249
+ const def = synthesizeDefault(kind, s);
250
+ if (def) accounts.push(def);
251
+ accounts.push(...resolveExtras(kind, s));
252
+ return accounts;
253
+ }
254
+
255
+ /** Public account list for the host UI (no secrets). */
256
+ export function listEngineAccounts(
257
+ kind: string,
258
+ seams: Partial<AccountSeams> = {},
259
+ ): EngineAccountInfo[] {
260
+ return resolveEngineAccounts(kind, seams).map((a) => ({
261
+ id: a.id,
262
+ kind: a.kind,
263
+ label: a.label,
264
+ authKind: a.authKind,
265
+ isDefault: a.isDefault,
266
+ }));
267
+ }
268
+
269
+ /** How many accounts a kind has right now (default + extras). */
270
+ export function engineAccountCount(
271
+ kind: string,
272
+ seams: Partial<AccountSeams> = {},
273
+ ): number {
274
+ return resolveEngineAccounts(kind, seams).length;
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Rotation state (in-memory) + selection.
279
+ // ---------------------------------------------------------------------------
280
+
281
+ interface RotationState {
282
+ cooldownUntil: number;
283
+ lastUsedAt: number;
284
+ }
285
+
286
+ const rotationState = new Map<string, RotationState>();
287
+
288
+ /** Default cooldown when the engine gives no reset hint. */
289
+ export const DEFAULT_COOLDOWN_MS = 15 * 60_000;
290
+
291
+ function state(id: string): RotationState {
292
+ let st = rotationState.get(id);
293
+ if (!st) {
294
+ st = { cooldownUntil: 0, lastUsedAt: 0 };
295
+ rotationState.set(id, st);
296
+ }
297
+ return st;
298
+ }
299
+
300
+ /** Put an account on cooldown (rate-limited). */
301
+ export function cooldownEngineAccount(
302
+ id: string,
303
+ ms: number = DEFAULT_COOLDOWN_MS,
304
+ now: number = Date.now(),
305
+ ): void {
306
+ state(id).cooldownUntil = now + ms;
307
+ }
308
+
309
+ /** Record that an account was just used (least-recently-used ordering). */
310
+ export function noteEngineAccountUsed(id: string, now: number = Date.now()): void {
311
+ state(id).lastUsedAt = now;
312
+ }
313
+
314
+ /**
315
+ * Pure selection over a resolved list: least-recently-used account not in
316
+ * `exclude` and not cooling. Falls back to the least-recently-used cooling
317
+ * account if ALL are cooling (better to try a capped account than stall).
318
+ * Returns null only when the list (minus exclusions) is empty.
319
+ */
320
+ export function selectAccount(
321
+ accounts: ResolvedEngineAccount[],
322
+ opts: { exclude?: Set<string>; now?: number } = {},
323
+ ): ResolvedEngineAccount | null {
324
+ const now = opts.now ?? Date.now();
325
+ const exclude = opts.exclude ?? new Set<string>();
326
+ const candidates = accounts.filter((a) => !exclude.has(a.id));
327
+ if (candidates.length === 0) return null;
328
+ const lru = (a: ResolvedEngineAccount): number => state(a.id).lastUsedAt;
329
+ const healthy = candidates.filter((a) => state(a.id).cooldownUntil <= now);
330
+ const pool = healthy.length > 0 ? healthy : candidates;
331
+ return pool.reduce((best, a) => (lru(a) < lru(best) ? a : best), pool[0]!);
332
+ }
333
+
334
+ /** Pick an account for a kind (default + extras), applying rotation state. */
335
+ export function pickEngineAccount(
336
+ kind: string,
337
+ opts: { exclude?: Set<string>; seams?: Partial<AccountSeams>; now?: number } = {},
338
+ ): ResolvedEngineAccount | null {
339
+ const accounts = resolveEngineAccounts(kind, opts.seams ?? {});
340
+ return selectAccount(accounts, { exclude: opts.exclude, now: opts.now });
341
+ }
342
+
343
+ /** Test hook: clear in-memory rotation state. */
344
+ export function resetRotationState(): void {
345
+ rotationState.clear();
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Provisioning (extra config-dir accounts) — copied into per-account container
350
+ // dirs at session start, since task-up.sh (bash) can't read the sealed DB.
351
+ // ---------------------------------------------------------------------------
352
+
353
+ async function copyDirInto(
354
+ hostDir: string,
355
+ containerDir: string,
356
+ container: string,
357
+ ): Promise<void> {
358
+ const must = async (args: string[], step: string): Promise<void> => {
359
+ const res = await dockerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
360
+ if (res.status !== 0) {
361
+ const detail = res.stderr.trim().slice(0, 200);
362
+ throw new Error(
363
+ `${step} failed (${res.status === null ? "docker timeout" : `exit ${res.status}`})` +
364
+ (detail ? `: ${detail}` : ""),
365
+ );
366
+ }
367
+ };
368
+ await must(
369
+ ["exec", "-u", "root", container, "mkdir", "-p", containerDir],
370
+ `mkdir ${containerDir}`,
371
+ );
372
+ // Copy the dir CONTENTS (trailing /.) into the target dir.
373
+ await must(["cp", `${hostDir}/.`, `${container}:${containerDir}`], "docker cp account dir");
374
+ await must(
375
+ ["exec", "-u", "root", container, "chown", "-R", "node:node", containerDir],
376
+ `chown -R node:node ${containerDir}`,
377
+ );
378
+ }
379
+
380
+ /**
381
+ * Copy every EXTRA config-dir account (for the given kinds) into its per-account
382
+ * container dir + chown. The DEFAULT account is copied by task-up.sh, so it is
383
+ * skipped here. Best-effort per account — a failure is logged (no secrets) and
384
+ * that account simply won't authenticate in this container.
385
+ */
386
+ export async function provisionEngineAccounts(
387
+ container: string,
388
+ kinds: Iterable<string>,
389
+ seams: Partial<AccountSeams> = {},
390
+ ): Promise<void> {
391
+ const s = withDefaults(seams);
392
+ const seen = new Set<string>();
393
+ for (const kind of kinds) {
394
+ if (seen.has(kind)) continue;
395
+ seen.add(kind);
396
+ for (const account of resolveEngineAccounts(kind, s)) {
397
+ if (account.isDefault || !account.configDir) continue;
398
+ if (!s.fileExists(account.configDir.hostDir)) continue;
399
+ try {
400
+ await copyDirInto(
401
+ account.configDir.hostDir,
402
+ account.configDir.containerDir,
403
+ container,
404
+ );
405
+ } catch (err) {
406
+ console.error(
407
+ `[engine-accounts] provision ${kind}/${account.id} into ${container}: ` +
408
+ `${err instanceof Error ? err.message : err} — this account won't authenticate in the task`,
409
+ );
410
+ }
411
+ }
412
+ }
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Mutations (host UI).
417
+ // ---------------------------------------------------------------------------
418
+
419
+ export interface AddAccountResult {
420
+ ok: boolean;
421
+ message: string;
422
+ id?: string;
423
+ }
424
+
425
+ /**
426
+ * Register an EXTRA account for a kind by pasting a token / API key (ADR-076).
427
+ * Claude accepts a `setup-token` token or an `sk-ant-…` key; Codex accepts an
428
+ * OpenAI API key (written into an isolated `~/.codex-acct-<id>/auth.json`).
429
+ */
430
+ export function addEngineAccount(
431
+ kind: string,
432
+ label: string,
433
+ creds: { apiKey?: string; token?: string },
434
+ seams: Partial<AccountSeams> = {},
435
+ ): AddAccountResult {
436
+ const s = withDefaults(seams);
437
+ const trimmedLabel = label.trim();
438
+ if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
439
+ const secret = (creds.token ?? creds.apiKey ?? "").trim();
440
+ if (!secret) return { ok: false, message: "Paste a token or API key." };
441
+ if (/\s/.test(secret) || secret.length < 12) {
442
+ return { ok: false, message: "That doesn't look like a token. Paste just the value." };
443
+ }
444
+
445
+ const id = newId();
446
+ const now = Date.now();
447
+
448
+ if (kind === "claude") {
449
+ const envVar = secret.startsWith("sk-ant-api")
450
+ ? "ANTHROPIC_API_KEY"
451
+ : "CLAUDE_CODE_OAUTH_TOKEN";
452
+ getDb()
453
+ .insert(schema.engineAccounts)
454
+ .values({
455
+ id,
456
+ kind,
457
+ label: trimmedLabel,
458
+ authKind: "env",
459
+ secretEnc: packSecret(JSON.stringify({ [envVar]: secret })),
460
+ configDir: null,
461
+ createdAt: now,
462
+ })
463
+ .run();
464
+ return { ok: true, message: `Added Claude account "${trimmedLabel}".`, id };
465
+ }
466
+
467
+ if (kind === "codex") {
468
+ const d = KINDS.codex as KindDescriptor;
469
+ const hostDir = (d.extraHostDir as (o: string, i: string) => string)(
470
+ s.ownerHome(),
471
+ id,
472
+ );
473
+ try {
474
+ const file = join(hostDir, "auth.json");
475
+ mkdirSync(hostDir, { recursive: true });
476
+ writeFileSync(file, `${JSON.stringify({ OPENAI_API_KEY: secret }, null, 2)}\n`, {
477
+ mode: 0o600,
478
+ });
479
+ try {
480
+ chmodSync(file, 0o600);
481
+ } catch {
482
+ /* best effort */
483
+ }
484
+ } catch (err) {
485
+ return {
486
+ ok: false,
487
+ message: `Could not write the account config: ${
488
+ err instanceof Error ? err.message : String(err)
489
+ }`,
490
+ };
491
+ }
492
+ getDb()
493
+ .insert(schema.engineAccounts)
494
+ .values({
495
+ id,
496
+ kind,
497
+ label: trimmedLabel,
498
+ authKind: "config-dir",
499
+ secretEnc: null,
500
+ configDir: hostDir,
501
+ createdAt: now,
502
+ })
503
+ .run();
504
+ return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
505
+ }
506
+
507
+ return {
508
+ ok: false,
509
+ message: `Adding a second ${kind} account isn't supported yet — connect the primary account from the Engines panel.`,
510
+ };
511
+ }
512
+
513
+ /** Remove an EXTRA account (idempotent). The synthesized default can't be
514
+ * removed here (use the engine disconnect flow for the legacy slot). The
515
+ * isolated host config dir of a config-dir account is deleted too. */
516
+ export function removeEngineAccount(id: string): { ok: boolean; message: string } {
517
+ if (id.startsWith("default:")) {
518
+ return {
519
+ ok: false,
520
+ message: "The default account is managed from the engine's Connect/Disconnect.",
521
+ };
522
+ }
523
+ const row = getDb()
524
+ .select()
525
+ .from(schema.engineAccounts)
526
+ .where(eq(schema.engineAccounts.id, id))
527
+ .get();
528
+ if (!row) return { ok: true, message: "Account already removed." };
529
+ getDb().delete(schema.engineAccounts).where(eq(schema.engineAccounts.id, id)).run();
530
+ if (row.authKind === "config-dir" && row.configDir) {
531
+ try {
532
+ rmSync(row.configDir, { recursive: true, force: true });
533
+ } catch {
534
+ /* best effort — the DB row is gone, which is what matters */
535
+ }
536
+ }
537
+ return { ok: true, message: `Removed "${row.label}".` };
538
+ }
package/lib/engines.ts CHANGED
@@ -73,8 +73,21 @@ import { dirname, join } from "node:path";
73
73
 
74
74
  import { env } from "./env";
75
75
 
76
- export type EngineKind = "claude" | "codex" | "kimi" | "grok" | "cursor";
77
- export type EngineAuthMode = "token-command" | "login-command" | "api-key";
76
+ export type EngineKind =
77
+ | "claude"
78
+ | "codex"
79
+ | "kimi"
80
+ | "grok"
81
+ | "cursor"
82
+ | "opencode";
83
+ export type EngineAuthMode =
84
+ | "token-command"
85
+ | "login-command"
86
+ | "api-key"
87
+ // The owner signs in with the engine's OWN interactive CLI in their terminal
88
+ // (e.g. `opencode auth login` — an arrow-key TUI we can't drive from the
89
+ // fire-and-log connect panel), and we just detect the credential it writes.
90
+ | "external-login";
78
91
 
79
92
  /** One entry of the engine catalog the UI's "Add engine" panel renders. */
80
93
  export interface EngineCatalogEntry {
@@ -94,6 +107,11 @@ export interface EngineCatalogEntry {
94
107
  * when the CLI is missing (null = nothing to install, e.g. Cursor).
95
108
  */
96
109
  installHint: string | null;
110
+ /**
111
+ * The command the owner runs in their OWN terminal to sign in
112
+ * (external-login mode only; null otherwise).
113
+ */
114
+ loginCmd: string | null;
97
115
  }
98
116
 
99
117
  interface EngineDescriptor {
@@ -104,6 +122,8 @@ interface EngineDescriptor {
104
122
  /** Pasted-API-key alternative (absent = login/token only, e.g. kimi). */
105
123
  apiKeyHint?: string;
106
124
  apiKeyUrl?: string;
125
+ /** external-login: the CLI command the owner runs in their terminal. */
126
+ loginCmd?: string;
107
127
  }
108
128
 
109
129
  /** Static descriptor table — the single source of truth for engine metadata. */
@@ -143,10 +163,28 @@ const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
143
163
  notes: "Paste a Cursor API key. Requires Cursor Pro.",
144
164
  getKeyUrl: "https://cursor.com/dashboard?tab=integrations",
145
165
  },
166
+ opencode: {
167
+ label: "OpenCode",
168
+ // `opencode auth login` is an interactive arrow-key TUI (provider picker →
169
+ // per-provider auth) — it can't be driven from the fire-and-log connect
170
+ // panel, so the owner runs it in their own terminal and we detect the
171
+ // auth.json it writes.
172
+ authMode: "external-login",
173
+ loginCmd: "opencode auth login",
174
+ notes:
175
+ "Sign in from your terminal with `opencode auth login` (an interactive picker), then check the connection here. Activates after the next image rebuild.",
176
+ },
146
177
  };
147
178
 
148
179
  /** Display order (matches the cloud picker's ordering intent). */
149
- const ORDER: EngineKind[] = ["claude", "codex", "kimi", "grok", "cursor"];
180
+ const ORDER: EngineKind[] = [
181
+ "claude",
182
+ "codex",
183
+ "kimi",
184
+ "grok",
185
+ "cursor",
186
+ "opencode",
187
+ ];
150
188
 
151
189
  // ---------------------------------------------------------------------------
152
190
  // Injectable seams.
@@ -318,6 +356,7 @@ export function engineCatalog(): EngineCatalogEntry[] {
318
356
  apiKeyUrl: d.apiKeyUrl ?? null,
319
357
  installHint:
320
358
  kind === "codex" ? CODEX_INSTALL_HINT : (INSTALLERS[kind] ?? null),
359
+ loginCmd: d.loginCmd ?? null,
321
360
  };
322
361
  });
323
362
  }
@@ -343,6 +382,7 @@ export function engineStatuses(
343
382
  kimi: detect("kimi", s),
344
383
  grok: detect("grok", s),
345
384
  cursor: detect("cursor", s),
385
+ opencode: detect("opencode", s),
346
386
  };
347
387
  }
348
388
 
@@ -360,7 +400,8 @@ export function isEngineKind(value: unknown): value is EngineKind {
360
400
  value === "codex" ||
361
401
  value === "kimi" ||
362
402
  value === "grok" ||
363
- value === "cursor"
403
+ value === "cursor" ||
404
+ value === "opencode"
364
405
  );
365
406
  }
366
407
 
@@ -399,6 +440,20 @@ export async function connectEngine(
399
440
  return saveApiKey(kind, d, opts.apiKey ?? "", s);
400
441
  }
401
442
 
443
+ // external-login (OpenCode): the owner ran the engine's own interactive CLI
444
+ // in their terminal — we never spawn it. "Connect" just re-detects the
445
+ // credential that login wrote.
446
+ if (d.authMode === "external-login") {
447
+ return detect(kind, s)
448
+ ? { ok: true, message: `${d.label} connected.` }
449
+ : {
450
+ ok: false,
451
+ message: d.loginCmd
452
+ ? `Not signed in yet. Run \`${d.loginCmd}\` in your terminal, then check again.`
453
+ : `${d.label} isn't signed in yet.`,
454
+ };
455
+ }
456
+
402
457
  if (d.authMode === "token-command") {
403
458
  // Manual paste fallback — accept a token (or API key) without spawning.
404
459
  if (opts.pastedToken !== undefined) {
@@ -533,6 +588,7 @@ const INSTALLERS: Partial<Record<EngineKind, string>> = {
533
588
  claude: "curl -fsSL https://claude.ai/install.sh | bash",
534
589
  kimi: "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
535
590
  grok: "curl -fsSL https://x.ai/cli/install.sh | bash",
591
+ opencode: "curl -fsSL https://opencode.ai/install | bash",
536
592
  };
537
593
 
538
594
  const CODEX_INSTALL_HINT = "brew install codex — or: npm i -g @openai/codex";
@@ -784,6 +840,7 @@ function detect(kind: EngineKind, s: EngineSeams): boolean {
784
840
  return existsSync(configPath(kind, s)) || envOrFileHas("XAI_API_KEY", s);
785
841
  case "codex":
786
842
  case "kimi":
843
+ case "opencode":
787
844
  return existsSync(configPath(kind, s));
788
845
  }
789
846
  }
@@ -806,6 +863,9 @@ function configPath(kind: EngineKind, s: EngineSeams): string {
806
863
  if (kind === "kimi") {
807
864
  return join(home, ".kimi-code", "credentials", "kimi-code.json");
808
865
  }
866
+ if (kind === "opencode") {
867
+ return join(home, ".local", "share", "opencode", "auth.json");
868
+ }
809
869
  return join(home, ".grok", "auth.json");
810
870
  }
811
871
 
@@ -840,6 +900,11 @@ function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
840
900
  );
841
901
  case "grok":
842
902
  return [join(home, ".grok", "bin"), ...common].map((d) => join(d, "grok"));
903
+ case "opencode":
904
+ // opencode.ai/install lands the binary in ~/.opencode/bin.
905
+ return [join(home, ".opencode", "bin"), ...common].map((d) =>
906
+ join(d, "opencode"),
907
+ );
843
908
  case "cursor":
844
909
  return []; // api-key mode — never spawned
845
910
  }