@runuai/host 0.8.13 → 0.8.16
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/lib/codex-auth.ts +112 -28
- package/lib/engines.ts +307 -10
- package/lib/orchestrator.ts +25 -2
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +19 -11
- package/src/main.ts +8 -2
- package/src/ui/server.ts +46 -3
- package/src/ui/types.ts +3 -0
- package/ui/app.js +62 -9
package/lib/codex-auth.ts
CHANGED
|
@@ -9,12 +9,24 @@
|
|
|
9
9
|
* re-login otherwise only helps NEW tasks. This re-copies the fresh `~/.codex`
|
|
10
10
|
* into the running task containers so live tasks self-heal.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Three triggers, covering every path:
|
|
13
13
|
* - {@link watchCodexAuth} — fs.watch on `~/.codex`, for `codex login` run
|
|
14
14
|
* from a terminal while the host stays up.
|
|
15
15
|
* - {@link reinjectCodexRunningTasks} at host start — for the desktop
|
|
16
16
|
* "Connect Codex" flow, which writes `~/.codex` then restarts the host
|
|
17
17
|
* (durable sessions reattach to containers that still hold the old creds).
|
|
18
|
+
* Sequenced AFTER boot recovery (main.ts awaits recoveryComplete()).
|
|
19
|
+
* - {@link injectCodexIntoContainer} — recovery calls it right after
|
|
20
|
+
* `docker start` of an exited container, before agents can respawn.
|
|
21
|
+
*
|
|
22
|
+
* Copies go ONLY into containers docker confirms are RUNNING. A DB-active row
|
|
23
|
+
* can be a stopped container (boot, pre-recovery), and `docker cp` into a
|
|
24
|
+
* stopped container SUCCEEDS while the `docker exec … chown` that follows
|
|
25
|
+
* cannot run — stranding auth.json/config.toml mode 0600 owned by the host
|
|
26
|
+
* uid (macOS 501:20), unreadable to the container's `node` (1000:1000), and
|
|
27
|
+
* Codex dies at startup. Every docker step's exit status is checked; failures
|
|
28
|
+
* surface as actionable errors (never credential contents — only file NAMES
|
|
29
|
+
* and docker stderr are logged).
|
|
18
30
|
*
|
|
19
31
|
* Codex auth is host-wide (the operator's single `~/.codex`, not per-user), so
|
|
20
32
|
* this targets ALL running tasks — matching task-up, which copies it into every
|
|
@@ -43,8 +55,11 @@ const EXEC_TIMEOUT_MS = 15_000;
|
|
|
43
55
|
/** Injectable seams (defaults hit the real DB / docker / fs) so the copy logic
|
|
44
56
|
* is testable without either. */
|
|
45
57
|
export interface CodexDeps {
|
|
46
|
-
exec?: (
|
|
47
|
-
|
|
58
|
+
exec?: (
|
|
59
|
+
args: string[],
|
|
60
|
+
) => Promise<{ status: number | null; stdout: string; stderr: string }>;
|
|
61
|
+
/** DB-active task containers — may include stopped ones; docker decides. */
|
|
62
|
+
activeTaskContainers?: () => string[];
|
|
48
63
|
fileExists?: (path: string) => boolean;
|
|
49
64
|
}
|
|
50
65
|
|
|
@@ -54,10 +69,10 @@ function ownerCodexDir(): string {
|
|
|
54
69
|
|
|
55
70
|
const defaultExec: NonNullable<CodexDeps["exec"]> = async (args) => {
|
|
56
71
|
const res = await dockerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
|
|
57
|
-
return { status: res.status, stderr: res.stderr };
|
|
72
|
+
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
|
58
73
|
};
|
|
59
74
|
|
|
60
|
-
function
|
|
75
|
+
function defaultActiveTaskContainers(): string[] {
|
|
61
76
|
return getDb()
|
|
62
77
|
.select({ taskId: schema.hostTasks.taskId })
|
|
63
78
|
.from(schema.hostTasks)
|
|
@@ -66,50 +81,119 @@ function defaultRunningContainers(): string[] {
|
|
|
66
81
|
.map((r) => `task-${r.taskId}-app-1`);
|
|
67
82
|
}
|
|
68
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The containers docker reports as RUNNING right now, or null when docker
|
|
86
|
+
* itself didn't answer — the caller must then skip injection rather than
|
|
87
|
+
* docker-cp into containers of unknown state.
|
|
88
|
+
*/
|
|
89
|
+
async function dockerRunningNames(
|
|
90
|
+
exec: NonNullable<CodexDeps["exec"]>,
|
|
91
|
+
): Promise<Set<string> | null> {
|
|
92
|
+
const res = await exec(["ps", "--filter", "status=running", "--format", "{{.Names}}"]);
|
|
93
|
+
if (res.status !== 0) return null;
|
|
94
|
+
return new Set(
|
|
95
|
+
res.stdout
|
|
96
|
+
.split("\n")
|
|
97
|
+
.map((l) => l.trim())
|
|
98
|
+
.filter(Boolean),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
/**
|
|
70
103
|
* Re-copy `~/.codex/<items>` into one container — mirrors task-up.sh exactly:
|
|
71
|
-
* ensure the dir (root), cp each item that exists, chown back to node
|
|
72
|
-
*
|
|
73
|
-
*
|
|
104
|
+
* ensure the dir (root), cp each item that exists, chown back to node:node
|
|
105
|
+
* (docker cp preserves the HOST uid/gid, and 0600 files owned by macOS
|
|
106
|
+
* 501:20 are unreadable to the container's node). Any nonzero step throws
|
|
107
|
+
* with the step name and docker's stderr — never file contents.
|
|
74
108
|
*/
|
|
75
109
|
async function copyCodexInto(container: string, deps: CodexDeps): Promise<void> {
|
|
76
110
|
const exec = deps.exec ?? defaultExec;
|
|
77
111
|
const exists = deps.fileExists ?? existsSync;
|
|
78
112
|
const dir = ownerCodexDir();
|
|
79
|
-
|
|
113
|
+
const must = async (args: string[], step: string): Promise<void> => {
|
|
114
|
+
const res = await exec(args);
|
|
115
|
+
if (res.status !== 0) {
|
|
116
|
+
const detail = res.stderr.trim().slice(0, 200);
|
|
117
|
+
throw new Error(
|
|
118
|
+
`${step} failed (${res.status === null ? "docker timeout" : `exit ${res.status}`})` +
|
|
119
|
+
(detail ? `: ${detail}` : ""),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
await must(
|
|
124
|
+
["exec", "-u", "root", container, "mkdir", "-p", "/home/node/.codex"],
|
|
125
|
+
"mkdir /home/node/.codex",
|
|
126
|
+
);
|
|
80
127
|
for (const item of CODEX_ITEMS) {
|
|
81
128
|
const src = join(dir, item);
|
|
82
129
|
if (!exists(src)) continue;
|
|
83
|
-
await
|
|
130
|
+
await must(["cp", src, `${container}:/home/node/.codex/`], `docker cp ${item}`);
|
|
84
131
|
}
|
|
85
|
-
await
|
|
86
|
-
"exec",
|
|
87
|
-
"-
|
|
88
|
-
|
|
89
|
-
container,
|
|
90
|
-
"chown",
|
|
91
|
-
"-R",
|
|
92
|
-
"node:node",
|
|
93
|
-
"/home/node/.codex",
|
|
94
|
-
]);
|
|
132
|
+
await must(
|
|
133
|
+
["exec", "-u", "root", container, "chown", "-R", "node:node", "/home/node/.codex"],
|
|
134
|
+
"chown -R node:node /home/node/.codex",
|
|
135
|
+
);
|
|
95
136
|
}
|
|
96
137
|
|
|
97
138
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
139
|
+
* Inject the owner's `~/.codex` into ONE container known to be running.
|
|
140
|
+
* Recovery calls this immediately after `docker start`, BEFORE uai-init and
|
|
141
|
+
* any agent respawn, so a recovered container never runs Codex against
|
|
142
|
+
* host-uid-owned credentials. Returns false after logging an actionable
|
|
143
|
+
* error (no secrets) — recovery of the task itself must not fail over Codex.
|
|
144
|
+
*/
|
|
145
|
+
export async function injectCodexIntoContainer(
|
|
146
|
+
container: string,
|
|
147
|
+
deps: CodexDeps = {},
|
|
148
|
+
): Promise<boolean> {
|
|
149
|
+
const exists = deps.fileExists ?? existsSync;
|
|
150
|
+
if (!exists(join(ownerCodexDir(), "auth.json"))) return true; // nothing to inject
|
|
151
|
+
try {
|
|
152
|
+
await copyCodexInto(container, deps);
|
|
153
|
+
return true;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(
|
|
156
|
+
`[codex] inject into ${container}: ${err instanceof Error ? err.message : err} — ` +
|
|
157
|
+
`Codex in this task will fail until fixed (run: docker exec -u root ${container} ` +
|
|
158
|
+
`chown -R node:node /home/node/.codex, or restart the host)`,
|
|
159
|
+
);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Re-copy the freshly (re)logged-in `~/.codex` into every task container that
|
|
166
|
+
* is both DB-active AND confirmed running by docker. No-op when there is no
|
|
167
|
+
* `~/.codex/auth.json` or no active tasks; skipped entirely (with a warning)
|
|
168
|
+
* when docker doesn't answer. Per-container failures are surfaced and don't
|
|
169
|
+
* stop the sweep.
|
|
101
170
|
*/
|
|
102
171
|
export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<void> {
|
|
103
172
|
const exists = deps.fileExists ?? existsSync;
|
|
104
173
|
if (!exists(join(ownerCodexDir(), "auth.json"))) return;
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
174
|
+
const active = (deps.activeTaskContainers ?? defaultActiveTaskContainers)();
|
|
175
|
+
if (active.length === 0) return;
|
|
176
|
+
const running = await dockerRunningNames(deps.exec ?? defaultExec);
|
|
177
|
+
if (running === null) {
|
|
178
|
+
console.warn(
|
|
179
|
+
"[codex] reinject skipped — docker didn't answer `docker ps`; will retry on the next trigger",
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const targets = active.filter((c) => running.has(c));
|
|
184
|
+
const stopped = active.length - targets.length;
|
|
185
|
+
if (stopped > 0) {
|
|
186
|
+
console.log(
|
|
187
|
+
`[codex] ${stopped} active task(s) not running — skipped (recovery injects them at docker start)`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (targets.length === 0) return;
|
|
191
|
+
console.log(`[codex] re-copying ~/.codex into ${targets.length} running task(s)`);
|
|
192
|
+
for (const container of targets) {
|
|
109
193
|
try {
|
|
110
194
|
await copyCodexInto(container, deps);
|
|
111
195
|
} catch (err) {
|
|
112
|
-
console.
|
|
196
|
+
console.error(
|
|
113
197
|
`[codex] reinject into ${container} failed: ${err instanceof Error ? err.message : err}`,
|
|
114
198
|
);
|
|
115
199
|
}
|
package/lib/engines.ts
CHANGED
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
chmodSync,
|
|
64
64
|
existsSync,
|
|
65
65
|
mkdirSync,
|
|
66
|
+
readdirSync,
|
|
66
67
|
readFileSync,
|
|
67
68
|
rmSync,
|
|
68
69
|
writeFileSync,
|
|
@@ -88,6 +89,11 @@ export interface EngineCatalogEntry {
|
|
|
88
89
|
apiKeyHint: string | null;
|
|
89
90
|
/** Where to mint a key for the pasted-API-key alternative. */
|
|
90
91
|
apiKeyUrl: string | null;
|
|
92
|
+
/**
|
|
93
|
+
* Human-readable install command the UI shows next to "Install & connect"
|
|
94
|
+
* when the CLI is missing (null = nothing to install, e.g. Cursor).
|
|
95
|
+
*/
|
|
96
|
+
installHint: string | null;
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
interface EngineDescriptor {
|
|
@@ -240,18 +246,43 @@ function defaultPtyWrap(bin: string, args: string[]): PtyCommand | null {
|
|
|
240
246
|
}
|
|
241
247
|
|
|
242
248
|
/**
|
|
243
|
-
* The PATH the owner's
|
|
249
|
+
* The PATH the owner's shell exports. Probes an INTERACTIVE login shell
|
|
250
|
+
* first: nvm/asdf/fnm put their PATH setup in `.zshrc`/`.bashrc`, which a
|
|
251
|
+
* plain login shell (`-lc`) never sources — that gap hid `npm i -g` installs
|
|
252
|
+
* from the probe. Falls back to `-lc` for profiles that misbehave when run
|
|
253
|
+
* interactively without a terminal. Null on failure of both; callers fall
|
|
254
|
+
* back to the inherited PATH.
|
|
255
|
+
*
|
|
256
|
+
* Cached (module-level, 5 min): the CLI-found statuses probe on every
|
|
257
|
+
* /api/engines poll and shelling out each time would be rude. Staleness is
|
|
258
|
+
* harmless — every installer this module runs lands in a candidate dir,
|
|
259
|
+
* which is checked before the probe.
|
|
260
|
+
*/
|
|
261
|
+
async function probeLoginShellPath(): Promise<string | null> {
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
if (loginPathCache && now - loginPathCache.at < LOGIN_PATH_CACHE_MS) {
|
|
264
|
+
return loginPathCache.value;
|
|
265
|
+
}
|
|
266
|
+
const value = (await shellPath("-lic")) ?? (await shellPath("-lc"));
|
|
267
|
+
loginPathCache = { value, at: now };
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let loginPathCache: { value: string | null; at: number } | null = null;
|
|
272
|
+
const LOGIN_PATH_CACHE_MS = 5 * 60_000;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Run `$SHELL <flags> /usr/bin/env` and pull PATH out. `/usr/bin/env` (not
|
|
244
276
|
* `echo $PATH`) so the value is the real colon-joined exported PATH under any
|
|
245
|
-
* shell — fish's `$PATH` is a list and would echo space-joined — and
|
|
246
|
-
* LAST `PATH=` line so profile echo noise can't spoof it.
|
|
247
|
-
* callers fall back to the inherited PATH.
|
|
277
|
+
* shell — fish's `$PATH` is a list and would echo space-joined — and the
|
|
278
|
+
* LAST `PATH=` line wins so profile echo noise can't spoof it.
|
|
248
279
|
*/
|
|
249
|
-
function
|
|
280
|
+
function shellPath(flags: string): Promise<string | null> {
|
|
250
281
|
return new Promise((resolve) => {
|
|
251
282
|
const shell = process.env.SHELL?.trim() || "/bin/zsh";
|
|
252
283
|
nodeExecFile(
|
|
253
284
|
shell,
|
|
254
|
-
[
|
|
285
|
+
[flags, "/usr/bin/env"],
|
|
255
286
|
{ timeout: 8_000 },
|
|
256
287
|
(err, stdout) => {
|
|
257
288
|
if (err) return resolve(null);
|
|
@@ -285,10 +316,22 @@ export function engineCatalog(): EngineCatalogEntry[] {
|
|
|
285
316
|
getKeyUrl: d.getKeyUrl ?? null,
|
|
286
317
|
apiKeyHint: d.apiKeyHint ?? null,
|
|
287
318
|
apiKeyUrl: d.apiKeyUrl ?? null,
|
|
319
|
+
installHint:
|
|
320
|
+
kind === "codex" ? CODEX_INSTALL_HINT : (INSTALLERS[kind] ?? null),
|
|
288
321
|
};
|
|
289
322
|
});
|
|
290
323
|
}
|
|
291
324
|
|
|
325
|
+
/** kind → whether the engine's CLI is resolvable on this host right now. */
|
|
326
|
+
export async function engineCliStatuses(
|
|
327
|
+
seams: Partial<EngineSeams> = {},
|
|
328
|
+
): Promise<Record<EngineKind, boolean>> {
|
|
329
|
+
const s = withDefaults(seams);
|
|
330
|
+
const out = {} as Record<EngineKind, boolean>;
|
|
331
|
+
for (const kind of ORDER) out[kind] = await engineCliFound(kind, s);
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
292
335
|
/** kind → connected, for every engine. */
|
|
293
336
|
export function engineStatuses(
|
|
294
337
|
seams: Partial<EngineSeams> = {},
|
|
@@ -474,6 +517,209 @@ export function disconnectEngine(
|
|
|
474
517
|
}
|
|
475
518
|
}
|
|
476
519
|
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// Install a missing engine CLI (ADR-072).
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Pinned installer one-liners — the same vendor installers the standard task
|
|
526
|
+
* image already trusts (host-agent/images/standard/Dockerfile), shown
|
|
527
|
+
* VERBATIM in the UI before running. Every one lands its binary in a
|
|
528
|
+
* candidateBins dir, so the next resolve finds it with no restart:
|
|
529
|
+
* claude → ~/.local/bin, kimi → ~/.kimi-code/bin, grok → ~/.grok/bin.
|
|
530
|
+
* Codex has no vendor script — resolved at run time (brew, then npm).
|
|
531
|
+
*/
|
|
532
|
+
const INSTALLERS: Partial<Record<EngineKind, string>> = {
|
|
533
|
+
claude: "curl -fsSL https://claude.ai/install.sh | bash",
|
|
534
|
+
kimi: "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
|
|
535
|
+
grok: "curl -fsSL https://x.ai/cli/install.sh | bash",
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const CODEX_INSTALL_HINT = "brew install codex — or: npm i -g @openai/codex";
|
|
539
|
+
|
|
540
|
+
/** Installers can compile/download; give them real time. */
|
|
541
|
+
const INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
542
|
+
|
|
543
|
+
export interface InstallResult {
|
|
544
|
+
ok: boolean;
|
|
545
|
+
message: string;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Install an engine's CLI with its pinned installer, streaming output to
|
|
550
|
+
* `onLog`. Refuses when there's nothing to install (api-key engines) and
|
|
551
|
+
* short-circuits when the CLI is already resolvable. Success = the installer
|
|
552
|
+
* exits 0 AND the CLI resolves afterwards.
|
|
553
|
+
*/
|
|
554
|
+
export async function installEngineCli(
|
|
555
|
+
kind: EngineKind,
|
|
556
|
+
onLog: (line: string) => void,
|
|
557
|
+
seams: Partial<EngineSeams> = {},
|
|
558
|
+
): Promise<InstallResult> {
|
|
559
|
+
const s = withDefaults(seams);
|
|
560
|
+
const d = DESCRIPTORS[kind];
|
|
561
|
+
if (d.authMode === "api-key") {
|
|
562
|
+
return {
|
|
563
|
+
ok: false,
|
|
564
|
+
message: `${d.label} doesn't use a CLI on the host — paste an API key instead.`,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
if (await engineCliFound(kind, s)) {
|
|
568
|
+
return { ok: true, message: `${d.label} CLI is already installed.` };
|
|
569
|
+
}
|
|
570
|
+
const inv = await resolveInstaller(kind, s);
|
|
571
|
+
if (!inv) {
|
|
572
|
+
return {
|
|
573
|
+
ok: false,
|
|
574
|
+
message:
|
|
575
|
+
"No way to install Codex automatically: install Homebrew or Node.js first, then `brew install codex` or `npm i -g @openai/codex`.",
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
let child: ChildProcess;
|
|
579
|
+
try {
|
|
580
|
+
child = s.spawn(inv.command, inv.args, {
|
|
581
|
+
...s.procEnv,
|
|
582
|
+
...(inv.path ? { PATH: inv.path } : {}),
|
|
583
|
+
});
|
|
584
|
+
} catch (err) {
|
|
585
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
586
|
+
}
|
|
587
|
+
return new Promise((resolve) => {
|
|
588
|
+
let settled = false;
|
|
589
|
+
let timer: NodeJS.Timeout | undefined;
|
|
590
|
+
const done = (r: InstallResult, kill = false): void => {
|
|
591
|
+
if (settled) return;
|
|
592
|
+
settled = true;
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
if (kill) {
|
|
595
|
+
try {
|
|
596
|
+
child.kill();
|
|
597
|
+
} catch {
|
|
598
|
+
/* already gone */
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
resolve(r);
|
|
602
|
+
};
|
|
603
|
+
const relay = createLineRelay(onLog);
|
|
604
|
+
child.stdout?.on("data", relay);
|
|
605
|
+
child.stderr?.on("data", relay);
|
|
606
|
+
child.on("error", (err: NodeJS.ErrnoException) =>
|
|
607
|
+
done({ ok: false, message: err.message }),
|
|
608
|
+
);
|
|
609
|
+
child.on("exit", (code) => {
|
|
610
|
+
void (async () => {
|
|
611
|
+
if (code !== 0) {
|
|
612
|
+
done({
|
|
613
|
+
ok: false,
|
|
614
|
+
message: `Install failed (exit ${code ?? "?"}) — see the log above.`,
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
done(
|
|
619
|
+
(await engineCliFound(kind, s))
|
|
620
|
+
? { ok: true, message: `${d.label} CLI installed.` }
|
|
621
|
+
: {
|
|
622
|
+
ok: false,
|
|
623
|
+
message:
|
|
624
|
+
"The installer finished but the CLI still isn't findable — install it manually, then try Connect.",
|
|
625
|
+
},
|
|
626
|
+
);
|
|
627
|
+
})();
|
|
628
|
+
});
|
|
629
|
+
timer = setTimeout(
|
|
630
|
+
() =>
|
|
631
|
+
done(
|
|
632
|
+
{ ok: false, message: "The installer timed out — see the log above." },
|
|
633
|
+
true,
|
|
634
|
+
),
|
|
635
|
+
INSTALL_TIMEOUT_MS,
|
|
636
|
+
);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
interface InstallInvocation {
|
|
641
|
+
command: string;
|
|
642
|
+
args: string[];
|
|
643
|
+
/** Child PATH override (npm needs its own dir leading so node resolves). */
|
|
644
|
+
path?: string;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Vendor script via sh for most engines; brew-then-npm for Codex. */
|
|
648
|
+
async function resolveInstaller(
|
|
649
|
+
kind: EngineKind,
|
|
650
|
+
s: EngineSeams,
|
|
651
|
+
): Promise<InstallInvocation | null> {
|
|
652
|
+
if (kind !== "codex") {
|
|
653
|
+
const script = INSTALLERS[kind];
|
|
654
|
+
return script ? { command: "/bin/sh", args: ["-c", script] } : null;
|
|
655
|
+
}
|
|
656
|
+
const brew = s.systemBinDirs.map((dir) => join(dir, "brew")).find(existsSync);
|
|
657
|
+
if (brew) return { command: brew, args: ["install", "codex"] };
|
|
658
|
+
const npm = await findBinNamed("npm", s);
|
|
659
|
+
if (npm) {
|
|
660
|
+
return {
|
|
661
|
+
command: npm,
|
|
662
|
+
args: ["install", "-g", "@openai/codex"],
|
|
663
|
+
path: prependPath([dirname(npm)], s.procEnv.PATH ?? ""),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Find an arbitrary tool the same way engine CLIs are found. */
|
|
670
|
+
async function findBinNamed(
|
|
671
|
+
name: string,
|
|
672
|
+
s: EngineSeams,
|
|
673
|
+
): Promise<string | null> {
|
|
674
|
+
const home = s.ownerHome();
|
|
675
|
+
const dirs = [
|
|
676
|
+
join(home, ".local", "bin"),
|
|
677
|
+
...nodeManagerBinDirs(home),
|
|
678
|
+
...s.systemBinDirs,
|
|
679
|
+
];
|
|
680
|
+
for (const dir of dirs) {
|
|
681
|
+
const file = join(dir, name);
|
|
682
|
+
if (existsSync(file)) return file;
|
|
683
|
+
}
|
|
684
|
+
const login = await s.loginShellPath();
|
|
685
|
+
const dir = login?.split(":").find((p) => p && existsSync(join(p, name)));
|
|
686
|
+
return dir ? join(dir, name) : null;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Whether an engine's CLI resolves right now (candidates, then login PATH). */
|
|
690
|
+
async function engineCliFound(
|
|
691
|
+
kind: EngineKind,
|
|
692
|
+
s: EngineSeams,
|
|
693
|
+
): Promise<boolean> {
|
|
694
|
+
if (DESCRIPTORS[kind].authMode === "api-key") return true; // never spawned
|
|
695
|
+
if (candidateBins(kind, s).some(existsSync)) return true;
|
|
696
|
+
const login = await s.loginShellPath();
|
|
697
|
+
return !!login
|
|
698
|
+
?.split(":")
|
|
699
|
+
.some((dir) => dir.length > 0 && existsSync(join(dir, kind)));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Accumulate a child's output and forward each distinct, meaningful,
|
|
704
|
+
* COMPLETE line to `onLog` once — same discipline as the token flow's
|
|
705
|
+
* relay, for plain (non-PTY) installer output with \r progress noise.
|
|
706
|
+
*/
|
|
707
|
+
function createLineRelay(onLog: (line: string) => void): (b: Buffer) => void {
|
|
708
|
+
let raw = "";
|
|
709
|
+
const forwarded = new Set<string>();
|
|
710
|
+
return (b) => {
|
|
711
|
+
raw += b.toString("utf8");
|
|
712
|
+
if (raw.length > 262_144) raw = raw.slice(-131_072);
|
|
713
|
+
const text = sanitizeTerminalOutput(raw);
|
|
714
|
+
for (const line of significantLines(text.slice(0, text.lastIndexOf("\n") + 1))) {
|
|
715
|
+
if (!forwarded.has(line)) {
|
|
716
|
+
forwarded.add(line);
|
|
717
|
+
onLog(line);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
477
723
|
/**
|
|
478
724
|
* Flatten a PTY byte stream into plain text lines. Strips OSC sequences
|
|
479
725
|
* (incl. OSC-8 hyperlink wrappers, keeping their visible text), CSI
|
|
@@ -571,14 +817,21 @@ function configPath(kind: EngineKind, s: EngineSeams): string {
|
|
|
571
817
|
*/
|
|
572
818
|
function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
|
|
573
819
|
const home = s.ownerHome();
|
|
574
|
-
const common = [
|
|
820
|
+
const common = [
|
|
821
|
+
join(home, ".local", "bin"),
|
|
822
|
+
...nodeManagerBinDirs(home),
|
|
823
|
+
...s.systemBinDirs,
|
|
824
|
+
];
|
|
575
825
|
switch (kind) {
|
|
576
826
|
case "claude":
|
|
577
827
|
// Native installer symlinks ~/.local/bin/claude (preferred); the older
|
|
578
828
|
// "local install" migration keeps a wrapper at ~/.claude/local/claude.
|
|
579
|
-
return [
|
|
580
|
-
|
|
581
|
-
|
|
829
|
+
return [
|
|
830
|
+
join(home, ".local", "bin"),
|
|
831
|
+
join(home, ".claude", "local"),
|
|
832
|
+
...nodeManagerBinDirs(home),
|
|
833
|
+
...s.systemBinDirs,
|
|
834
|
+
].map((d) => join(d, "claude"));
|
|
582
835
|
case "codex":
|
|
583
836
|
return common.map((d) => join(d, "codex"));
|
|
584
837
|
case "kimi":
|
|
@@ -592,6 +845,50 @@ function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
|
|
|
592
845
|
}
|
|
593
846
|
}
|
|
594
847
|
|
|
848
|
+
/**
|
|
849
|
+
* Bin dirs of the common node version managers — `npm i -g` lands CLIs here,
|
|
850
|
+
* and none of these are on a GUI PATH or even in a NON-interactive login
|
|
851
|
+
* shell's PATH (their init lives in .zshrc/.bashrc), which is exactly how an
|
|
852
|
+
* installed `codex` still read "not found". Deterministic dirs beat shell
|
|
853
|
+
* probing: no subprocess, no profile side effects.
|
|
854
|
+
*/
|
|
855
|
+
function nodeManagerBinDirs(home: string): string[] {
|
|
856
|
+
const dirs = [
|
|
857
|
+
join(home, ".asdf", "shims"), // shims exec asdf by absolute path
|
|
858
|
+
join(home, ".volta", "bin"),
|
|
859
|
+
join(home, ".bun", "bin"),
|
|
860
|
+
join(home, ".npm-global", "bin"),
|
|
861
|
+
// fnm's stable default-alias bin — the per-shell multishell paths its
|
|
862
|
+
// shell init exports are ephemeral. macOS, XDG, and legacy layouts.
|
|
863
|
+
join(home, "Library", "Application Support", "fnm", "aliases", "default", "bin"),
|
|
864
|
+
join(home, ".local", "share", "fnm", "aliases", "default", "bin"),
|
|
865
|
+
join(home, ".fnm", "aliases", "default", "bin"),
|
|
866
|
+
];
|
|
867
|
+
// nvm has no stable "current" symlink — enumerate installed versions,
|
|
868
|
+
// newest first (numeric compare: lexicographic would put v9 over v22).
|
|
869
|
+
// A version dir's bin also holds its `node`, so the shebang of whatever
|
|
870
|
+
// we find there resolves against the same dir once it leads PATH.
|
|
871
|
+
try {
|
|
872
|
+
const versions = join(home, ".nvm", "versions", "node");
|
|
873
|
+
dirs.push(
|
|
874
|
+
...readdirSync(versions)
|
|
875
|
+
.sort(compareVersionDesc)
|
|
876
|
+
.map((v) => join(versions, v, "bin")),
|
|
877
|
+
);
|
|
878
|
+
} catch {
|
|
879
|
+
/* no nvm */
|
|
880
|
+
}
|
|
881
|
+
return dirs;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function compareVersionDesc(a: string, b: string): number {
|
|
885
|
+
const parts = (s: string): number[] =>
|
|
886
|
+
s.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
887
|
+
const [a1 = 0, a2 = 0, a3 = 0] = parts(a);
|
|
888
|
+
const [b1 = 0, b2 = 0, b3 = 0] = parts(b);
|
|
889
|
+
return b1 - a1 || b2 - a2 || b3 - a3;
|
|
890
|
+
}
|
|
891
|
+
|
|
595
892
|
interface ResolvedBin {
|
|
596
893
|
/** Command to spawn — absolute when found, else the bare name. */
|
|
597
894
|
bin: string;
|
package/lib/orchestrator.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
writeAgentCli,
|
|
52
52
|
} from "./agent-cli";
|
|
53
53
|
import { setupBrowserTesting } from "./browser-testing";
|
|
54
|
+
import { injectCodexIntoContainer } from "./codex-auth";
|
|
54
55
|
import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
|
|
55
56
|
import { env } from "./env";
|
|
56
57
|
import type {
|
|
@@ -1392,11 +1393,26 @@ export function getOrchestrator(): Orchestrator {
|
|
|
1392
1393
|
if (!globalForOrchestrator.__uaiRecoverRan) {
|
|
1393
1394
|
globalForOrchestrator.__uaiRecoverRan = true;
|
|
1394
1395
|
// Fire-and-forget — the orchestrator is usable while recovery runs.
|
|
1395
|
-
|
|
1396
|
+
// The promise is kept so boot steps that must NOT race recovery (the
|
|
1397
|
+
// Codex credential reinject) can sequence behind it via
|
|
1398
|
+
// recoveryComplete(). recoverRunningTasks never rejects.
|
|
1399
|
+
recoveryPromise = recoverRunningTasks();
|
|
1400
|
+
void recoveryPromise;
|
|
1396
1401
|
}
|
|
1397
1402
|
return globalForOrchestrator.__uaiOrchestrator;
|
|
1398
1403
|
}
|
|
1399
1404
|
|
|
1405
|
+
let recoveryPromise: Promise<void> = Promise.resolve();
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Resolves once boot-time recovery has finished (starting the orchestrator —
|
|
1409
|
+
* and with it recovery — if that hasn't happened yet). Never rejects.
|
|
1410
|
+
*/
|
|
1411
|
+
export function recoveryComplete(): Promise<void> {
|
|
1412
|
+
getOrchestrator();
|
|
1413
|
+
return recoveryPromise;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1400
1416
|
// ---------------------------------------------------------------------------
|
|
1401
1417
|
// Boot-time recovery
|
|
1402
1418
|
//
|
|
@@ -1497,7 +1513,8 @@ async function dockerExec(
|
|
|
1497
1513
|
return res.status === 0;
|
|
1498
1514
|
}
|
|
1499
1515
|
|
|
1500
|
-
|
|
1516
|
+
/** Exported for tests; production entry is the getOrchestrator() boot guard. */
|
|
1517
|
+
export async function recoverRunningTasks(): Promise<void> {
|
|
1501
1518
|
try {
|
|
1502
1519
|
const db = getDb();
|
|
1503
1520
|
const rows = db
|
|
@@ -1601,6 +1618,12 @@ async function recoverOneTask(
|
|
|
1601
1618
|
});
|
|
1602
1619
|
return;
|
|
1603
1620
|
}
|
|
1621
|
+
// Correctly-owned Codex creds BEFORE uai-init / any agent respawn: the boot
|
|
1622
|
+
// reinject sweep only targets containers already running, so a container
|
|
1623
|
+
// recovered here would otherwise keep whatever it held when it exited —
|
|
1624
|
+
// possibly host-uid-owned 0600 files Codex can't read. Failure is surfaced
|
|
1625
|
+
// by the inject itself and must not abort the task's recovery.
|
|
1626
|
+
await injectCodexIntoContainer(containerName);
|
|
1604
1627
|
// uai-init reinstalls workspace deps (pnpm/npm install) — minutes on a big
|
|
1605
1628
|
// repo. dockerCli's 30s default would SIGKILL it mid-install.
|
|
1606
1629
|
if (
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -462,17 +462,25 @@ fi
|
|
|
462
462
|
# Copy Codex credentials/config into a task-private /home/node/.codex.
|
|
463
463
|
# Do not mount the host ~/.codex writable: Codex stores live SQLite state
|
|
464
464
|
# there, and concurrent host/container access can corrupt it.
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
465
|
+
# Failures here are REAL errors (the container is running at task-up):
|
|
466
|
+
# docker cp preserves the host uid/gid, so a skipped chown leaves 0600
|
|
467
|
+
# files owned by e.g. macOS 501:20 that the container's node (1000:1000)
|
|
468
|
+
# cannot read — Codex then dies at startup.
|
|
469
|
+
if docker exec -u root "$app_container" \
|
|
470
|
+
mkdir -p /home/node/.codex >/dev/null; then
|
|
471
|
+
for codex_item in auth.json config.toml AGENTS.md version.json installation_id rules; do
|
|
472
|
+
if [ -e "$UAI_OWNER_HOME/.codex/$codex_item" ]; then
|
|
473
|
+
docker cp "$UAI_OWNER_HOME/.codex/$codex_item" \
|
|
474
|
+
"$app_container":/home/node/.codex/ >/dev/null \
|
|
475
|
+
|| log "ERROR: docker cp of .codex/$codex_item failed; Codex in this task may not authenticate"
|
|
476
|
+
fi
|
|
477
|
+
done
|
|
478
|
+
docker exec -u root "$app_container" \
|
|
479
|
+
chown -R node:node /home/node/.codex >/dev/null \
|
|
480
|
+
|| log "ERROR: chown of /home/node/.codex failed — 0600 credentials remain unreadable by node. Fix: docker exec -u root $app_container chown -R node:node /home/node/.codex"
|
|
481
|
+
else
|
|
482
|
+
log "ERROR: mkdir /home/node/.codex failed in $app_container; Codex credentials were not injected"
|
|
483
|
+
fi
|
|
476
484
|
|
|
477
485
|
# Copy Kimi Code subscription/config into a task-private /home/node/.kimi-code.
|
|
478
486
|
# The Linux `kimi` binary is baked into the image; here we copy ONLY the
|
package/src/main.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
markReconnecting,
|
|
24
24
|
} from "../lib/cloud-state";
|
|
25
25
|
import { getHostTask } from "../lib/runtime-state";
|
|
26
|
-
import { getOrchestrator } from "../lib/orchestrator";
|
|
26
|
+
import { getOrchestrator, recoveryComplete } from "../lib/orchestrator";
|
|
27
27
|
import {
|
|
28
28
|
connectedUserIds,
|
|
29
29
|
isTransientGithubError,
|
|
@@ -159,7 +159,13 @@ void ensureStandardImage();
|
|
|
159
159
|
// token → `codex login`) otherwise reaches only NEW tasks — re-copy into
|
|
160
160
|
// running tasks on start (covers the desktop "Connect Codex", which restarts
|
|
161
161
|
// the host) and watch ~/.codex for future logins (a terminal `codex login`).
|
|
162
|
-
|
|
162
|
+
// Sequenced AFTER boot recovery: reinjecting while recovery was still
|
|
163
|
+
// `docker start`ing exited containers raced — `docker cp` into a stopped
|
|
164
|
+
// container succeeds but the ownership-fix exec can't run, stranding 0600
|
|
165
|
+
// files owned by the host uid (macOS 501) that the container's node (1000)
|
|
166
|
+
// can't read, and Codex died at startup. The post-recovery sweep also
|
|
167
|
+
// self-heals such containers: it re-copies and chowns every running task.
|
|
168
|
+
void recoveryComplete().then(() => reinjectCodexRunningTasks());
|
|
163
169
|
watchCodexAuth();
|
|
164
170
|
connect();
|
|
165
171
|
// Local browser UI (ADR-028) — same single process, alongside the WSS client.
|
package/src/ui/server.ts
CHANGED
|
@@ -30,6 +30,8 @@ import { dockerCli } from "../../lib/docker-exec";
|
|
|
30
30
|
import {
|
|
31
31
|
connectEngine,
|
|
32
32
|
disconnectEngine,
|
|
33
|
+
engineCliStatuses,
|
|
34
|
+
installEngineCli,
|
|
33
35
|
engineCatalog,
|
|
34
36
|
engineStatuses,
|
|
35
37
|
isEngineKind,
|
|
@@ -151,6 +153,8 @@ async function handle(
|
|
|
151
153
|
switch (path) {
|
|
152
154
|
case "/api/engines/connect":
|
|
153
155
|
return await handleEngineConnect(req, res, opts);
|
|
156
|
+
case "/api/engines/install":
|
|
157
|
+
return await handleEngineInstall(req, res);
|
|
154
158
|
case "/api/engines/disconnect":
|
|
155
159
|
return await handleEngineDisconnect(req, res, opts);
|
|
156
160
|
case "/api/tasks/stop":
|
|
@@ -181,7 +185,7 @@ async function handle(
|
|
|
181
185
|
case "/api/users":
|
|
182
186
|
return sendJson(res, UsersResponse, usersBody(opts));
|
|
183
187
|
case "/api/engines":
|
|
184
|
-
return sendJson(res, EnginesResponse, enginesBody());
|
|
188
|
+
return sendJson(res, EnginesResponse, await enginesBody());
|
|
185
189
|
}
|
|
186
190
|
if (path.startsWith("/api/")) {
|
|
187
191
|
return sendError(res, 404, `no such endpoint: ${path}`);
|
|
@@ -194,8 +198,47 @@ async function handle(
|
|
|
194
198
|
|
|
195
199
|
// --- engines ----------------------------------------------------------------
|
|
196
200
|
|
|
197
|
-
function enginesBody(): EnginesResponse {
|
|
198
|
-
return {
|
|
201
|
+
async function enginesBody(): Promise<EnginesResponse> {
|
|
202
|
+
return {
|
|
203
|
+
catalog: engineCatalog(),
|
|
204
|
+
statuses: engineStatuses(),
|
|
205
|
+
cli: await engineCliStatuses(),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* POST /api/engines/install `{kind}` — run the engine's pinned installer,
|
|
211
|
+
* streaming NDJSON like connect: `{"line":…}` frames, then `{done,ok,message}`.
|
|
212
|
+
* No re-advertise or image rebuild here: installing a CLI connects nothing;
|
|
213
|
+
* that happens on the connect that follows.
|
|
214
|
+
*/
|
|
215
|
+
async function handleEngineInstall(
|
|
216
|
+
req: IncomingMessage,
|
|
217
|
+
res: ServerResponse,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
const body = await readJsonBody(req);
|
|
220
|
+
const kind = body?.kind;
|
|
221
|
+
if (!isEngineKind(kind)) {
|
|
222
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
223
|
+
}
|
|
224
|
+
res.writeHead(200, {
|
|
225
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
226
|
+
"cache-control": "no-store",
|
|
227
|
+
});
|
|
228
|
+
const emit = (obj: unknown): void => {
|
|
229
|
+
res.write(`${JSON.stringify(obj)}\n`);
|
|
230
|
+
};
|
|
231
|
+
let result: { ok: boolean; message: string };
|
|
232
|
+
try {
|
|
233
|
+
result = await installEngineCli(kind, (line) => emit({ line }));
|
|
234
|
+
} catch (err) {
|
|
235
|
+
result = {
|
|
236
|
+
ok: false,
|
|
237
|
+
message: err instanceof Error ? err.message : "install failed",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
emit({ done: true, ok: result.ok, message: result.message });
|
|
241
|
+
res.end();
|
|
199
242
|
}
|
|
200
243
|
|
|
201
244
|
/**
|
package/src/ui/types.ts
CHANGED
|
@@ -84,12 +84,15 @@ export const EngineCatalogEntry = z.object({
|
|
|
84
84
|
// Pasted-API-key alternative to the login/token flow (null = not offered).
|
|
85
85
|
apiKeyHint: z.string().nullable(),
|
|
86
86
|
apiKeyUrl: z.string().nullable(),
|
|
87
|
+
// Install command shown when the CLI is missing (null = nothing to install).
|
|
88
|
+
installHint: z.string().nullable(),
|
|
87
89
|
});
|
|
88
90
|
export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
|
|
89
91
|
|
|
90
92
|
export const EnginesResponse = z.object({
|
|
91
93
|
catalog: z.array(EngineCatalogEntry),
|
|
92
94
|
statuses: z.record(z.boolean()), // kind → connected
|
|
95
|
+
cli: z.record(z.boolean()), // kind → CLI resolvable on this host
|
|
93
96
|
});
|
|
94
97
|
export type EnginesResponse = z.infer<typeof EnginesResponse>;
|
|
95
98
|
|
package/ui/app.js
CHANGED
|
@@ -483,6 +483,11 @@ function commandForm(e) {
|
|
|
483
483
|
log.className = "log";
|
|
484
484
|
log.hidden = true;
|
|
485
485
|
|
|
486
|
+
const pushLine = (line) => {
|
|
487
|
+
log.textContent += (log.textContent ? "\n" : "") + line;
|
|
488
|
+
log.scrollTop = log.scrollHeight;
|
|
489
|
+
};
|
|
490
|
+
|
|
486
491
|
const actions = document.createElement("div");
|
|
487
492
|
actions.className = "setup-actions";
|
|
488
493
|
const connect = document.createElement("button");
|
|
@@ -499,10 +504,7 @@ function commandForm(e) {
|
|
|
499
504
|
: "Your browser will open to sign in…";
|
|
500
505
|
log.hidden = false;
|
|
501
506
|
log.textContent = "";
|
|
502
|
-
const result = await runConnect({ kind: e.kind },
|
|
503
|
-
log.textContent += (log.textContent ? "\n" : "") + line;
|
|
504
|
-
log.scrollTop = log.scrollHeight;
|
|
505
|
-
});
|
|
507
|
+
const result = await runConnect({ kind: e.kind }, pushLine);
|
|
506
508
|
if (result.ok) {
|
|
507
509
|
await poll();
|
|
508
510
|
closeModal();
|
|
@@ -514,6 +516,47 @@ function commandForm(e) {
|
|
|
514
516
|
}
|
|
515
517
|
});
|
|
516
518
|
actions.append(connect);
|
|
519
|
+
|
|
520
|
+
// CLI missing → offer the pinned installer first, chaining into connect on
|
|
521
|
+
// success. The command is shown verbatim; its output streams into the log.
|
|
522
|
+
const cliMissing =
|
|
523
|
+
!!latestEngines?.cli && latestEngines.cli[e.kind] === false && !!e.installHint;
|
|
524
|
+
if (cliMissing) {
|
|
525
|
+
connect.hidden = true;
|
|
526
|
+
const installNote = document.createElement("p");
|
|
527
|
+
installNote.className = "setup-note";
|
|
528
|
+
installNote.textContent = `The ${e.label} CLI isn't installed on this Mac. Install it with:`;
|
|
529
|
+
const installCmd = document.createElement("pre");
|
|
530
|
+
installCmd.className = "log";
|
|
531
|
+
installCmd.textContent = e.installHint;
|
|
532
|
+
const install = document.createElement("button");
|
|
533
|
+
install.className = "btn";
|
|
534
|
+
install.type = "button";
|
|
535
|
+
install.textContent = "Install & connect";
|
|
536
|
+
install.addEventListener("click", async () => {
|
|
537
|
+
install.disabled = true;
|
|
538
|
+
install.textContent = "Installing…";
|
|
539
|
+
status.className = "setup-status";
|
|
540
|
+
status.textContent = "Running the installer…";
|
|
541
|
+
log.hidden = false;
|
|
542
|
+
log.textContent = "";
|
|
543
|
+
const result = await runInstall({ kind: e.kind }, pushLine);
|
|
544
|
+
if (result.ok) {
|
|
545
|
+
await poll();
|
|
546
|
+
install.hidden = true;
|
|
547
|
+
connect.hidden = false;
|
|
548
|
+
connect.click();
|
|
549
|
+
} else {
|
|
550
|
+
install.disabled = false;
|
|
551
|
+
install.textContent = "Try again";
|
|
552
|
+
status.className = "setup-status err";
|
|
553
|
+
status.textContent = result.message || "Install failed.";
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
actions.prepend(install);
|
|
557
|
+
form.append(installNote, installCmd);
|
|
558
|
+
}
|
|
559
|
+
|
|
517
560
|
form.append(actions, status, log);
|
|
518
561
|
|
|
519
562
|
// Manual paste fallback for when the browser flow isn't possible: Claude
|
|
@@ -590,14 +633,24 @@ function commandForm(e) {
|
|
|
590
633
|
return form;
|
|
591
634
|
}
|
|
592
635
|
|
|
636
|
+
/** POST /api/engines/connect and consume the NDJSON stream. */
|
|
637
|
+
function runConnect(body, onLine) {
|
|
638
|
+
return runStream("/api/engines/connect", body, onLine);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** POST /api/engines/install and consume the NDJSON stream. */
|
|
642
|
+
function runInstall(body, onLine) {
|
|
643
|
+
return runStream("/api/engines/install", body, onLine);
|
|
644
|
+
}
|
|
645
|
+
|
|
593
646
|
/**
|
|
594
|
-
* POST
|
|
595
|
-
*
|
|
647
|
+
* POST an NDJSON-streaming endpoint: `{line}` frames feed onLine; the final
|
|
648
|
+
* `{done,ok,message}` frame is the result.
|
|
596
649
|
*/
|
|
597
|
-
async function
|
|
650
|
+
async function runStream(path, body, onLine) {
|
|
598
651
|
let res;
|
|
599
652
|
try {
|
|
600
|
-
res = await fetch(
|
|
653
|
+
res = await fetch(path, {
|
|
601
654
|
method: "POST",
|
|
602
655
|
headers: { "content-type": "application/json" },
|
|
603
656
|
body: JSON.stringify(body),
|
|
@@ -606,7 +659,7 @@ async function runConnect(body, onLine) {
|
|
|
606
659
|
return { ok: false, message: String(err) };
|
|
607
660
|
}
|
|
608
661
|
if (!res.ok || !res.body) {
|
|
609
|
-
return { ok: false, message: `
|
|
662
|
+
return { ok: false, message: `request failed (${res.status})` };
|
|
610
663
|
}
|
|
611
664
|
const reader = res.body.getReader();
|
|
612
665
|
const dec = new TextDecoder();
|