@runuai/host 0.8.9 → 0.8.11
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/agents/grok.ts +7 -1
- package/lib/engines.ts +269 -52
- package/lib/standard-image.ts +10 -12
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +5 -0
- package/src/ui/types.ts +3 -0
- package/ui/app.js +29 -11
package/lib/agents/grok.ts
CHANGED
|
@@ -148,6 +148,11 @@ export class GrokSession implements AgentSession {
|
|
|
148
148
|
|
|
149
149
|
private buildArgs(prompt: string): string[] {
|
|
150
150
|
const args = ["exec", "-i", "-u", "node"];
|
|
151
|
+
// Host-resident API key (pasted in the engines panel, ADR-015): forward
|
|
152
|
+
// name-only — docker takes the value from the client env, keeping the
|
|
153
|
+
// secret out of argv. Subscription logins need nothing here (task-up
|
|
154
|
+
// copies ~/.grok into the container instead).
|
|
155
|
+
if (process.env.XAI_API_KEY) args.push("-e", "XAI_API_KEY");
|
|
151
156
|
for (const [k, v] of Object.entries(this.agentEnv)) {
|
|
152
157
|
args.push("-e", `${k}=${v}`);
|
|
153
158
|
}
|
|
@@ -249,7 +254,8 @@ register({
|
|
|
249
254
|
supportedModels: () => [...GROK_MODELS],
|
|
250
255
|
defaultModel: GROK_DEFAULT_MODEL,
|
|
251
256
|
supportedEfforts: () => [...GROK_EFFORTS],
|
|
252
|
-
available: () =>
|
|
257
|
+
available: () =>
|
|
258
|
+
existsSync(grokAuthPath()) || Boolean(process.env.XAI_API_KEY),
|
|
253
259
|
create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
|
|
254
260
|
new GrokSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
|
|
255
261
|
});
|
package/lib/engines.ts
CHANGED
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A single descriptor table for the AI coding engines the host can run
|
|
5
5
|
* (Claude, Codex, Kimi, Grok, Cursor) plus the connect/disconnect/status logic
|
|
6
|
-
* the local UI drives
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* the local UI drives (the desktop app renders that same UI). It writes the
|
|
7
|
+
* `.env.local` the RUNNING launchd/npm host reads (UAI_HOME) and sets
|
|
8
|
+
* `process.env` so a connect takes effect WITHOUT a host restart — the
|
|
9
|
+
* adapters' `available()` and task-up's env injection both read `process.env`.
|
|
10
|
+
*
|
|
11
|
+
* CLI resolution: a host supervised by the desktop app (or launchd) inherits a
|
|
12
|
+
* minimal GUI PATH that lacks wherever the engine CLIs live (~/.local/bin,
|
|
13
|
+
* npm/nvm/asdf prefixes, Homebrew), so spawning a bare `claude`/`codex` would
|
|
14
|
+
* ENOENT even though the CLI is installed. We resolve binaries the way the
|
|
15
|
+
* owner's terminal would — well-known install dirs first, then the login
|
|
16
|
+
* shell's PATH (same trick as the desktop's docker.ts) — and spawn with that
|
|
17
|
+
* PATH so `#!/usr/bin/env node` shebangs resolve too.
|
|
11
18
|
*
|
|
12
19
|
* Three auth modes:
|
|
13
20
|
* - token-command (Claude): `claude setup-token` prints a token to stdout;
|
|
@@ -17,6 +24,21 @@
|
|
|
17
24
|
* writes a config file; success = that file appearing.
|
|
18
25
|
* - api-key (Cursor): no command — persist a pasted CURSOR_API_KEY.
|
|
19
26
|
*
|
|
27
|
+
* Besides its primary mode, an engine can accept a pasted API key as an
|
|
28
|
+
* alternative (`apiKeyHint`/`apiKeyUrl` in the catalog drive the UI):
|
|
29
|
+
* - claude → ANTHROPIC_API_KEY (the adapter already forwards it; a pasted
|
|
30
|
+
* `sk-ant-oat…` still lands in CLAUDE_CODE_OAUTH_TOKEN — classified by
|
|
31
|
+
* prefix, so either credential works in either paste box).
|
|
32
|
+
* - codex → ~/.codex/auth.json `{"OPENAI_API_KEY": …}`, the same plaintext
|
|
33
|
+
* file `codex login --with-api-key` writes. Written directly (not via the
|
|
34
|
+
* CLI): task-up docker-cp's that FILE into containers, so a keychain-backed
|
|
35
|
+
* CLI login would break task auth, and this works with no host CLI at all.
|
|
36
|
+
* - grok → XAI_API_KEY (documented pay-as-you-go fallback of the grok CLI;
|
|
37
|
+
* the adapter forwards it into task containers).
|
|
38
|
+
* - kimi → none: Kimi Code reads keys only from its config.toml via the
|
|
39
|
+
* interactive `/login`, never from the environment — so we don't offer a
|
|
40
|
+
* paste box we can't honor.
|
|
41
|
+
*
|
|
20
42
|
* Detection mirrors each adapter's `available()`: env/`.env.local` for
|
|
21
43
|
* claude/cursor, a config file under the owner home for codex/kimi/grok.
|
|
22
44
|
*
|
|
@@ -25,7 +47,11 @@
|
|
|
25
47
|
* the real filesystem or spawning anything.
|
|
26
48
|
*/
|
|
27
49
|
|
|
28
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
execFile as nodeExecFile,
|
|
52
|
+
spawn as nodeSpawn,
|
|
53
|
+
type ChildProcess,
|
|
54
|
+
} from "node:child_process";
|
|
29
55
|
import {
|
|
30
56
|
chmodSync,
|
|
31
57
|
existsSync,
|
|
@@ -51,6 +77,10 @@ export interface EngineCatalogEntry {
|
|
|
51
77
|
notes: string | null;
|
|
52
78
|
/** Where to mint an API key (api-key mode only). */
|
|
53
79
|
getKeyUrl: string | null;
|
|
80
|
+
/** Placeholder for the pasted-API-key alternative; null = not supported. */
|
|
81
|
+
apiKeyHint: string | null;
|
|
82
|
+
/** Where to mint a key for the pasted-API-key alternative. */
|
|
83
|
+
apiKeyUrl: string | null;
|
|
54
84
|
}
|
|
55
85
|
|
|
56
86
|
interface EngineDescriptor {
|
|
@@ -58,6 +88,9 @@ interface EngineDescriptor {
|
|
|
58
88
|
authMode: EngineAuthMode;
|
|
59
89
|
notes: string;
|
|
60
90
|
getKeyUrl?: string;
|
|
91
|
+
/** Pasted-API-key alternative (absent = login/token only, e.g. kimi). */
|
|
92
|
+
apiKeyHint?: string;
|
|
93
|
+
apiKeyUrl?: string;
|
|
61
94
|
}
|
|
62
95
|
|
|
63
96
|
/** Static descriptor table — the single source of truth for engine metadata. */
|
|
@@ -67,11 +100,15 @@ const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
|
|
|
67
100
|
authMode: "token-command",
|
|
68
101
|
notes:
|
|
69
102
|
"Opens your browser to authorize Claude, then captures the token automatically.",
|
|
103
|
+
apiKeyHint: "sk-ant-…",
|
|
104
|
+
apiKeyUrl: "https://console.anthropic.com/settings/keys",
|
|
70
105
|
},
|
|
71
106
|
codex: {
|
|
72
107
|
label: "Codex",
|
|
73
108
|
authMode: "login-command",
|
|
74
109
|
notes: "Opens your browser to sign in to your OpenAI Codex account.",
|
|
110
|
+
apiKeyHint: "sk-…",
|
|
111
|
+
apiKeyUrl: "https://platform.openai.com/api-keys",
|
|
75
112
|
},
|
|
76
113
|
kimi: {
|
|
77
114
|
label: "Kimi Code",
|
|
@@ -84,6 +121,8 @@ const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
|
|
|
84
121
|
authMode: "login-command",
|
|
85
122
|
notes:
|
|
86
123
|
"Sign in with your xAI Grok subscription. Activates after the next image rebuild.",
|
|
124
|
+
apiKeyHint: "xai-…",
|
|
125
|
+
apiKeyUrl: "https://console.x.ai",
|
|
87
126
|
},
|
|
88
127
|
cursor: {
|
|
89
128
|
label: "Cursor",
|
|
@@ -101,7 +140,11 @@ const ORDER: EngineKind[] = ["claude", "codex", "kimi", "grok", "cursor"];
|
|
|
101
140
|
// ---------------------------------------------------------------------------
|
|
102
141
|
|
|
103
142
|
/** How a login/token command is spawned. Tests provide a fake. */
|
|
104
|
-
export type EngineSpawn = (
|
|
143
|
+
export type EngineSpawn = (
|
|
144
|
+
command: string,
|
|
145
|
+
args: string[],
|
|
146
|
+
env: NodeJS.ProcessEnv,
|
|
147
|
+
) => ChildProcess;
|
|
105
148
|
|
|
106
149
|
export interface EngineSeams {
|
|
107
150
|
/** Spawn a CLI, piping stdout/stderr. */
|
|
@@ -112,18 +155,53 @@ export interface EngineSeams {
|
|
|
112
155
|
ownerHome: () => string;
|
|
113
156
|
/** The live process env (adapters + task-up read creds from here). */
|
|
114
157
|
procEnv: NodeJS.ProcessEnv;
|
|
158
|
+
/** PATH as the owner's login shell sees it (null when unprobeable). */
|
|
159
|
+
loginShellPath: () => Promise<string | null>;
|
|
160
|
+
/** System-wide bin dirs probed for engine CLIs (tests pin to []). */
|
|
161
|
+
systemBinDirs: string[];
|
|
115
162
|
}
|
|
116
163
|
|
|
117
164
|
function defaultSeams(): EngineSeams {
|
|
118
165
|
return {
|
|
119
|
-
spawn: (command, args) =>
|
|
120
|
-
nodeSpawn(command, args, {
|
|
166
|
+
spawn: (command, args, spawnEnv) =>
|
|
167
|
+
nodeSpawn(command, args, {
|
|
168
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
169
|
+
env: spawnEnv,
|
|
170
|
+
}),
|
|
121
171
|
envLocalPath: () => join(env.uaiHome, ".env.local"),
|
|
122
172
|
ownerHome: () => process.env.UAI_OWNER_HOME?.trim() || homedir(),
|
|
123
173
|
procEnv: process.env,
|
|
174
|
+
loginShellPath: probeLoginShellPath,
|
|
175
|
+
systemBinDirs: ["/opt/homebrew/bin", "/usr/local/bin"],
|
|
124
176
|
};
|
|
125
177
|
}
|
|
126
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The PATH the owner's login shell exports. Runs `/usr/bin/env` (not
|
|
181
|
+
* `echo $PATH`) so the value is the real colon-joined exported PATH under any
|
|
182
|
+
* shell — fish's `$PATH` is a list and would echo space-joined — and takes the
|
|
183
|
+
* LAST `PATH=` line so profile echo noise can't spoof it. Null on any failure;
|
|
184
|
+
* callers fall back to the inherited PATH.
|
|
185
|
+
*/
|
|
186
|
+
function probeLoginShellPath(): Promise<string | null> {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
const shell = process.env.SHELL?.trim() || "/bin/zsh";
|
|
189
|
+
nodeExecFile(
|
|
190
|
+
shell,
|
|
191
|
+
["-lc", "/usr/bin/env"],
|
|
192
|
+
{ timeout: 8_000 },
|
|
193
|
+
(err, stdout) => {
|
|
194
|
+
if (err) return resolve(null);
|
|
195
|
+
const lines = String(stdout)
|
|
196
|
+
.split(/\r?\n/)
|
|
197
|
+
.filter((l) => l.startsWith("PATH="));
|
|
198
|
+
const last = lines[lines.length - 1];
|
|
199
|
+
resolve(last ? last.slice("PATH=".length).trim() || null : null);
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
127
205
|
function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
|
|
128
206
|
return { ...defaultSeams(), ...seams };
|
|
129
207
|
}
|
|
@@ -132,7 +210,7 @@ function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
|
|
|
132
210
|
// Public catalog / status.
|
|
133
211
|
// ---------------------------------------------------------------------------
|
|
134
212
|
|
|
135
|
-
/** The static engine catalog (kind, label, authMode, notes,
|
|
213
|
+
/** The static engine catalog (kind, label, authMode, notes, key URLs). */
|
|
136
214
|
export function engineCatalog(): EngineCatalogEntry[] {
|
|
137
215
|
return ORDER.map((kind) => {
|
|
138
216
|
const d = DESCRIPTORS[kind];
|
|
@@ -142,6 +220,8 @@ export function engineCatalog(): EngineCatalogEntry[] {
|
|
|
142
220
|
authMode: d.authMode,
|
|
143
221
|
notes: d.notes,
|
|
144
222
|
getKeyUrl: d.getKeyUrl ?? null,
|
|
223
|
+
apiKeyHint: d.apiKeyHint ?? null,
|
|
224
|
+
apiKeyUrl: d.apiKeyUrl ?? null,
|
|
145
225
|
};
|
|
146
226
|
});
|
|
147
227
|
}
|
|
@@ -183,9 +263,9 @@ export function isEngineKind(value: unknown): value is EngineKind {
|
|
|
183
263
|
// ---------------------------------------------------------------------------
|
|
184
264
|
|
|
185
265
|
export interface ConnectOptions {
|
|
186
|
-
/**
|
|
266
|
+
/** A pasted API key — primary for Cursor, alternative for claude/codex/grok. */
|
|
187
267
|
apiKey?: string;
|
|
188
|
-
/** token-command manual fallback: a pasted token (Claude). */
|
|
268
|
+
/** token-command manual fallback: a pasted token or API key (Claude). */
|
|
189
269
|
pastedToken?: string;
|
|
190
270
|
}
|
|
191
271
|
|
|
@@ -207,21 +287,14 @@ export async function connectEngine(
|
|
|
207
287
|
const s = withDefaults(seams);
|
|
208
288
|
const d = DESCRIPTORS[kind];
|
|
209
289
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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.` };
|
|
290
|
+
// A pasted API key connects without spawning anything — the only flow for
|
|
291
|
+
// api-key engines (Cursor), an alternative for the others.
|
|
292
|
+
if (d.authMode === "api-key" || opts.apiKey !== undefined) {
|
|
293
|
+
return saveApiKey(kind, d, opts.apiKey ?? "", s);
|
|
221
294
|
}
|
|
222
295
|
|
|
223
296
|
if (d.authMode === "token-command") {
|
|
224
|
-
// Manual paste fallback — accept a token without spawning
|
|
297
|
+
// Manual paste fallback — accept a token (or API key) without spawning.
|
|
225
298
|
if (opts.pastedToken !== undefined) {
|
|
226
299
|
const token = opts.pastedToken.trim();
|
|
227
300
|
if (!token) return { ok: false, message: "Paste a token." };
|
|
@@ -231,7 +304,15 @@ export async function connectEngine(
|
|
|
231
304
|
message: "That doesn't look like a token. Paste just the value.",
|
|
232
305
|
};
|
|
233
306
|
}
|
|
234
|
-
|
|
307
|
+
// One paste box serves both credential kinds — classify by prefix so an
|
|
308
|
+
// `sk-ant-api…` platform key doesn't get stored as an OAuth token.
|
|
309
|
+
upsertEnvLocal(
|
|
310
|
+
token.startsWith("sk-ant-api")
|
|
311
|
+
? "ANTHROPIC_API_KEY"
|
|
312
|
+
: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
313
|
+
token,
|
|
314
|
+
s,
|
|
315
|
+
);
|
|
235
316
|
return { ok: true, message: `${d.label} connected.` };
|
|
236
317
|
}
|
|
237
318
|
return runTokenCommand(kind, d.label, onLog, s);
|
|
@@ -240,6 +321,70 @@ export async function connectEngine(
|
|
|
240
321
|
return runLoginCommand(kind, d.label, onLog, s);
|
|
241
322
|
}
|
|
242
323
|
|
|
324
|
+
/** Persist a pasted API key where the engine's adapter actually reads it. */
|
|
325
|
+
function saveApiKey(
|
|
326
|
+
kind: EngineKind,
|
|
327
|
+
d: EngineDescriptor,
|
|
328
|
+
raw: string,
|
|
329
|
+
s: EngineSeams,
|
|
330
|
+
): ConnectResult {
|
|
331
|
+
const key = raw.trim();
|
|
332
|
+
if (!key) return { ok: false, message: `Paste your ${d.label} API key.` };
|
|
333
|
+
if (/\s/.test(key) || key.length < 8) {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
message: "That doesn't look like an API key. Paste just the value.",
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
switch (kind) {
|
|
340
|
+
case "cursor":
|
|
341
|
+
upsertEnvLocal("CURSOR_API_KEY", key, s);
|
|
342
|
+
break;
|
|
343
|
+
case "claude":
|
|
344
|
+
// Accept either credential kind here too (see the paste fallback above).
|
|
345
|
+
upsertEnvLocal(
|
|
346
|
+
key.startsWith("sk-ant-oat") ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY",
|
|
347
|
+
key,
|
|
348
|
+
s,
|
|
349
|
+
);
|
|
350
|
+
break;
|
|
351
|
+
case "grok":
|
|
352
|
+
// The grok CLI's documented pay-as-you-go fallback; the adapter forwards
|
|
353
|
+
// it into task containers.
|
|
354
|
+
upsertEnvLocal("XAI_API_KEY", key, s);
|
|
355
|
+
break;
|
|
356
|
+
case "codex":
|
|
357
|
+
writeCodexAuthJson(key, s);
|
|
358
|
+
break;
|
|
359
|
+
case "kimi":
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
message:
|
|
363
|
+
"Kimi Code authenticates via subscription sign-in — it doesn't read API keys from the environment.",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return { ok: true, message: `${d.label} connected.` };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Write `~/.codex/auth.json` the way `codex login --with-api-key` does. We
|
|
371
|
+
* write the plaintext file directly (not via the CLI) because task-up
|
|
372
|
+
* docker-cp's this exact FILE into containers — a keychain-backed CLI login
|
|
373
|
+
* would leave nothing to copy — and it must work without a host codex CLI.
|
|
374
|
+
*/
|
|
375
|
+
function writeCodexAuthJson(key: string, s: EngineSeams): void {
|
|
376
|
+
const file = join(s.ownerHome(), ".codex", "auth.json");
|
|
377
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
378
|
+
writeFileSync(file, `${JSON.stringify({ OPENAI_API_KEY: key }, null, 2)}\n`, {
|
|
379
|
+
mode: 0o600,
|
|
380
|
+
});
|
|
381
|
+
try {
|
|
382
|
+
chmodSync(file, 0o600);
|
|
383
|
+
} catch {
|
|
384
|
+
/* best effort */
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
243
388
|
/** Disconnect an engine: forget its credential (env line and/or config file). */
|
|
244
389
|
export function disconnectEngine(
|
|
245
390
|
kind: EngineKind,
|
|
@@ -256,6 +401,8 @@ export function disconnectEngine(
|
|
|
256
401
|
removeEnvLocal("ANTHROPIC_AUTH_TOKEN", s);
|
|
257
402
|
return;
|
|
258
403
|
}
|
|
404
|
+
// grok can be connected via login (auth.json) OR a pasted key — drop both.
|
|
405
|
+
if (kind === "grok") removeEnvLocal("XAI_API_KEY", s);
|
|
259
406
|
// login-command engines: remove the config file the adapter detects.
|
|
260
407
|
try {
|
|
261
408
|
rmSync(configPath(kind, s), { force: true });
|
|
@@ -299,9 +446,10 @@ function detect(kind: EngineKind, s: EngineSeams): boolean {
|
|
|
299
446
|
);
|
|
300
447
|
case "cursor":
|
|
301
448
|
return envOrFileHas("CURSOR_API_KEY", s);
|
|
449
|
+
case "grok":
|
|
450
|
+
return existsSync(configPath(kind, s)) || envOrFileHas("XAI_API_KEY", s);
|
|
302
451
|
case "codex":
|
|
303
452
|
case "kimi":
|
|
304
|
-
case "grok":
|
|
305
453
|
return existsSync(configPath(kind, s));
|
|
306
454
|
}
|
|
307
455
|
}
|
|
@@ -327,26 +475,103 @@ function configPath(kind: EngineKind, s: EngineSeams): string {
|
|
|
327
475
|
return join(home, ".grok", "auth.json");
|
|
328
476
|
}
|
|
329
477
|
|
|
330
|
-
/**
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
478
|
+
/**
|
|
479
|
+
* Well-known install locations for an engine CLI — the dirs a GUI-launched
|
|
480
|
+
* host's minimal PATH misses. Home-relative entries resolve against the OWNER
|
|
481
|
+
* home (seam-injectable). The binary name equals the engine kind for all four
|
|
482
|
+
* CLI engines.
|
|
483
|
+
*/
|
|
484
|
+
function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
|
|
485
|
+
const home = s.ownerHome();
|
|
486
|
+
const common = [join(home, ".local", "bin"), ...s.systemBinDirs];
|
|
487
|
+
switch (kind) {
|
|
488
|
+
case "claude":
|
|
489
|
+
// Native installer symlinks ~/.local/bin/claude (preferred); the older
|
|
490
|
+
// "local install" migration keeps a wrapper at ~/.claude/local/claude.
|
|
491
|
+
return [join(home, ".local", "bin"), join(home, ".claude", "local"), ...s.systemBinDirs].map(
|
|
492
|
+
(d) => join(d, "claude"),
|
|
493
|
+
);
|
|
494
|
+
case "codex":
|
|
495
|
+
return common.map((d) => join(d, "codex"));
|
|
496
|
+
case "kimi":
|
|
497
|
+
return [join(home, ".kimi-code", "bin"), ...common].map((d) =>
|
|
498
|
+
join(d, "kimi"),
|
|
499
|
+
);
|
|
500
|
+
case "grok":
|
|
501
|
+
return [join(home, ".grok", "bin"), ...common].map((d) => join(d, "grok"));
|
|
502
|
+
case "cursor":
|
|
503
|
+
return []; // api-key mode — never spawned
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
interface ResolvedBin {
|
|
508
|
+
/** Command to spawn — absolute when found, else the bare name. */
|
|
509
|
+
bin: string;
|
|
510
|
+
/** PATH for the child, augmented so nested lookups (node, git) resolve. */
|
|
511
|
+
path: string;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Find the engine CLI the way the owner's terminal would: well-known install
|
|
516
|
+
* dirs first (fast, no subprocess), then the login shell's PATH (sources the
|
|
517
|
+
* owner's profile — covers nvm/asdf/npm prefixes), else the bare name against
|
|
518
|
+
* the inherited PATH so a genuinely missing CLI still surfaces the friendly
|
|
519
|
+
* ENOENT "install it first" message.
|
|
520
|
+
*/
|
|
521
|
+
async function resolveEngineBin(
|
|
522
|
+
kind: EngineKind,
|
|
523
|
+
s: EngineSeams,
|
|
524
|
+
): Promise<ResolvedBin> {
|
|
525
|
+
const inherited = s.procEnv.PATH ?? "";
|
|
526
|
+
for (const file of candidateBins(kind, s)) {
|
|
527
|
+
if (existsSync(file)) {
|
|
528
|
+
return { bin: file, path: prependPath([dirname(file)], inherited) };
|
|
529
|
+
}
|
|
335
530
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
531
|
+
const login = await s.loginShellPath();
|
|
532
|
+
if (login) {
|
|
533
|
+
const dirs = login.split(":").filter(Boolean);
|
|
534
|
+
const dir = dirs.find((d) => existsSync(join(d, kind)));
|
|
535
|
+
// Found or not, hand the login PATH to the child — it is the environment
|
|
536
|
+
// where the CLI (and whatever it shells out to) is known to work.
|
|
537
|
+
return {
|
|
538
|
+
bin: dir ? join(dir, kind) : kind,
|
|
539
|
+
path: prependPath(dirs, inherited),
|
|
540
|
+
};
|
|
339
541
|
}
|
|
340
|
-
|
|
341
|
-
|
|
542
|
+
return { bin: kind, path: inherited };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Prepend dirs to a colon-joined PATH, deduped, order-preserving. */
|
|
546
|
+
function prependPath(prepend: string[], current: string): string {
|
|
547
|
+
const seen = new Set<string>();
|
|
548
|
+
return [...prepend, ...current.split(":")]
|
|
549
|
+
.filter((p) => p.length > 0 && !seen.has(p) && (seen.add(p), true))
|
|
550
|
+
.join(":");
|
|
342
551
|
}
|
|
343
552
|
|
|
344
|
-
|
|
553
|
+
/** Resolve the CLI and spawn it with the augmented PATH. */
|
|
554
|
+
async function spawnEngineCli(
|
|
555
|
+
kind: EngineKind,
|
|
556
|
+
args: string[],
|
|
557
|
+
s: EngineSeams,
|
|
558
|
+
): Promise<ChildProcess> {
|
|
559
|
+
const { bin, path } = await resolveEngineBin(kind, s);
|
|
560
|
+
return s.spawn(bin, args, { ...s.procEnv, PATH: path });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function runTokenCommand(
|
|
345
564
|
kind: EngineKind,
|
|
346
565
|
label: string,
|
|
347
566
|
onLog: (line: string) => void,
|
|
348
567
|
s: EngineSeams,
|
|
349
568
|
): Promise<ConnectResult> {
|
|
569
|
+
let child: ChildProcess;
|
|
570
|
+
try {
|
|
571
|
+
child = await spawnEngineCli(kind, ["setup-token"], s);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
574
|
+
}
|
|
350
575
|
return new Promise((resolve) => {
|
|
351
576
|
let settled = false;
|
|
352
577
|
const done = (r: ConnectResult): void => {
|
|
@@ -355,13 +580,6 @@ function runTokenCommand(
|
|
|
355
580
|
resolve(r);
|
|
356
581
|
}
|
|
357
582
|
};
|
|
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
583
|
let stdout = "";
|
|
366
584
|
child.stdout?.on("data", (b: Buffer) => {
|
|
367
585
|
const text = b.toString("utf8");
|
|
@@ -394,12 +612,18 @@ function runTokenCommand(
|
|
|
394
612
|
});
|
|
395
613
|
}
|
|
396
614
|
|
|
397
|
-
function runLoginCommand(
|
|
615
|
+
async function runLoginCommand(
|
|
398
616
|
kind: EngineKind,
|
|
399
617
|
label: string,
|
|
400
618
|
onLog: (line: string) => void,
|
|
401
619
|
s: EngineSeams,
|
|
402
620
|
): Promise<ConnectResult> {
|
|
621
|
+
let child: ChildProcess;
|
|
622
|
+
try {
|
|
623
|
+
child = await spawnEngineCli(kind, ["login"], s);
|
|
624
|
+
} catch (err) {
|
|
625
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
626
|
+
}
|
|
403
627
|
return new Promise((resolve) => {
|
|
404
628
|
let settled = false;
|
|
405
629
|
const done = (r: ConnectResult): void => {
|
|
@@ -408,13 +632,6 @@ function runLoginCommand(
|
|
|
408
632
|
resolve(r);
|
|
409
633
|
}
|
|
410
634
|
};
|
|
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
635
|
child.stdout?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
|
|
419
636
|
child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
|
|
420
637
|
child.on("error", (err: NodeJS.ErrnoException) =>
|
package/lib/standard-image.ts
CHANGED
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
|
-
import { existsSync } from "node:fs";
|
|
22
21
|
import { readdir, readFile } from "node:fs/promises";
|
|
23
|
-
import { homedir } from "node:os";
|
|
24
22
|
import { dirname, join, resolve } from "node:path";
|
|
25
23
|
import { fileURLToPath } from "node:url";
|
|
26
24
|
|
|
25
|
+
import { detectEngine } from "./engines";
|
|
26
|
+
|
|
27
27
|
/** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
|
|
28
28
|
export const STANDARD_IMAGE_TAG = "uai-standard:dev";
|
|
29
29
|
export const ASDF_DATA_VOLUME = "uai-asdf-data";
|
|
@@ -71,23 +71,21 @@ const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
|
|
|
71
71
|
*/
|
|
72
72
|
/**
|
|
73
73
|
* Which OPTIONAL agent CLIs the operator has actually configured — so the
|
|
74
|
-
* image installs only those, not every engine on every host.
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* image installs only those, not every engine on every host. Delegates to
|
|
75
|
+
* engines.ts detection so every connect flavor counts: kimi's copied cred
|
|
76
|
+
* file, grok's login file OR a pasted XAI_API_KEY, cursor's CURSOR_API_KEY
|
|
77
|
+
* (claude/codex are always baked). Folded into the build hash below, so
|
|
78
|
+
* connecting a new engine triggers a rebuild.
|
|
78
79
|
*/
|
|
79
80
|
export function configuredOptionalEngines(): {
|
|
80
81
|
kimi: boolean;
|
|
81
82
|
grok: boolean;
|
|
82
83
|
cursor: boolean;
|
|
83
84
|
} {
|
|
84
|
-
const home = process.env.UAI_OWNER_HOME?.trim() || homedir();
|
|
85
85
|
return {
|
|
86
|
-
kimi:
|
|
87
|
-
grok:
|
|
88
|
-
|
|
89
|
-
// config file — install it when the key is present.
|
|
90
|
-
cursor: Boolean(process.env.CURSOR_API_KEY),
|
|
86
|
+
kimi: detectEngine("kimi"),
|
|
87
|
+
grok: detectEngine("grok"),
|
|
88
|
+
cursor: detectEngine("cursor"),
|
|
91
89
|
};
|
|
92
90
|
}
|
|
93
91
|
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -365,6 +365,11 @@ fi
|
|
|
365
365
|
# claude and the code-server terminal's interactive claude authenticate.
|
|
366
366
|
printf ' %s: "${%s:-}"\n' \
|
|
367
367
|
"CLAUDE_CODE_OAUTH_TOKEN" "CLAUDE_CODE_OAUTH_TOKEN"
|
|
368
|
+
# Pasted engine API keys (ADR-070) ride the same at-up interpolation, so the
|
|
369
|
+
# terminal CLIs authenticate like the adapters' exec-time env forwarding.
|
|
370
|
+
printf ' %s: "${%s:-}"\n' "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEY"
|
|
371
|
+
printf ' %s: "${%s:-}"\n' "ANTHROPIC_AUTH_TOKEN" "ANTHROPIC_AUTH_TOKEN"
|
|
372
|
+
printf ' %s: "${%s:-}"\n' "XAI_API_KEY" "XAI_API_KEY"
|
|
368
373
|
# ADR-053: point Playwright at the shared browser cache volume.
|
|
369
374
|
printf ' PLAYWRIGHT_BROWSERS_PATH: "/opt/pw-browsers"\n'
|
|
370
375
|
# No GH_TOKEN/GITHUB_TOKEN in the container env (ADR-027). `gh` prefers such
|
package/src/ui/types.ts
CHANGED
|
@@ -81,6 +81,9 @@ export const EngineCatalogEntry = z.object({
|
|
|
81
81
|
authMode: z.enum(["token-command", "login-command", "api-key"]),
|
|
82
82
|
notes: z.string().nullable(),
|
|
83
83
|
getKeyUrl: z.string().nullable(),
|
|
84
|
+
// Pasted-API-key alternative to the login/token flow (null = not offered).
|
|
85
|
+
apiKeyHint: z.string().nullable(),
|
|
86
|
+
apiKeyUrl: z.string().nullable(),
|
|
84
87
|
});
|
|
85
88
|
export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
|
|
86
89
|
|
package/ui/app.js
CHANGED
|
@@ -516,19 +516,25 @@ function commandForm(e) {
|
|
|
516
516
|
actions.append(connect);
|
|
517
517
|
form.append(actions, status, log);
|
|
518
518
|
|
|
519
|
-
//
|
|
520
|
-
|
|
519
|
+
// Manual paste fallback for when the browser flow isn't possible: Claude
|
|
520
|
+
// takes a token or API key (one box, classified server-side); login-command
|
|
521
|
+
// engines with an apiKeyHint (Codex, Grok) take an API key.
|
|
522
|
+
const isToken = e.authMode === "token-command";
|
|
523
|
+
if (isToken || e.apiKeyHint) {
|
|
524
|
+
const saveLabel = isToken ? "Save token" : "Save key";
|
|
521
525
|
const pasteToggle = document.createElement("button");
|
|
522
526
|
pasteToggle.className = "link-btn";
|
|
523
527
|
pasteToggle.type = "button";
|
|
524
|
-
pasteToggle.textContent =
|
|
528
|
+
pasteToggle.textContent = isToken
|
|
529
|
+
? "Paste a token or API key instead"
|
|
530
|
+
: "Use an API key instead";
|
|
525
531
|
const pasteWrap = document.createElement("div");
|
|
526
532
|
pasteWrap.className = "engine-setup";
|
|
527
533
|
pasteWrap.hidden = true;
|
|
528
534
|
const pInput = document.createElement("input");
|
|
529
535
|
pInput.type = "password";
|
|
530
536
|
pInput.className = "text-input";
|
|
531
|
-
pInput.placeholder = "sk-ant
|
|
537
|
+
pInput.placeholder = e.apiKeyHint || "sk-ant-…";
|
|
532
538
|
pInput.autocomplete = "off";
|
|
533
539
|
pInput.spellcheck = false;
|
|
534
540
|
const pField = document.createElement("div");
|
|
@@ -539,30 +545,42 @@ function commandForm(e) {
|
|
|
539
545
|
const pSave = document.createElement("button");
|
|
540
546
|
pSave.className = "btn";
|
|
541
547
|
pSave.type = "button";
|
|
542
|
-
pSave.textContent =
|
|
548
|
+
pSave.textContent = saveLabel;
|
|
543
549
|
pSave.addEventListener("click", async () => {
|
|
544
|
-
const
|
|
545
|
-
if (!
|
|
550
|
+
const value = pInput.value.trim();
|
|
551
|
+
if (!value) {
|
|
546
552
|
pStatus.className = "setup-status err";
|
|
547
|
-
pStatus.textContent = "Paste a token first.";
|
|
553
|
+
pStatus.textContent = isToken ? "Paste a token first." : "Paste a key first.";
|
|
548
554
|
return;
|
|
549
555
|
}
|
|
550
556
|
pSave.disabled = true;
|
|
551
557
|
pSave.textContent = "Saving…";
|
|
552
|
-
const
|
|
558
|
+
const body = isToken
|
|
559
|
+
? { kind: e.kind, pastedToken: value }
|
|
560
|
+
: { kind: e.kind, apiKey: value };
|
|
561
|
+
const result = await runConnect(body);
|
|
553
562
|
if (result.ok) {
|
|
554
563
|
await poll();
|
|
555
564
|
closeModal();
|
|
556
565
|
} else {
|
|
557
566
|
pSave.disabled = false;
|
|
558
|
-
pSave.textContent =
|
|
567
|
+
pSave.textContent = saveLabel;
|
|
559
568
|
pStatus.className = "setup-status err";
|
|
560
|
-
pStatus.textContent = result.message || "Couldn't save
|
|
569
|
+
pStatus.textContent = result.message || "Couldn't save it.";
|
|
561
570
|
}
|
|
562
571
|
});
|
|
563
572
|
const pActions = document.createElement("div");
|
|
564
573
|
pActions.className = "setup-actions";
|
|
565
574
|
pActions.append(pSave);
|
|
575
|
+
if (e.apiKeyUrl) {
|
|
576
|
+
const link = document.createElement("a");
|
|
577
|
+
link.className = "link-btn";
|
|
578
|
+
link.href = e.apiKeyUrl;
|
|
579
|
+
link.target = "_blank";
|
|
580
|
+
link.rel = "noreferrer";
|
|
581
|
+
link.textContent = "Get a key";
|
|
582
|
+
pActions.append(link);
|
|
583
|
+
}
|
|
566
584
|
pasteWrap.append(pField, pActions, pStatus);
|
|
567
585
|
pasteToggle.addEventListener("click", () => {
|
|
568
586
|
pasteWrap.hidden = !pasteWrap.hidden;
|