@runuai/host 0.8.5 → 0.8.6
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/engines.ts +486 -0
- package/package.json +1 -1
- package/src/main.ts +3 -0
- package/src/ui/server.ts +130 -1
- package/src/ui/types.ts +23 -0
- package/ui/app.js +389 -2
- package/ui/index.html +36 -1
- package/ui/style.css +262 -0
package/lib/engines.ts
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine connect/disconnect for the local host UI (ADR-028, ADR-044 P2).
|
|
3
|
+
*
|
|
4
|
+
* A single descriptor table for the AI coding engines the host can run
|
|
5
|
+
* (Claude, Codex, Kimi, Grok, Cursor) plus the connect/disconnect/status logic
|
|
6
|
+
* the local UI drives. This is the host-side twin of the desktop's
|
|
7
|
+
* `apps/host-desktop/src/llm.ts`, but it writes the `.env.local` the RUNNING
|
|
8
|
+
* launchd/npm host reads (UAI_HOME) and sets `process.env` so a connect takes
|
|
9
|
+
* effect WITHOUT a host restart — the adapters' `available()` and task-up's env
|
|
10
|
+
* injection both read `process.env`.
|
|
11
|
+
*
|
|
12
|
+
* Three auth modes:
|
|
13
|
+
* - token-command (Claude): `claude setup-token` prints a token to stdout;
|
|
14
|
+
* we capture it (an `sk-ant-oat…` value) and persist it as
|
|
15
|
+
* CLAUDE_CODE_OAUTH_TOKEN. A pasted token is accepted too.
|
|
16
|
+
* - login-command (Codex/Kimi/Grok): `<cli> login` runs a browser OAuth and
|
|
17
|
+
* writes a config file; success = that file appearing.
|
|
18
|
+
* - api-key (Cursor): no command — persist a pasted CURSOR_API_KEY.
|
|
19
|
+
*
|
|
20
|
+
* Detection mirrors each adapter's `available()`: env/`.env.local` for
|
|
21
|
+
* claude/cursor, a config file under the owner home for codex/kimi/grok.
|
|
22
|
+
*
|
|
23
|
+
* Every side-effecting seam (spawn, the `.env.local` path, the owner home,
|
|
24
|
+
* process.env) is injectable so the flows are unit-testable without touching
|
|
25
|
+
* the real filesystem or spawning anything.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
|
|
29
|
+
import {
|
|
30
|
+
chmodSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
rmSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from "node:fs";
|
|
37
|
+
import { homedir } from "node:os";
|
|
38
|
+
import { dirname, join } from "node:path";
|
|
39
|
+
|
|
40
|
+
import { env } from "./env";
|
|
41
|
+
|
|
42
|
+
export type EngineKind = "claude" | "codex" | "kimi" | "grok" | "cursor";
|
|
43
|
+
export type EngineAuthMode = "token-command" | "login-command" | "api-key";
|
|
44
|
+
|
|
45
|
+
/** One entry of the engine catalog the UI's "Add engine" panel renders. */
|
|
46
|
+
export interface EngineCatalogEntry {
|
|
47
|
+
kind: EngineKind;
|
|
48
|
+
label: string;
|
|
49
|
+
authMode: EngineAuthMode;
|
|
50
|
+
/** One-line help shown under the engine in the picker. */
|
|
51
|
+
notes: string | null;
|
|
52
|
+
/** Where to mint an API key (api-key mode only). */
|
|
53
|
+
getKeyUrl: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface EngineDescriptor {
|
|
57
|
+
label: string;
|
|
58
|
+
authMode: EngineAuthMode;
|
|
59
|
+
notes: string;
|
|
60
|
+
getKeyUrl?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Static descriptor table — the single source of truth for engine metadata. */
|
|
64
|
+
const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
|
|
65
|
+
claude: {
|
|
66
|
+
label: "Claude",
|
|
67
|
+
authMode: "token-command",
|
|
68
|
+
notes:
|
|
69
|
+
"Opens your browser to authorize Claude, then captures the token automatically.",
|
|
70
|
+
},
|
|
71
|
+
codex: {
|
|
72
|
+
label: "Codex",
|
|
73
|
+
authMode: "login-command",
|
|
74
|
+
notes: "Opens your browser to sign in to your OpenAI Codex account.",
|
|
75
|
+
},
|
|
76
|
+
kimi: {
|
|
77
|
+
label: "Kimi Code",
|
|
78
|
+
authMode: "login-command",
|
|
79
|
+
notes:
|
|
80
|
+
"Sign in with your Moonshot Kimi Code subscription. Activates after the next image rebuild.",
|
|
81
|
+
},
|
|
82
|
+
grok: {
|
|
83
|
+
label: "Grok",
|
|
84
|
+
authMode: "login-command",
|
|
85
|
+
notes:
|
|
86
|
+
"Sign in with your xAI Grok subscription. Activates after the next image rebuild.",
|
|
87
|
+
},
|
|
88
|
+
cursor: {
|
|
89
|
+
label: "Cursor",
|
|
90
|
+
authMode: "api-key",
|
|
91
|
+
notes: "Paste a Cursor API key. Requires Cursor Pro.",
|
|
92
|
+
getKeyUrl: "https://cursor.com/dashboard?tab=integrations",
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Display order (matches the cloud picker's ordering intent). */
|
|
97
|
+
const ORDER: EngineKind[] = ["claude", "codex", "kimi", "grok", "cursor"];
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Injectable seams.
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/** How a login/token command is spawned. Tests provide a fake. */
|
|
104
|
+
export type EngineSpawn = (command: string, args: string[]) => ChildProcess;
|
|
105
|
+
|
|
106
|
+
export interface EngineSeams {
|
|
107
|
+
/** Spawn a CLI, piping stdout/stderr. */
|
|
108
|
+
spawn: EngineSpawn;
|
|
109
|
+
/** Absolute path to the `.env.local` the running host reads (UAI_HOME). */
|
|
110
|
+
envLocalPath: () => string;
|
|
111
|
+
/** The owner's real home — where codex/kimi/grok write their config dirs. */
|
|
112
|
+
ownerHome: () => string;
|
|
113
|
+
/** The live process env (adapters + task-up read creds from here). */
|
|
114
|
+
procEnv: NodeJS.ProcessEnv;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function defaultSeams(): EngineSeams {
|
|
118
|
+
return {
|
|
119
|
+
spawn: (command, args) =>
|
|
120
|
+
nodeSpawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }),
|
|
121
|
+
envLocalPath: () => join(env.uaiHome, ".env.local"),
|
|
122
|
+
ownerHome: () => process.env.UAI_OWNER_HOME?.trim() || homedir(),
|
|
123
|
+
procEnv: process.env,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
|
|
128
|
+
return { ...defaultSeams(), ...seams };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Public catalog / status.
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/** The static engine catalog (kind, label, authMode, notes, getKeyUrl). */
|
|
136
|
+
export function engineCatalog(): EngineCatalogEntry[] {
|
|
137
|
+
return ORDER.map((kind) => {
|
|
138
|
+
const d = DESCRIPTORS[kind];
|
|
139
|
+
return {
|
|
140
|
+
kind,
|
|
141
|
+
label: d.label,
|
|
142
|
+
authMode: d.authMode,
|
|
143
|
+
notes: d.notes,
|
|
144
|
+
getKeyUrl: d.getKeyUrl ?? null,
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** kind → connected, for every engine. */
|
|
150
|
+
export function engineStatuses(
|
|
151
|
+
seams: Partial<EngineSeams> = {},
|
|
152
|
+
): Record<EngineKind, boolean> {
|
|
153
|
+
const s = withDefaults(seams);
|
|
154
|
+
return {
|
|
155
|
+
claude: detect("claude", s),
|
|
156
|
+
codex: detect("codex", s),
|
|
157
|
+
kimi: detect("kimi", s),
|
|
158
|
+
grok: detect("grok", s),
|
|
159
|
+
cursor: detect("cursor", s),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Whether a single engine is connected on this host right now. */
|
|
164
|
+
export function detectEngine(
|
|
165
|
+
kind: EngineKind,
|
|
166
|
+
seams: Partial<EngineSeams> = {},
|
|
167
|
+
): boolean {
|
|
168
|
+
return detect(kind, withDefaults(seams));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function isEngineKind(value: unknown): value is EngineKind {
|
|
172
|
+
return (
|
|
173
|
+
value === "claude" ||
|
|
174
|
+
value === "codex" ||
|
|
175
|
+
value === "kimi" ||
|
|
176
|
+
value === "grok" ||
|
|
177
|
+
value === "cursor"
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Connect / disconnect.
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
export interface ConnectOptions {
|
|
186
|
+
/** api-key mode: the pasted key (Cursor). */
|
|
187
|
+
apiKey?: string;
|
|
188
|
+
/** token-command manual fallback: a pasted token (Claude). */
|
|
189
|
+
pastedToken?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ConnectResult {
|
|
193
|
+
ok: boolean;
|
|
194
|
+
message: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Connect an engine. api-key/paste resolve synchronously; login/token spawn a
|
|
199
|
+
* CLI and stream its output to `onLog` (the browser OAuth happens meanwhile).
|
|
200
|
+
*/
|
|
201
|
+
export async function connectEngine(
|
|
202
|
+
kind: EngineKind,
|
|
203
|
+
opts: ConnectOptions,
|
|
204
|
+
onLog: (line: string) => void,
|
|
205
|
+
seams: Partial<EngineSeams> = {},
|
|
206
|
+
): Promise<ConnectResult> {
|
|
207
|
+
const s = withDefaults(seams);
|
|
208
|
+
const d = DESCRIPTORS[kind];
|
|
209
|
+
|
|
210
|
+
if (d.authMode === "api-key") {
|
|
211
|
+
const key = (opts.apiKey ?? "").trim();
|
|
212
|
+
if (!key) return { ok: false, message: `Paste your ${d.label} API key.` };
|
|
213
|
+
if (/\s/.test(key) || key.length < 8) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
message: "That doesn't look like an API key. Paste just the value.",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
upsertEnvLocal("CURSOR_API_KEY", key, s);
|
|
220
|
+
return { ok: true, message: `${d.label} connected.` };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (d.authMode === "token-command") {
|
|
224
|
+
// Manual paste fallback — accept a token without spawning the CLI.
|
|
225
|
+
if (opts.pastedToken !== undefined) {
|
|
226
|
+
const token = opts.pastedToken.trim();
|
|
227
|
+
if (!token) return { ok: false, message: "Paste a token." };
|
|
228
|
+
if (/\s/.test(token) || token.length < 20) {
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
message: "That doesn't look like a token. Paste just the value.",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
|
|
235
|
+
return { ok: true, message: `${d.label} connected.` };
|
|
236
|
+
}
|
|
237
|
+
return runTokenCommand(kind, d.label, onLog, s);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return runLoginCommand(kind, d.label, onLog, s);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Disconnect an engine: forget its credential (env line and/or config file). */
|
|
244
|
+
export function disconnectEngine(
|
|
245
|
+
kind: EngineKind,
|
|
246
|
+
seams: Partial<EngineSeams> = {},
|
|
247
|
+
): void {
|
|
248
|
+
const s = withDefaults(seams);
|
|
249
|
+
if (kind === "cursor") {
|
|
250
|
+
removeEnvLocal("CURSOR_API_KEY", s);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (kind === "claude") {
|
|
254
|
+
removeEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", s);
|
|
255
|
+
removeEnvLocal("ANTHROPIC_API_KEY", s);
|
|
256
|
+
removeEnvLocal("ANTHROPIC_AUTH_TOKEN", s);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// login-command engines: remove the config file the adapter detects.
|
|
260
|
+
try {
|
|
261
|
+
rmSync(configPath(kind, s), { force: true });
|
|
262
|
+
} catch {
|
|
263
|
+
/* best effort */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Extract a Claude OAuth token from `claude setup-token` stdout. The CLI prints
|
|
269
|
+
* the token (an `sk-ant-oat…` value) on its own line near the end; scan from the
|
|
270
|
+
* bottom for it, falling back to a lone long token-charset line.
|
|
271
|
+
*/
|
|
272
|
+
export function extractClaudeToken(stdout: string): string | null {
|
|
273
|
+
const lines = stdout
|
|
274
|
+
.split(/\r?\n/)
|
|
275
|
+
.map((l) => l.trim())
|
|
276
|
+
.filter(Boolean);
|
|
277
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
278
|
+
const m = /(sk-ant-[A-Za-z0-9_-]{16,})/.exec(lines[i] ?? "");
|
|
279
|
+
if (m?.[1]) return m[1];
|
|
280
|
+
}
|
|
281
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
282
|
+
const line = lines[i] ?? "";
|
|
283
|
+
if (/^[A-Za-z0-9_-]{40,}$/.test(line)) return line;
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// Internals.
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
function detect(kind: EngineKind, s: EngineSeams): boolean {
|
|
293
|
+
switch (kind) {
|
|
294
|
+
case "claude":
|
|
295
|
+
return (
|
|
296
|
+
envOrFileHas("CLAUDE_CODE_OAUTH_TOKEN", s) ||
|
|
297
|
+
envOrFileHas("ANTHROPIC_API_KEY", s) ||
|
|
298
|
+
envOrFileHas("ANTHROPIC_AUTH_TOKEN", s)
|
|
299
|
+
);
|
|
300
|
+
case "cursor":
|
|
301
|
+
return envOrFileHas("CURSOR_API_KEY", s);
|
|
302
|
+
case "codex":
|
|
303
|
+
case "kimi":
|
|
304
|
+
case "grok":
|
|
305
|
+
return existsSync(configPath(kind, s));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** True when `key` is set in process.env or present in `.env.local`. */
|
|
310
|
+
function envOrFileHas(key: string, s: EngineSeams): boolean {
|
|
311
|
+
if (s.procEnv[key]) return true;
|
|
312
|
+
try {
|
|
313
|
+
const body = readFileSync(s.envLocalPath(), "utf8");
|
|
314
|
+
return new RegExp(`^${key}=.+`, "m").test(body);
|
|
315
|
+
} catch {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Config file whose presence gates a login-command engine. */
|
|
321
|
+
function configPath(kind: EngineKind, s: EngineSeams): string {
|
|
322
|
+
const home = s.ownerHome();
|
|
323
|
+
if (kind === "codex") return join(home, ".codex", "auth.json");
|
|
324
|
+
if (kind === "kimi") {
|
|
325
|
+
return join(home, ".kimi-code", "credentials", "kimi-code.json");
|
|
326
|
+
}
|
|
327
|
+
return join(home, ".grok", "auth.json");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Resolve the login/token CLI binary — absolute for kimi/grok, else on PATH. */
|
|
331
|
+
function resolveBin(kind: EngineKind, s: EngineSeams): string {
|
|
332
|
+
if (kind === "kimi") {
|
|
333
|
+
const local = join(s.ownerHome(), ".kimi-code", "bin", "kimi");
|
|
334
|
+
return existsSync(local) ? local : "kimi";
|
|
335
|
+
}
|
|
336
|
+
if (kind === "grok") {
|
|
337
|
+
const local = join(s.ownerHome(), ".grok", "bin", "grok");
|
|
338
|
+
return existsSync(local) ? local : "grok";
|
|
339
|
+
}
|
|
340
|
+
// claude / codex are on PATH.
|
|
341
|
+
return kind;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function runTokenCommand(
|
|
345
|
+
kind: EngineKind,
|
|
346
|
+
label: string,
|
|
347
|
+
onLog: (line: string) => void,
|
|
348
|
+
s: EngineSeams,
|
|
349
|
+
): Promise<ConnectResult> {
|
|
350
|
+
return new Promise((resolve) => {
|
|
351
|
+
let settled = false;
|
|
352
|
+
const done = (r: ConnectResult): void => {
|
|
353
|
+
if (!settled) {
|
|
354
|
+
settled = true;
|
|
355
|
+
resolve(r);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
let child: ChildProcess;
|
|
359
|
+
try {
|
|
360
|
+
child = s.spawn(resolveBin(kind, s), ["setup-token"]);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
done({ ok: false, message: err instanceof Error ? err.message : String(err) });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
let stdout = "";
|
|
366
|
+
child.stdout?.on("data", (b: Buffer) => {
|
|
367
|
+
const text = b.toString("utf8");
|
|
368
|
+
stdout += text;
|
|
369
|
+
relay(text, onLog);
|
|
370
|
+
});
|
|
371
|
+
child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
|
|
372
|
+
child.on("error", (err: NodeJS.ErrnoException) =>
|
|
373
|
+
done({
|
|
374
|
+
ok: false,
|
|
375
|
+
message:
|
|
376
|
+
err.code === "ENOENT"
|
|
377
|
+
? `${label} CLI not found on PATH — install Claude Code first.`
|
|
378
|
+
: err.message,
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
381
|
+
child.on("exit", () => {
|
|
382
|
+
const token = extractClaudeToken(stdout);
|
|
383
|
+
if (!token) {
|
|
384
|
+
done({
|
|
385
|
+
ok: false,
|
|
386
|
+
message:
|
|
387
|
+
"Couldn't read a token from the CLI output. Try again, or paste the token manually.",
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
|
|
392
|
+
done({ ok: true, message: `${label} connected.` });
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function runLoginCommand(
|
|
398
|
+
kind: EngineKind,
|
|
399
|
+
label: string,
|
|
400
|
+
onLog: (line: string) => void,
|
|
401
|
+
s: EngineSeams,
|
|
402
|
+
): Promise<ConnectResult> {
|
|
403
|
+
return new Promise((resolve) => {
|
|
404
|
+
let settled = false;
|
|
405
|
+
const done = (r: ConnectResult): void => {
|
|
406
|
+
if (!settled) {
|
|
407
|
+
settled = true;
|
|
408
|
+
resolve(r);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
let child: ChildProcess;
|
|
412
|
+
try {
|
|
413
|
+
child = s.spawn(resolveBin(kind, s), ["login"]);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
done({ ok: false, message: err instanceof Error ? err.message : String(err) });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
child.stdout?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
|
|
419
|
+
child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
|
|
420
|
+
child.on("error", (err: NodeJS.ErrnoException) =>
|
|
421
|
+
done({
|
|
422
|
+
ok: false,
|
|
423
|
+
message:
|
|
424
|
+
err.code === "ENOENT"
|
|
425
|
+
? `${label} CLI not found — install it first.`
|
|
426
|
+
: err.message,
|
|
427
|
+
}),
|
|
428
|
+
);
|
|
429
|
+
child.on("exit", () => {
|
|
430
|
+
done(
|
|
431
|
+
detect(kind, s)
|
|
432
|
+
? { ok: true, message: `${label} connected.` }
|
|
433
|
+
: { ok: false, message: `${label} login didn't complete.` },
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function relay(text: string, onLog: (line: string) => void): void {
|
|
440
|
+
for (const l of text.split(/\r?\n/)) {
|
|
441
|
+
if (l.trim()) onLog(l.trim());
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Upsert KEY=value into the host's `.env.local` (0600) AND process.env. */
|
|
446
|
+
function upsertEnvLocal(key: string, value: string, s: EngineSeams): void {
|
|
447
|
+
const file = s.envLocalPath();
|
|
448
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
449
|
+
let body = "";
|
|
450
|
+
try {
|
|
451
|
+
body = readFileSync(file, "utf8");
|
|
452
|
+
} catch {
|
|
453
|
+
/* new file */
|
|
454
|
+
}
|
|
455
|
+
const line = `${key}=${value}`;
|
|
456
|
+
const re = new RegExp(`^${key}=.*$`, "m");
|
|
457
|
+
body = re.test(body)
|
|
458
|
+
? body.replace(re, line)
|
|
459
|
+
: body + (body && !body.endsWith("\n") ? "\n" : "") + line + "\n";
|
|
460
|
+
writeFileSync(file, body, { mode: 0o600 });
|
|
461
|
+
try {
|
|
462
|
+
chmodSync(file, 0o600);
|
|
463
|
+
} catch {
|
|
464
|
+
/* best effort */
|
|
465
|
+
}
|
|
466
|
+
// Immediate effect: the running host reads creds from process.env (adapters'
|
|
467
|
+
// available() + task-up env injection), not by reloading .env.local — so set
|
|
468
|
+
// it here or a connect wouldn't take effect until a restart.
|
|
469
|
+
s.procEnv[key] = value;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Remove a KEY line from `.env.local` and process.env (no-op if absent). */
|
|
473
|
+
function removeEnvLocal(key: string, s: EngineSeams): void {
|
|
474
|
+
const file = s.envLocalPath();
|
|
475
|
+
try {
|
|
476
|
+
const body = readFileSync(file, "utf8");
|
|
477
|
+
const next = body
|
|
478
|
+
.split(/\r?\n/)
|
|
479
|
+
.filter((l) => !new RegExp(`^${key}=`).test(l))
|
|
480
|
+
.join("\n");
|
|
481
|
+
writeFileSync(file, next, { mode: 0o600 });
|
|
482
|
+
} catch {
|
|
483
|
+
/* no file */
|
|
484
|
+
}
|
|
485
|
+
delete s.procEnv[key];
|
|
486
|
+
}
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -180,6 +180,9 @@ async function startLocalUi(): Promise<void> {
|
|
|
180
180
|
hostId,
|
|
181
181
|
logPath: serviceLogPath(),
|
|
182
182
|
taskMemory: dockerMemoryBytes,
|
|
183
|
+
// Engine connect/disconnect in the local UI re-advertises capabilities so
|
|
184
|
+
// the cloud's task picker reflects a newly-configured engine promptly.
|
|
185
|
+
readvertise: sendCapabilities,
|
|
183
186
|
});
|
|
184
187
|
console.log(`[host-agent] local UI on http://127.0.0.1:${handle.port}`);
|
|
185
188
|
} catch (err) {
|
package/src/ui/server.ts
CHANGED
|
@@ -27,8 +27,18 @@ import { schema, type Db } from "../../lib/db";
|
|
|
27
27
|
import { parsePreviewPortRuntimes } from "../../lib/preview-ports";
|
|
28
28
|
import { getCloudState } from "../../lib/cloud-state";
|
|
29
29
|
import { dockerCli } from "../../lib/docker-exec";
|
|
30
|
+
import {
|
|
31
|
+
connectEngine,
|
|
32
|
+
disconnectEngine,
|
|
33
|
+
engineCatalog,
|
|
34
|
+
engineStatuses,
|
|
35
|
+
isEngineKind,
|
|
36
|
+
} from "../../lib/engines";
|
|
37
|
+
import { ensureStandardImage } from "../../lib/standard-image";
|
|
30
38
|
import {
|
|
31
39
|
CloudResponse,
|
|
40
|
+
EngineOpResponse,
|
|
41
|
+
EnginesResponse,
|
|
32
42
|
EventsResponse,
|
|
33
43
|
StatusResponse,
|
|
34
44
|
TasksResponse,
|
|
@@ -55,6 +65,12 @@ export interface UiServerOptions {
|
|
|
55
65
|
logPath: string;
|
|
56
66
|
/** Best-effort container memory by compose project; null on failure. */
|
|
57
67
|
taskMemory?: (composeProject: string) => Promise<number | null>;
|
|
68
|
+
/**
|
|
69
|
+
* Re-advertise host capabilities to the cloud (ADR-021). Wired from main.ts
|
|
70
|
+
* (`sendCapabilities`) so an engine connect/disconnect updates the cloud's
|
|
71
|
+
* task picker promptly. Optional (tests omit it).
|
|
72
|
+
*/
|
|
73
|
+
readvertise?: () => void;
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
export interface UiServerHandle {
|
|
@@ -121,8 +137,20 @@ async function handle(
|
|
|
121
137
|
opts: UiServerOptions,
|
|
122
138
|
): Promise<void> {
|
|
123
139
|
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
140
|
+
const method = req.method ?? "GET";
|
|
124
141
|
try {
|
|
125
|
-
|
|
142
|
+
// POST — engine connect/disconnect (the only writes; localhost-bound, so
|
|
143
|
+
// the loopback binding is the perimeter, ADR-028).
|
|
144
|
+
if (method === "POST") {
|
|
145
|
+
switch (path) {
|
|
146
|
+
case "/api/engines/connect":
|
|
147
|
+
return await handleEngineConnect(req, res, opts);
|
|
148
|
+
case "/api/engines/disconnect":
|
|
149
|
+
return await handleEngineDisconnect(req, res, opts);
|
|
150
|
+
}
|
|
151
|
+
return sendError(res, 404, `no such endpoint: ${path}`);
|
|
152
|
+
}
|
|
153
|
+
if (method !== "GET") {
|
|
126
154
|
return sendError(res, 405, "method not allowed");
|
|
127
155
|
}
|
|
128
156
|
switch (path) {
|
|
@@ -144,6 +172,8 @@ async function handle(
|
|
|
144
172
|
return sendJson(res, EventsResponse, eventsBody(opts));
|
|
145
173
|
case "/api/users":
|
|
146
174
|
return sendJson(res, UsersResponse, usersBody(opts));
|
|
175
|
+
case "/api/engines":
|
|
176
|
+
return sendJson(res, EnginesResponse, enginesBody());
|
|
147
177
|
}
|
|
148
178
|
if (path.startsWith("/api/")) {
|
|
149
179
|
return sendError(res, 404, `no such endpoint: ${path}`);
|
|
@@ -154,6 +184,105 @@ async function handle(
|
|
|
154
184
|
}
|
|
155
185
|
}
|
|
156
186
|
|
|
187
|
+
// --- engines ----------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
function enginesBody(): EnginesResponse {
|
|
190
|
+
return { catalog: engineCatalog(), statuses: engineStatuses() };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* POST /api/engines/connect `{kind, apiKey?, pastedToken?}`.
|
|
195
|
+
*
|
|
196
|
+
* Streams the connect as newline-delimited JSON (`application/x-ndjson`): each
|
|
197
|
+
* live CLI log line is `{"line":"…"}`, and a final `{"done":true,"ok":…,
|
|
198
|
+
* "message":"…"}` carries the result. api-key/paste connects emit no log lines
|
|
199
|
+
* and just the final frame. On success we re-advertise and kick a (best-effort)
|
|
200
|
+
* standard-image rebuild so a newly-configured engine's CLI gets installed.
|
|
201
|
+
*/
|
|
202
|
+
async function handleEngineConnect(
|
|
203
|
+
req: IncomingMessage,
|
|
204
|
+
res: ServerResponse,
|
|
205
|
+
opts: UiServerOptions,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
const body = await readJsonBody(req);
|
|
208
|
+
const kind = body?.kind;
|
|
209
|
+
if (!isEngineKind(kind)) {
|
|
210
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
211
|
+
}
|
|
212
|
+
const apiKey = typeof body?.apiKey === "string" ? body.apiKey : undefined;
|
|
213
|
+
const pastedToken =
|
|
214
|
+
typeof body?.pastedToken === "string" ? body.pastedToken : undefined;
|
|
215
|
+
|
|
216
|
+
res.writeHead(200, {
|
|
217
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
218
|
+
"cache-control": "no-store",
|
|
219
|
+
});
|
|
220
|
+
const emit = (obj: unknown): void => {
|
|
221
|
+
res.write(`${JSON.stringify(obj)}\n`);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
let result: { ok: boolean; message: string };
|
|
225
|
+
try {
|
|
226
|
+
result = await connectEngine(
|
|
227
|
+
kind,
|
|
228
|
+
{ apiKey, pastedToken },
|
|
229
|
+
(line) => emit({ line }),
|
|
230
|
+
);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
result = {
|
|
233
|
+
ok: false,
|
|
234
|
+
message: err instanceof Error ? err.message : "connect failed",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (result.ok) {
|
|
238
|
+
// Re-advertise so the cloud picker offers the engine; rebuild the image so
|
|
239
|
+
// an optional engine's CLI (kimi/grok/cursor) is installed. Best-effort.
|
|
240
|
+
opts.readvertise?.();
|
|
241
|
+
void ensureStandardImage();
|
|
242
|
+
}
|
|
243
|
+
emit({ done: true, ok: result.ok, message: result.message });
|
|
244
|
+
res.end();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** POST /api/engines/disconnect `{kind}` → forget the credential + re-advertise. */
|
|
248
|
+
async function handleEngineDisconnect(
|
|
249
|
+
req: IncomingMessage,
|
|
250
|
+
res: ServerResponse,
|
|
251
|
+
opts: UiServerOptions,
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const body = await readJsonBody(req);
|
|
254
|
+
const kind = body?.kind;
|
|
255
|
+
if (!isEngineKind(kind)) {
|
|
256
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
257
|
+
}
|
|
258
|
+
disconnectEngine(kind);
|
|
259
|
+
opts.readvertise?.();
|
|
260
|
+
return sendJson(res, EngineOpResponse, { ok: true });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Read a request body and parse it as a JSON object; null on empty/invalid. */
|
|
264
|
+
async function readJsonBody(
|
|
265
|
+
req: IncomingMessage,
|
|
266
|
+
): Promise<Record<string, unknown> | null> {
|
|
267
|
+
const chunks: Buffer[] = [];
|
|
268
|
+
let size = 0;
|
|
269
|
+
for await (const chunk of req) {
|
|
270
|
+
const buf = chunk as Buffer;
|
|
271
|
+
size += buf.length;
|
|
272
|
+
if (size > 64 * 1024) throw new Error("request body too large");
|
|
273
|
+
chunks.push(buf);
|
|
274
|
+
}
|
|
275
|
+
if (chunks.length === 0) return null;
|
|
276
|
+
try {
|
|
277
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
278
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
279
|
+
? (parsed as Record<string, unknown>)
|
|
280
|
+
: null;
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
157
286
|
// --- handlers ---------------------------------------------------------------
|
|
158
287
|
|
|
159
288
|
async function tasksBody(opts: UiServerOptions): Promise<TasksResponse> {
|
package/src/ui/types.ts
CHANGED
|
@@ -74,5 +74,28 @@ export type UserRow = z.infer<typeof UserRow>;
|
|
|
74
74
|
export const UsersResponse = z.object({ users: z.array(UserRow) });
|
|
75
75
|
export type UsersResponse = z.infer<typeof UsersResponse>;
|
|
76
76
|
|
|
77
|
+
// GET /api/engines — the engine catalog + which are connected on this host.
|
|
78
|
+
export const EngineCatalogEntry = z.object({
|
|
79
|
+
kind: z.enum(["claude", "codex", "kimi", "grok", "cursor"]),
|
|
80
|
+
label: z.string(),
|
|
81
|
+
authMode: z.enum(["token-command", "login-command", "api-key"]),
|
|
82
|
+
notes: z.string().nullable(),
|
|
83
|
+
getKeyUrl: z.string().nullable(),
|
|
84
|
+
});
|
|
85
|
+
export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
|
|
86
|
+
|
|
87
|
+
export const EnginesResponse = z.object({
|
|
88
|
+
catalog: z.array(EngineCatalogEntry),
|
|
89
|
+
statuses: z.record(z.boolean()), // kind → connected
|
|
90
|
+
});
|
|
91
|
+
export type EnginesResponse = z.infer<typeof EnginesResponse>;
|
|
92
|
+
|
|
93
|
+
// POST /api/engines/disconnect — the disconnect result.
|
|
94
|
+
export const EngineOpResponse = z.object({
|
|
95
|
+
ok: z.boolean(),
|
|
96
|
+
message: z.string().optional(),
|
|
97
|
+
});
|
|
98
|
+
export type EngineOpResponse = z.infer<typeof EngineOpResponse>;
|
|
99
|
+
|
|
77
100
|
export const ErrorResponse = z.object({ error: z.string() });
|
|
78
101
|
export type ErrorResponse = z.infer<typeof ErrorResponse>;
|