@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.
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +24 -0
- package/images/standard/Dockerfile +9 -0
- package/lib/agents/claude.ts +7 -1
- package/lib/agents/codex.ts +8 -2
- package/lib/agents/factory.ts +1 -0
- package/lib/agents/opencode.ts +461 -0
- package/lib/agents/rate-limit.ts +34 -0
- package/lib/agents/types.ts +4 -2
- package/lib/codex-auth.ts +4 -0
- package/lib/engine-accounts.ts +538 -0
- package/lib/engines.ts +69 -4
- package/lib/mcp-gateway.ts +30 -5
- package/lib/orchestrator.ts +168 -7
- package/lib/standard-image.ts +5 -1
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +17 -0
- package/src/ui/server.ts +65 -0
- package/src/ui/types.ts +22 -2
- package/ui/app.js +190 -1
- package/ui/style.css +19 -0
|
@@ -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 =
|
|
77
|
-
|
|
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[] = [
|
|
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
|
}
|