@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
package/lib/mcp-gateway.ts
CHANGED
|
@@ -270,21 +270,24 @@ export function startMcpGateway(): void {
|
|
|
270
270
|
|
|
271
271
|
// --- task container wiring (ADR-057 task-up writers) -------------------------
|
|
272
272
|
|
|
273
|
-
/** Idempotent node -e merge of entries (argv[2]) into
|
|
274
|
-
* (argv[
|
|
275
|
-
*
|
|
273
|
+
/** Idempotent node -e merge of entries (argv[2]) into a JSON file (argv[1])
|
|
274
|
+
* under a top-level object key (argv[3], default "mcpServers") — reused for
|
|
275
|
+
* Claude's /workspace/.mcp.json + Cursor's ~/.cursor/mcp.json (mcpServers)
|
|
276
|
+
* and OpenCode's ~/.config/opencode/opencode.json (mcp). Creates the parent
|
|
277
|
+
* dir when missing. */
|
|
276
278
|
const MERGE_MCP_JSON = `
|
|
277
279
|
const fs = require("fs");
|
|
278
280
|
const path = require("path");
|
|
279
281
|
const p = process.argv[1];
|
|
282
|
+
const key = process.argv[3] || "mcpServers";
|
|
280
283
|
try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch {}
|
|
281
284
|
let j = {};
|
|
282
285
|
let existed = true;
|
|
283
286
|
try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { existed = false; }
|
|
284
|
-
j
|
|
287
|
+
j[key] = j[key] || {};
|
|
285
288
|
let changed = false;
|
|
286
289
|
for (const [k, v] of Object.entries(JSON.parse(process.argv[2]))) {
|
|
287
|
-
if (JSON.stringify(j
|
|
290
|
+
if (JSON.stringify(j[key][k]) !== JSON.stringify(v)) { j[key][k] = v; changed = true; }
|
|
288
291
|
}
|
|
289
292
|
if (changed || !existed) fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\\n");
|
|
290
293
|
`.trim();
|
|
@@ -302,6 +305,9 @@ function shellQuote(value: string): string {
|
|
|
302
305
|
* - Cursor — ~/.cursor/mcp.json ({ url } form; the adapter passes
|
|
303
306
|
* `--approve-mcps`).
|
|
304
307
|
* - Grok — `grok mcp add` writes ~/.grok/config.toml (idempotent per slug).
|
|
308
|
+
* - OpenCode — ~/.config/opencode/opencode.json `mcp` block (type:"remote",
|
|
309
|
+
* oauth:false so it treats the gateway URL as a plain server; the path
|
|
310
|
+
* token is the auth). Written only when the task has connections.
|
|
305
311
|
* Safe to re-run every ensure. `engineKinds` is the roster's agent kinds.
|
|
306
312
|
*/
|
|
307
313
|
export async function setupMcpTaskConfig(
|
|
@@ -321,10 +327,20 @@ export async function setupMcpTaskConfig(
|
|
|
321
327
|
|
|
322
328
|
const claudeEntries: Record<string, unknown> = {};
|
|
323
329
|
const cursorEntries: Record<string, unknown> = {};
|
|
330
|
+
const opencodeEntries: Record<string, unknown> = {};
|
|
324
331
|
for (const c of connections) {
|
|
325
332
|
claudeEntries[c.slug] = { type: "http", url: urlFor(c.slug) };
|
|
326
333
|
// Cursor's mcp.json wants a bare { url } for remote (http/sse) servers.
|
|
327
334
|
cursorEntries[c.slug] = { url: urlFor(c.slug) };
|
|
335
|
+
// OpenCode: type:"remote" + oauth:false — the gateway needs no client
|
|
336
|
+
// auth (the path token is the secret), so suppress OpenCode's own OAuth
|
|
337
|
+
// flow that would otherwise fire on a gateway 401 and hang headless.
|
|
338
|
+
opencodeEntries[c.slug] = {
|
|
339
|
+
type: "remote",
|
|
340
|
+
url: urlFor(c.slug),
|
|
341
|
+
enabled: true,
|
|
342
|
+
oauth: false,
|
|
343
|
+
};
|
|
328
344
|
}
|
|
329
345
|
const steps = [
|
|
330
346
|
"mkdir -p /workspace/.claude",
|
|
@@ -360,6 +376,15 @@ export async function setupMcpTaskConfig(
|
|
|
360
376
|
)} -t http -s user >/dev/null 2>&1 || true`,
|
|
361
377
|
)
|
|
362
378
|
: []),
|
|
379
|
+
// OpenCode: global config `mcp` block (auto-discovered regardless of the
|
|
380
|
+
// exec cwd, and independent of any per-account OPENCODE_DATA_DIR).
|
|
381
|
+
...(has("opencode") && connections.length > 0
|
|
382
|
+
? [
|
|
383
|
+
`node -e ${shellQuote(MERGE_MCP_JSON)} /home/node/.config/opencode/opencode.json ${shellQuote(
|
|
384
|
+
JSON.stringify(opencodeEntries),
|
|
385
|
+
)} mcp`,
|
|
386
|
+
]
|
|
387
|
+
: []),
|
|
363
388
|
].join(" && ");
|
|
364
389
|
const result = await dockerCli(
|
|
365
390
|
["exec", containerName, "sh", "-lc", steps],
|
package/lib/orchestrator.ts
CHANGED
|
@@ -52,6 +52,13 @@ import {
|
|
|
52
52
|
} from "./agent-cli";
|
|
53
53
|
import { setupBrowserTesting } from "./browser-testing";
|
|
54
54
|
import { injectCodexIntoContainer } from "./codex-auth";
|
|
55
|
+
import {
|
|
56
|
+
cooldownEngineAccount,
|
|
57
|
+
noteEngineAccountUsed,
|
|
58
|
+
pickEngineAccount,
|
|
59
|
+
provisionEngineAccounts,
|
|
60
|
+
resolveEngineAccounts,
|
|
61
|
+
} from "./engine-accounts";
|
|
55
62
|
import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
|
|
56
63
|
import { env } from "./env";
|
|
57
64
|
import type {
|
|
@@ -112,10 +119,21 @@ interface Channel {
|
|
|
112
119
|
/** Agents with a reconcile-spawn in flight (ADR-049 mid-task adds) — guards
|
|
113
120
|
* against a concurrent ensure double-spawning the same new agent. */
|
|
114
121
|
spawning: Set<string>;
|
|
122
|
+
/** ADR-076: which engine account each agent's live session is bound to, so a
|
|
123
|
+
* rate-limit can cool THAT account and rotate off it. */
|
|
124
|
+
accountByAgent: Map<string, string>;
|
|
125
|
+
/** ADR-076: the last prompt delivered to each agent, re-sent after an
|
|
126
|
+
* account rotation so the interrupted turn resumes on the new account. */
|
|
127
|
+
lastPrompt: Map<string, string>;
|
|
128
|
+
/** ADR-076: per-agent account-rotation budget (reset on a successful turn) —
|
|
129
|
+
* bounds a rate-limit storm across accounts, mirroring the respawn budget. */
|
|
130
|
+
rotations: Map<string, number>;
|
|
115
131
|
}
|
|
116
132
|
|
|
117
133
|
/** Hard cap on automatic respawns per agent per channel lifetime. */
|
|
118
134
|
const MAX_RESPAWNS_PER_AGENT = 5;
|
|
135
|
+
/** ADR-076: hard cap on account rotations per agent between successful turns. */
|
|
136
|
+
const MAX_ROTATIONS_PER_AGENT = 6;
|
|
119
137
|
/** A burned respawn budget resets after this quiet period (see reconcile). */
|
|
120
138
|
const RESPAWN_COOLDOWN_MS = 10 * 60_000;
|
|
121
139
|
|
|
@@ -266,6 +284,9 @@ class Orchestrator {
|
|
|
266
284
|
sharedFiles: spec.sharedFiles ?? "ro",
|
|
267
285
|
mcpConnections: spec.mcpConnections ?? [],
|
|
268
286
|
spawning: new Set(),
|
|
287
|
+
accountByAgent: new Map(),
|
|
288
|
+
lastPrompt: new Map(),
|
|
289
|
+
rotations: new Map(),
|
|
269
290
|
};
|
|
270
291
|
this.channels.set(taskId, channel);
|
|
271
292
|
return channel;
|
|
@@ -410,18 +431,28 @@ class Orchestrator {
|
|
|
410
431
|
const cliSecret = loadTaskCliSecret(channel.taskId);
|
|
411
432
|
writeAgentCli(channel.taskId, channel.roster, apiUrl);
|
|
412
433
|
|
|
434
|
+
// ADR-076: extra config-dir accounts for the newly-spawning agents.
|
|
435
|
+
await provisionEngineAccounts(
|
|
436
|
+
channel.containerName,
|
|
437
|
+
missing.map((a) => a.kind),
|
|
438
|
+
);
|
|
439
|
+
|
|
413
440
|
for (const agent of missing) {
|
|
414
441
|
const session = await this.factory.create({
|
|
415
442
|
taskId: channel.taskId,
|
|
416
443
|
agent,
|
|
417
444
|
containerName: channel.containerName,
|
|
418
445
|
systemPreamble: channel.preambles.get(agent.id) ?? "",
|
|
419
|
-
agentEnv:
|
|
420
|
-
channel
|
|
446
|
+
agentEnv: this.accountAgentEnv(
|
|
447
|
+
channel,
|
|
421
448
|
agent,
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
449
|
+
agentCliEnv(
|
|
450
|
+
channel.taskId,
|
|
451
|
+
agent,
|
|
452
|
+
task.ownerUserId,
|
|
453
|
+
apiUrl,
|
|
454
|
+
cliSecret,
|
|
455
|
+
),
|
|
425
456
|
),
|
|
426
457
|
});
|
|
427
458
|
channel.sessions.set(agent.id, session);
|
|
@@ -434,6 +465,28 @@ class Orchestrator {
|
|
|
434
465
|
}
|
|
435
466
|
}
|
|
436
467
|
|
|
468
|
+
/**
|
|
469
|
+
* ADR-076: per-agent exec env = the uai token base + the SELECTED engine
|
|
470
|
+
* account's env. Picks the least-recently-used, non-cooling account for the
|
|
471
|
+
* agent's kind and records it so a rate-limit can rotate off it. With zero
|
|
472
|
+
* configured accounts (unknown kind) this is a no-op — `base` passes through
|
|
473
|
+
* unchanged, so behavior is identical to before.
|
|
474
|
+
*/
|
|
475
|
+
private accountAgentEnv(
|
|
476
|
+
channel: Channel,
|
|
477
|
+
agent: RosterAgent,
|
|
478
|
+
base: Record<string, string>,
|
|
479
|
+
): Record<string, string> {
|
|
480
|
+
const account = pickEngineAccount(agent.kind);
|
|
481
|
+
if (!account) {
|
|
482
|
+
channel.accountByAgent.delete(agent.id);
|
|
483
|
+
return base;
|
|
484
|
+
}
|
|
485
|
+
channel.accountByAgent.set(agent.id, account.id);
|
|
486
|
+
noteEngineAccountUsed(account.id);
|
|
487
|
+
return { ...base, ...account.execEnv };
|
|
488
|
+
}
|
|
489
|
+
|
|
437
490
|
private async startSessions(channel: Channel): Promise<boolean> {
|
|
438
491
|
const task = getHostTask(channel.taskId);
|
|
439
492
|
if (!task || task.statusMirror !== "running") return false;
|
|
@@ -490,13 +543,25 @@ class Orchestrator {
|
|
|
490
543
|
const cliSecret = loadTaskCliSecret(channel.taskId);
|
|
491
544
|
writeAgentCli(channel.taskId, channel.roster, apiUrl);
|
|
492
545
|
|
|
546
|
+
// ADR-076: copy EXTRA config-dir engine accounts into their per-account
|
|
547
|
+
// container dirs (chowned) before sessions spawn — the default account's
|
|
548
|
+
// dir is already handled by task-up.sh. Best-effort per account.
|
|
549
|
+
await provisionEngineAccounts(
|
|
550
|
+
channel.containerName,
|
|
551
|
+
channel.roster.map((a) => a.kind),
|
|
552
|
+
);
|
|
553
|
+
|
|
493
554
|
for (const agent of channel.roster) {
|
|
494
555
|
const session = await this.factory.create({
|
|
495
556
|
taskId: channel.taskId,
|
|
496
557
|
agent,
|
|
497
558
|
containerName: channel.containerName,
|
|
498
559
|
systemPreamble: channel.preambles.get(agent.id) ?? "",
|
|
499
|
-
agentEnv:
|
|
560
|
+
agentEnv: this.accountAgentEnv(
|
|
561
|
+
channel,
|
|
562
|
+
agent,
|
|
563
|
+
agentCliEnv(channel.taskId, agent, task.ownerUserId, apiUrl, cliSecret),
|
|
564
|
+
),
|
|
500
565
|
});
|
|
501
566
|
channel.sessions.set(agent.id, session);
|
|
502
567
|
session.onEvent((event) => {
|
|
@@ -553,7 +618,11 @@ class Orchestrator {
|
|
|
553
618
|
const session = channel.sessions.get(agentId);
|
|
554
619
|
if (!session) return { ok: false, error: `no such agent: ${agentId}` };
|
|
555
620
|
|
|
556
|
-
|
|
621
|
+
const prompt = rewriteAttachmentRefs(text);
|
|
622
|
+
// ADR-076: remember the in-flight prompt so an account rotation on a
|
|
623
|
+
// rate-limit can re-deliver it to the fresh session.
|
|
624
|
+
channel.lastPrompt.set(agentId, prompt);
|
|
625
|
+
void session.send(prompt);
|
|
557
626
|
return { ok: true };
|
|
558
627
|
}
|
|
559
628
|
|
|
@@ -674,6 +743,16 @@ class Orchestrator {
|
|
|
674
743
|
// session so the user keeps working instead of staring at a dead
|
|
675
744
|
// chat.
|
|
676
745
|
const agent = channel.roster.find((a) => a.id === agentId);
|
|
746
|
+
// ADR-076: a rate-limit/usage-cap error fails over to another account
|
|
747
|
+
// for this kind (cool the current one, recreate the session on the
|
|
748
|
+
// next, re-deliver the in-flight prompt). No-op with <2 accounts.
|
|
749
|
+
if (
|
|
750
|
+
event.retryable === "rate_limit" &&
|
|
751
|
+
agent &&
|
|
752
|
+
(await this.rotateAccount(channel, agentId, agent))
|
|
753
|
+
) {
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
677
756
|
const isClaude = agent?.kind === "claude";
|
|
678
757
|
const configMissing = CLAUDE_CONFIG_MISSING_PATTERNS.some((re) =>
|
|
679
758
|
re.test(event.message),
|
|
@@ -710,6 +789,9 @@ class Orchestrator {
|
|
|
710
789
|
// the cloud discards the buffer instead of handing it to peers.
|
|
711
790
|
const aborted = channel.interrupted.delete(agentId);
|
|
712
791
|
channel.openTurns.delete(agentId);
|
|
792
|
+
// ADR-076: a turn that completed means the current account is healthy —
|
|
793
|
+
// reset its rotation budget so a LATER rate-limit gets a fresh failover.
|
|
794
|
+
channel.rotations.delete(agentId);
|
|
713
795
|
this.emitHost({
|
|
714
796
|
kind: "agent.turn_complete",
|
|
715
797
|
taskId: channel.taskId,
|
|
@@ -793,6 +875,85 @@ class Orchestrator {
|
|
|
793
875
|
});
|
|
794
876
|
}
|
|
795
877
|
|
|
878
|
+
/**
|
|
879
|
+
* ADR-076: fail over an agent to another account for its kind after a
|
|
880
|
+
* rate-limit. Cools the current account, recreates the session bound to the
|
|
881
|
+
* next healthy account (provisioning its config-dir first), and re-delivers
|
|
882
|
+
* the in-flight prompt. Bounded by MAX_ROTATIONS_PER_AGENT. Returns true when
|
|
883
|
+
* it rotated (the error is handled); false to let normal error handling run
|
|
884
|
+
* (no second account, budget exhausted, or the container is gone).
|
|
885
|
+
*/
|
|
886
|
+
private async rotateAccount(
|
|
887
|
+
channel: Channel,
|
|
888
|
+
agentId: string,
|
|
889
|
+
agent: RosterAgent,
|
|
890
|
+
): Promise<boolean> {
|
|
891
|
+
if (resolveEngineAccounts(agent.kind).length < 2) return false;
|
|
892
|
+
|
|
893
|
+
const budget = (channel.rotations.get(agentId) ?? 0) + 1;
|
|
894
|
+
channel.rotations.set(agentId, budget);
|
|
895
|
+
if (budget > MAX_ROTATIONS_PER_AGENT) return false;
|
|
896
|
+
|
|
897
|
+
const current = channel.accountByAgent.get(agentId);
|
|
898
|
+
if (current) cooldownEngineAccount(current);
|
|
899
|
+
const next = pickEngineAccount(agent.kind, {
|
|
900
|
+
exclude: current ? new Set([current]) : undefined,
|
|
901
|
+
});
|
|
902
|
+
if (!next) return false;
|
|
903
|
+
|
|
904
|
+
const task = getHostTask(channel.taskId);
|
|
905
|
+
if (!task || task.statusMirror !== "running") return false;
|
|
906
|
+
|
|
907
|
+
// Tear the rate-limited session down (its `closed` flag suppresses its own
|
|
908
|
+
// exit, so no respawn-budget noise races this rotation).
|
|
909
|
+
const old = channel.sessions.get(agentId);
|
|
910
|
+
channel.sessions.delete(agentId);
|
|
911
|
+
if (old) {
|
|
912
|
+
try {
|
|
913
|
+
await old.close();
|
|
914
|
+
} catch {
|
|
915
|
+
/* already gone — close is idempotent for our adapters */
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Extra config-dir accounts need their dir copied in before use.
|
|
920
|
+
await provisionEngineAccounts(channel.containerName, [agent.kind]);
|
|
921
|
+
|
|
922
|
+
const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
|
|
923
|
+
const cliSecret = loadTaskCliSecret(channel.taskId);
|
|
924
|
+
const base = agentCliEnv(
|
|
925
|
+
channel.taskId,
|
|
926
|
+
agent,
|
|
927
|
+
task.ownerUserId,
|
|
928
|
+
apiUrl,
|
|
929
|
+
cliSecret,
|
|
930
|
+
);
|
|
931
|
+
channel.accountByAgent.set(agentId, next.id);
|
|
932
|
+
noteEngineAccountUsed(next.id);
|
|
933
|
+
|
|
934
|
+
const session = await this.factory.create({
|
|
935
|
+
taskId: channel.taskId,
|
|
936
|
+
agent,
|
|
937
|
+
containerName: channel.containerName,
|
|
938
|
+
systemPreamble: channel.preambles.get(agentId) ?? "",
|
|
939
|
+
agentEnv: { ...base, ...next.execEnv },
|
|
940
|
+
});
|
|
941
|
+
channel.sessions.set(agentId, session);
|
|
942
|
+
session.onEvent((event) => {
|
|
943
|
+
void this.handleAgentEvent(channel, agentId, event);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
this.emitSystemNote(
|
|
947
|
+
channel.taskId,
|
|
948
|
+
`${agentId}: hit a rate limit — switched to another ${agent.kind} account and retried.`,
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
// Re-deliver the in-flight prompt so the interrupted turn resumes.
|
|
952
|
+
const prompt = channel.lastPrompt.get(agentId);
|
|
953
|
+
if (prompt) void session.send(prompt);
|
|
954
|
+
return true;
|
|
955
|
+
}
|
|
956
|
+
|
|
796
957
|
// -- permission resolution ------------------------------------------------
|
|
797
958
|
|
|
798
959
|
async resolvePermission(
|
package/lib/standard-image.ts
CHANGED
|
@@ -81,11 +81,13 @@ export function configuredOptionalEngines(): {
|
|
|
81
81
|
kimi: boolean;
|
|
82
82
|
grok: boolean;
|
|
83
83
|
cursor: boolean;
|
|
84
|
+
opencode: boolean;
|
|
84
85
|
} {
|
|
85
86
|
return {
|
|
86
87
|
kimi: detectEngine("kimi"),
|
|
87
88
|
grok: detectEngine("grok"),
|
|
88
89
|
cursor: detectEngine("cursor"),
|
|
90
|
+
opencode: detectEngine("opencode"),
|
|
89
91
|
};
|
|
90
92
|
}
|
|
91
93
|
|
|
@@ -397,9 +399,11 @@ async function ensureStandardImageInner(): Promise<StandardImageResult> {
|
|
|
397
399
|
`INSTALL_GROK=${engines.grok ? 1 : 0}`,
|
|
398
400
|
"--build-arg",
|
|
399
401
|
`INSTALL_CURSOR=${engines.cursor ? 1 : 0}`,
|
|
402
|
+
"--build-arg",
|
|
403
|
+
`INSTALL_OPENCODE=${engines.opencode ? 1 : 0}`,
|
|
400
404
|
];
|
|
401
405
|
const contextHash = await hashBuildContext(
|
|
402
|
-
`kimi=${engines.kimi ? 1 : 0};grok=${engines.grok ? 1 : 0};cursor=${engines.cursor ? 1 : 0}`,
|
|
406
|
+
`kimi=${engines.kimi ? 1 : 0};grok=${engines.grok ? 1 : 0};cursor=${engines.cursor ? 1 : 0};opencode=${engines.opencode ? 1 : 0}`,
|
|
403
407
|
);
|
|
404
408
|
const inspect = await run("docker", [
|
|
405
409
|
"image",
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -554,6 +554,23 @@ done
|
|
|
554
554
|
docker exec -u root "$app_container" \
|
|
555
555
|
chown -R node:node /home/node/.grok >/dev/null 2>&1 || true
|
|
556
556
|
|
|
557
|
+
# Copy OpenCode provider auth into a task-private /home/node/.local/share/opencode
|
|
558
|
+
# (ADR-077), same per-task-writable-copy pattern as Codex. The Linux `opencode`
|
|
559
|
+
# binary is baked into the image; here we copy the arch-independent auth+config.
|
|
560
|
+
# ADR-076 EXTRA accounts (isolated dirs) are copied later from the host-agent
|
|
561
|
+
# (provisionEngineAccounts) since they live in the sealed DB, not on disk here.
|
|
562
|
+
docker exec -u root "$app_container" \
|
|
563
|
+
mkdir -p /home/node/.local/share/opencode >/dev/null 2>&1 || true
|
|
564
|
+
for oc_item in auth.json config.json; do
|
|
565
|
+
if [ -e "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" ]; then
|
|
566
|
+
docker cp "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" \
|
|
567
|
+
"$app_container":/home/node/.local/share/opencode/ >/dev/null 2>&1 \
|
|
568
|
+
|| log "warning: docker cp of opencode/$oc_item failed; opencode may need re-login"
|
|
569
|
+
fi
|
|
570
|
+
done
|
|
571
|
+
docker exec -u root "$app_container" \
|
|
572
|
+
chown -R node:node /home/node/.local/share/opencode >/dev/null 2>&1 || true
|
|
573
|
+
|
|
557
574
|
# Copy the same resolved SSH identity (task creator's per-user key when present,
|
|
558
575
|
# else the operator identity — see above) into the container, so the agent signs
|
|
559
576
|
# + pushes with the key whose .pub the user registered on GitHub. ADR-027 drops
|
package/src/ui/server.ts
CHANGED
|
@@ -36,6 +36,11 @@ import {
|
|
|
36
36
|
engineStatuses,
|
|
37
37
|
isEngineKind,
|
|
38
38
|
} from "../../lib/engines";
|
|
39
|
+
import {
|
|
40
|
+
addEngineAccount,
|
|
41
|
+
listEngineAccounts,
|
|
42
|
+
removeEngineAccount,
|
|
43
|
+
} from "../../lib/engine-accounts";
|
|
39
44
|
import { ensureStandardImage } from "../../lib/standard-image";
|
|
40
45
|
import {
|
|
41
46
|
CloudResponse,
|
|
@@ -157,6 +162,10 @@ async function handle(
|
|
|
157
162
|
return await handleEngineInstall(req, res);
|
|
158
163
|
case "/api/engines/disconnect":
|
|
159
164
|
return await handleEngineDisconnect(req, res, opts);
|
|
165
|
+
case "/api/engines/accounts/add":
|
|
166
|
+
return await handleEngineAccountAdd(req, res, opts);
|
|
167
|
+
case "/api/engines/accounts/remove":
|
|
168
|
+
return await handleEngineAccountRemove(req, res, opts);
|
|
160
169
|
case "/api/tasks/stop":
|
|
161
170
|
return await handleTaskStop(req, res, opts);
|
|
162
171
|
}
|
|
@@ -198,11 +207,19 @@ async function handle(
|
|
|
198
207
|
|
|
199
208
|
// --- engines ----------------------------------------------------------------
|
|
200
209
|
|
|
210
|
+
/** Engine kinds that participate in the multi-account model (ADR-076). */
|
|
211
|
+
const ACCOUNT_KINDS = ["claude", "codex", "opencode"] as const;
|
|
212
|
+
|
|
201
213
|
async function enginesBody(): Promise<EnginesResponse> {
|
|
214
|
+
const accounts: Record<string, ReturnType<typeof listEngineAccounts>> = {};
|
|
215
|
+
for (const kind of ACCOUNT_KINDS) {
|
|
216
|
+
accounts[kind] = listEngineAccounts(kind);
|
|
217
|
+
}
|
|
202
218
|
return {
|
|
203
219
|
catalog: engineCatalog(),
|
|
204
220
|
statuses: engineStatuses(),
|
|
205
221
|
cli: await engineCliStatuses(),
|
|
222
|
+
accounts,
|
|
206
223
|
};
|
|
207
224
|
}
|
|
208
225
|
|
|
@@ -311,6 +328,54 @@ async function handleEngineDisconnect(
|
|
|
311
328
|
return sendJson(res, EngineOpResponse, { ok: true });
|
|
312
329
|
}
|
|
313
330
|
|
|
331
|
+
/**
|
|
332
|
+
* POST /api/engines/accounts/add `{kind, label, apiKey?|token?}` → register an
|
|
333
|
+
* EXTRA account (ADR-076) by pasting a token / API key, then re-advertise (the
|
|
334
|
+
* kind may not have been available before) + rebuild the image if needed.
|
|
335
|
+
*/
|
|
336
|
+
async function handleEngineAccountAdd(
|
|
337
|
+
req: IncomingMessage,
|
|
338
|
+
res: ServerResponse,
|
|
339
|
+
opts: UiServerOptions,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
const body = await readJsonBody(req);
|
|
342
|
+
const kind = body?.kind;
|
|
343
|
+
if (!isEngineKind(kind)) {
|
|
344
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
345
|
+
}
|
|
346
|
+
const label = typeof body?.label === "string" ? body.label : "";
|
|
347
|
+
const apiKey = typeof body?.apiKey === "string" ? body.apiKey : undefined;
|
|
348
|
+
const token = typeof body?.token === "string" ? body.token : undefined;
|
|
349
|
+
const result = addEngineAccount(kind, label, { apiKey, token });
|
|
350
|
+
if (result.ok) {
|
|
351
|
+
opts.readvertise?.();
|
|
352
|
+
void ensureStandardImage();
|
|
353
|
+
}
|
|
354
|
+
return sendJson(res, EngineOpResponse, {
|
|
355
|
+
ok: result.ok,
|
|
356
|
+
message: result.message,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** POST /api/engines/accounts/remove `{id}` → forget an EXTRA account. */
|
|
361
|
+
async function handleEngineAccountRemove(
|
|
362
|
+
req: IncomingMessage,
|
|
363
|
+
res: ServerResponse,
|
|
364
|
+
opts: UiServerOptions,
|
|
365
|
+
): Promise<void> {
|
|
366
|
+
const body = await readJsonBody(req);
|
|
367
|
+
const id = body?.id;
|
|
368
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
369
|
+
return sendError(res, 400, "missing account id");
|
|
370
|
+
}
|
|
371
|
+
const result = removeEngineAccount(id);
|
|
372
|
+
if (result.ok) opts.readvertise?.();
|
|
373
|
+
return sendJson(res, EngineOpResponse, {
|
|
374
|
+
ok: result.ok,
|
|
375
|
+
message: result.message,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
314
379
|
/** POST /api/tasks/stop `{taskId}` → compose-stop the task (resumable). */
|
|
315
380
|
async function handleTaskStop(
|
|
316
381
|
req: IncomingMessage,
|
package/src/ui/types.ts
CHANGED
|
@@ -76,9 +76,14 @@ export type UsersResponse = z.infer<typeof UsersResponse>;
|
|
|
76
76
|
|
|
77
77
|
// GET /api/engines — the engine catalog + which are connected on this host.
|
|
78
78
|
export const EngineCatalogEntry = z.object({
|
|
79
|
-
kind: z.enum(["claude", "codex", "kimi", "grok", "cursor"]),
|
|
79
|
+
kind: z.enum(["claude", "codex", "kimi", "grok", "cursor", "opencode"]),
|
|
80
80
|
label: z.string(),
|
|
81
|
-
authMode: z.enum([
|
|
81
|
+
authMode: z.enum([
|
|
82
|
+
"token-command",
|
|
83
|
+
"login-command",
|
|
84
|
+
"api-key",
|
|
85
|
+
"external-login",
|
|
86
|
+
]),
|
|
82
87
|
notes: z.string().nullable(),
|
|
83
88
|
getKeyUrl: z.string().nullable(),
|
|
84
89
|
// Pasted-API-key alternative to the login/token flow (null = not offered).
|
|
@@ -86,13 +91,28 @@ export const EngineCatalogEntry = z.object({
|
|
|
86
91
|
apiKeyUrl: z.string().nullable(),
|
|
87
92
|
// Install command shown when the CLI is missing (null = nothing to install).
|
|
88
93
|
installHint: z.string().nullable(),
|
|
94
|
+
// external-login: the command the owner runs in their terminal (null else).
|
|
95
|
+
loginCmd: z.string().nullable(),
|
|
89
96
|
});
|
|
90
97
|
export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
|
|
91
98
|
|
|
99
|
+
// One engine account (ADR-076) — secret-free descriptor for the host UI.
|
|
100
|
+
export const EngineAccountInfo = z.object({
|
|
101
|
+
id: z.string(),
|
|
102
|
+
kind: z.string(),
|
|
103
|
+
label: z.string(),
|
|
104
|
+
authKind: z.enum(["env", "config-dir"]),
|
|
105
|
+
isDefault: z.boolean(),
|
|
106
|
+
});
|
|
107
|
+
export type EngineAccountInfo = z.infer<typeof EngineAccountInfo>;
|
|
108
|
+
|
|
92
109
|
export const EnginesResponse = z.object({
|
|
93
110
|
catalog: z.array(EngineCatalogEntry),
|
|
94
111
|
statuses: z.record(z.boolean()), // kind → connected
|
|
95
112
|
cli: z.record(z.boolean()), // kind → CLI resolvable on this host
|
|
113
|
+
// kind → its accounts (default + extras), ADR-076. Kinds with no account
|
|
114
|
+
// model (kimi/grok/cursor) are absent.
|
|
115
|
+
accounts: z.record(z.array(EngineAccountInfo)),
|
|
96
116
|
});
|
|
97
117
|
export type EnginesResponse = z.infer<typeof EnginesResponse>;
|
|
98
118
|
|