@runuai/host 0.8.5 → 0.8.7

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.
@@ -212,6 +212,7 @@ export class CursorSession implements AgentSession {
212
212
  "--stream-partial-output",
213
213
  "--force", // container is the sandbox — auto-run tools
214
214
  "--trust", // skip the workspace-trust prompt in headless
215
+ "--approve-mcps", // load the gateway MCP servers from ~/.cursor/mcp.json (ADR-057)
215
216
  );
216
217
  if (this.model && this.model !== "auto") args.push("-m", this.model);
217
218
  if (this.sessionId) args.push("--resume", this.sessionId);
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
+ }
@@ -270,16 +270,20 @@ export function startMcpGateway(): void {
270
270
 
271
271
  // --- task container wiring (ADR-057 task-up writers) -------------------------
272
272
 
273
- /** Idempotent node -e merge of entries into /workspace/.mcp.json. */
273
+ /** Idempotent node -e merge of entries (argv[2]) into an mcpServers file
274
+ * (argv[1]) — reused for Claude's /workspace/.mcp.json and Cursor's
275
+ * ~/.cursor/mcp.json. Creates the parent dir when missing. */
274
276
  const MERGE_MCP_JSON = `
275
277
  const fs = require("fs");
276
- const p = "/workspace/.mcp.json";
278
+ const path = require("path");
279
+ const p = process.argv[1];
280
+ try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch {}
277
281
  let j = {};
278
282
  let existed = true;
279
283
  try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { existed = false; }
280
284
  j.mcpServers = j.mcpServers || {};
281
285
  let changed = false;
282
- for (const [k, v] of Object.entries(JSON.parse(process.argv[1]))) {
286
+ for (const [k, v] of Object.entries(JSON.parse(process.argv[2]))) {
283
287
  if (JSON.stringify(j.mcpServers[k]) !== JSON.stringify(v)) { j.mcpServers[k] = v; changed = true; }
284
288
  }
285
289
  if (changed || !existed) fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\\n");
@@ -290,16 +294,21 @@ function shellQuote(value: string): string {
290
294
  }
291
295
 
292
296
  /**
293
- * Write the task's MCP configs inside the container: gateway-URL entries per
294
- * connection for Claude (/workspace/.mcp.json, merged coexists with the
295
- * ADR-053 browser server) and mcp-remote shims for Codex (config.toml,
296
- * append-once per slug). Safe to re-run every ensure.
297
+ * Write the task's MCP configs inside the container, one shape per engine on
298
+ * the roster all pointing at the same host gateway URLs:
299
+ * - Claude /workspace/.mcp.json (merged; coexists with the ADR-053 browser
300
+ * server). Written unconditionally: the adapter passes `--mcp-config`.
301
+ * - Codex — mcp-remote shims appended once per slug to config.toml.
302
+ * - Cursor — ~/.cursor/mcp.json ({ url } form; the adapter passes
303
+ * `--approve-mcps`).
304
+ * - Grok — `grok mcp add` writes ~/.grok/config.toml (idempotent per slug).
305
+ * Safe to re-run every ensure. `engineKinds` is the roster's agent kinds.
297
306
  */
298
307
  export async function setupMcpTaskConfig(
299
308
  taskId: string,
300
309
  containerName: string,
301
310
  connections: TaskMcpConnection[],
302
- hasCodex: boolean,
311
+ engineKinds: string[],
303
312
  ): Promise<void> {
304
313
  // No early return on empty: the claude adapter passes
305
314
  // `--mcp-config /workspace/.mcp.json` unconditionally (ADR-057), so the
@@ -308,18 +317,25 @@ export async function setupMcpTaskConfig(
308
317
  const acl = ensureTaskGatewayAcl(taskId, connections);
309
318
  const urlFor = (slug: string): string =>
310
319
  `http://host.docker.internal:${MCP_GATEWAY_PORT}/t/${acl.token}/${slug}`;
320
+ const has = (kind: string): boolean => engineKinds.includes(kind);
311
321
 
312
322
  const claudeEntries: Record<string, unknown> = {};
323
+ const cursorEntries: Record<string, unknown> = {};
313
324
  for (const c of connections) {
314
325
  claudeEntries[c.slug] = { type: "http", url: urlFor(c.slug) };
326
+ // Cursor's mcp.json wants a bare { url } for remote (http/sse) servers.
327
+ cursorEntries[c.slug] = { url: urlFor(c.slug) };
315
328
  }
316
329
  const steps = [
317
330
  "mkdir -p /workspace/.claude",
318
331
  `[ -f /workspace/.claude/settings.json ] || printf '%s\\n' ${shellQuote(
319
332
  JSON.stringify({ enableAllProjectMcpServers: true }, null, 2),
320
333
  )} > /workspace/.claude/settings.json`,
321
- `node -e ${shellQuote(MERGE_MCP_JSON)} ${shellQuote(JSON.stringify(claudeEntries))}`,
322
- ...(hasCodex
334
+ `node -e ${shellQuote(MERGE_MCP_JSON)} /workspace/.mcp.json ${shellQuote(
335
+ JSON.stringify(claudeEntries),
336
+ )}`,
337
+ // Codex: stdio-only, so each connection is an mcp-remote shim.
338
+ ...(has("codex")
323
339
  ? connections.map(
324
340
  (c) =>
325
341
  `grep -q "mcp_servers.${c.slug}]" /home/node/.codex/config.toml 2>/dev/null || printf '%s' ${shellQuote(
@@ -327,6 +343,23 @@ export async function setupMcpTaskConfig(
327
343
  )} >> /home/node/.codex/config.toml`,
328
344
  )
329
345
  : []),
346
+ // Cursor: reads ~/.cursor/mcp.json (the adapter passes --approve-mcps).
347
+ ...(has("cursor") && connections.length > 0
348
+ ? [
349
+ `node -e ${shellQuote(MERGE_MCP_JSON)} /home/node/.cursor/mcp.json ${shellQuote(
350
+ JSON.stringify(cursorEntries),
351
+ )}`,
352
+ ]
353
+ : []),
354
+ // Grok: `grok mcp add` writes ~/.grok/config.toml. Idempotent + tolerant.
355
+ ...(has("grok")
356
+ ? connections.map(
357
+ (c) =>
358
+ `/home/node/.local/bin/grok mcp add ${shellQuote(c.slug)} ${shellQuote(
359
+ urlFor(c.slug),
360
+ )} -t http -s user >/dev/null 2>&1 || true`,
361
+ )
362
+ : []),
330
363
  ].join(" && ");
331
364
  const result = await dockerCli(
332
365
  ["exec", containerName, "sh", "-lc", steps],
@@ -172,6 +172,14 @@ class Orchestrator {
172
172
  /** Fold a fresh channel spec into a live channel (ADR-049). */
173
173
  private refreshChannel(channel: Channel, spec: ChannelEnsureInput): void {
174
174
  const known = new Set(channel.roster.map((a) => a.id));
175
+ // Replace EXISTING agents' data with the fresh spec — a mid-task roster edit
176
+ // can change permissions, model, brief, role or skills, and without this the
177
+ // stale snapshot persists (e.g. granting `uai` CLI permissions mid-task never
178
+ // took effect: writeAgentCli kept seeing the old empty list). Swapping the
179
+ // object (not merging) also drops fields that were removed. The token/model a
180
+ // LIVE session already carries only changes on its next respawn.
181
+ const bySpecId = new Map(spec.agents.map((a) => [a.id, a]));
182
+ channel.roster = channel.roster.map((a) => bySpecId.get(a.id) ?? a);
175
183
  for (const agent of spec.agents) {
176
184
  if (!known.has(agent.id)) {
177
185
  channel.roster.push(agent);
@@ -321,7 +329,7 @@ class Orchestrator {
321
329
  channel.taskId,
322
330
  channel.containerName,
323
331
  channel.mcpConnections,
324
- channel.roster.some((a) => a.kind === "codex"),
332
+ channel.roster.map((a) => a.kind),
325
333
  );
326
334
  // Agent CLIs read MCP servers once, at process start — and durable
327
335
  // sessions (ADR-061) make processes long-lived, so without this a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -75,10 +75,10 @@
75
75
  "zod": "^3.23.8"
76
76
  },
77
77
  "devDependencies": {
78
- "@typescript-eslint/eslint-plugin": "^8.59.4",
79
- "@typescript-eslint/parser": "^8.59.4",
80
78
  "@types/node": "^22.9.0",
81
79
  "@types/ws": "^8.18.1",
80
+ "@typescript-eslint/eslint-plugin": "^8.59.4",
81
+ "@typescript-eslint/parser": "^8.59.4",
82
82
  "eslint": "^9.14.0",
83
83
  "typescript": "^5.6.3",
84
84
  "vitest": "^2.1.4"
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) {