@runuai/host 0.8.42 → 0.8.43

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 CHANGED
@@ -6,8 +6,10 @@
6
6
  * task-up (`scripts/agent/task-up.sh`). When the owner re-logs into Codex —
7
7
  * the refresh token gets revoked (a login elsewhere) and `codex login` rewrites
8
8
  * `~/.codex/auth.json` — already-running containers keep their stale copy, so a
9
- * re-login otherwise only helps NEW tasks. This re-copies the fresh `~/.codex`
10
- * into the running task containers so live tasks self-heal.
9
+ * re-login otherwise only helps NEW tasks. The live sweep refreshes the
10
+ * credential-bearing copy while deliberately preserving the container's
11
+ * `config.toml`: browser/MCP setup owns that live file and serializes its
12
+ * updates with agent spawning.
11
13
  *
12
14
  * Three triggers, covering every path:
13
15
  * - {@link watchCodexAuth} — fs.watch on `~/.codex`, for `codex login` run
@@ -40,7 +42,6 @@ import { isNull } from "drizzle-orm";
40
42
 
41
43
  import { getDb, schema } from "./db";
42
44
  import { dockerCli } from "./docker-exec";
43
- import { provisionEngineAccounts } from "./engine-accounts";
44
45
 
45
46
  /** The exact set task-up.sh copies into `/home/node/.codex`. */
46
47
  const CODEX_ITEMS = [
@@ -51,6 +52,8 @@ const CODEX_ITEMS = [
51
52
  "installation_id",
52
53
  "rules",
53
54
  ] as const;
55
+ /** Live auth refresh must not overwrite MCP configuration in a running task. */
56
+ const LIVE_CODEX_ITEMS = CODEX_ITEMS.filter((item) => item !== "config.toml");
54
57
  const EXEC_TIMEOUT_MS = 15_000;
55
58
 
56
59
  /** Injectable seams (defaults hit the real DB / docker / fs) so the copy logic
@@ -107,7 +110,11 @@ async function dockerRunningNames(
107
110
  * 501:20 are unreadable to the container's node). Any nonzero step throws
108
111
  * with the step name and docker's stderr — never file contents.
109
112
  */
110
- async function copyCodexInto(container: string, deps: CodexDeps): Promise<void> {
113
+ async function copyCodexInto(
114
+ container: string,
115
+ deps: CodexDeps,
116
+ items: readonly (typeof CODEX_ITEMS)[number][] = CODEX_ITEMS,
117
+ ): Promise<void> {
111
118
  const exec = deps.exec ?? defaultExec;
112
119
  const exists = deps.fileExists ?? existsSync;
113
120
  const dir = ownerCodexDir();
@@ -125,7 +132,7 @@ async function copyCodexInto(container: string, deps: CodexDeps): Promise<void>
125
132
  ["exec", "-u", "root", container, "mkdir", "-p", "/home/node/.codex"],
126
133
  "mkdir /home/node/.codex",
127
134
  );
128
- for (const item of CODEX_ITEMS) {
135
+ for (const item of items) {
129
136
  const src = join(dir, item);
130
137
  if (!exists(src)) continue;
131
138
  await must(["cp", src, `${container}:/home/node/.codex/`], `docker cp ${item}`);
@@ -163,11 +170,14 @@ export async function injectCodexIntoContainer(
163
170
  }
164
171
 
165
172
  /**
166
- * Re-copy the freshly (re)logged-in `~/.codex` into every task container that
167
- * is both DB-active AND confirmed running by docker. No-op when there is no
168
- * `~/.codex/auth.json` or no active tasks; skipped entirely (with a warning)
169
- * when docker doesn't answer. Per-container failures are surfaced and don't
170
- * stop the sweep.
173
+ * Refresh the freshly (re)logged-in Codex files in every task container that
174
+ * is both DB-active AND confirmed running by docker. `config.toml` is excluded:
175
+ * copying the host's file over a live task races the orchestrator's managed MCP
176
+ * writer and can erase the browser definition after readiness was established.
177
+ * Recovery/start injection still uses the full item set above, before agents
178
+ * can spawn. No-op when there is no `~/.codex/auth.json` or no active tasks;
179
+ * skipped entirely (with a warning) when docker doesn't answer. Per-container
180
+ * failures are surfaced and don't stop the sweep.
171
181
  */
172
182
  export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<void> {
173
183
  const exists = deps.fileExists ?? existsSync;
@@ -189,18 +199,15 @@ export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<v
189
199
  );
190
200
  }
191
201
  if (targets.length === 0) return;
192
- console.log(`[codex] re-copying ~/.codex into ${targets.length} running task(s)`);
202
+ console.log(`[codex] refreshing auth in ${targets.length} running task(s)`);
193
203
  for (const container of targets) {
194
204
  try {
195
- await copyCodexInto(container, deps);
205
+ await copyCodexInto(container, deps, LIVE_CODEX_ITEMS);
196
206
  } catch (err) {
197
207
  console.error(
198
208
  `[codex] reinject into ${container} failed: ${err instanceof Error ? err.message : err}`,
199
209
  );
200
210
  }
201
- // ADR-076: also refresh EXTRA config-dir accounts (codex + opencode) so a
202
- // newly-added second account reaches running tasks, same as the default.
203
- await provisionEngineAccounts(container, ["codex", "opencode"]);
204
211
  }
205
212
  }
206
213
 
@@ -380,29 +380,47 @@ async function copyDirInto(
380
380
  /**
381
381
  * Copy every EXTRA config-dir account (for the given kinds) into its per-account
382
382
  * container dir + chown. The DEFAULT account is copied by task-up.sh, so it is
383
- * skipped here. Best-effort per account — a failure is logged (no secrets) and
384
- * that account simply won't authenticate in this container.
383
+ * skipped here. Best-effort per account — failures are logged (no secrets) and
384
+ * reported in aggregate so the orchestrator can retry without hot-looping.
385
385
  */
386
+ export interface ProvisionEngineAccountsResult {
387
+ copied: number;
388
+ failed: number;
389
+ mayHaveMutated: boolean;
390
+ }
391
+
386
392
  export async function provisionEngineAccounts(
387
393
  container: string,
388
394
  kinds: Iterable<string>,
389
395
  seams: Partial<AccountSeams> = {},
390
- ): Promise<void> {
396
+ ): Promise<ProvisionEngineAccountsResult> {
391
397
  const s = withDefaults(seams);
392
398
  const seen = new Set<string>();
399
+ let copied = 0;
400
+ let failed = 0;
401
+ let mayHaveMutated = false;
393
402
  for (const kind of kinds) {
394
403
  if (seen.has(kind)) continue;
395
404
  seen.add(kind);
396
405
  for (const account of resolveEngineAccounts(kind, s)) {
397
406
  if (account.isDefault || !account.configDir) continue;
398
- if (!s.fileExists(account.configDir.hostDir)) continue;
407
+ if (!s.fileExists(account.configDir.hostDir)) {
408
+ failed += 1;
409
+ continue;
410
+ }
399
411
  try {
400
412
  await copyDirInto(
401
413
  account.configDir.hostDir,
402
414
  account.configDir.containerDir,
403
415
  container,
404
416
  );
417
+ copied += 1;
405
418
  } catch (err) {
419
+ failed += 1;
420
+ // copyDirInto performs more than one external operation. A non-zero
421
+ // result can therefore mean the destination changed before the
422
+ // failure surfaced; callers must fence/reassert managed config.
423
+ mayHaveMutated = true;
406
424
  console.error(
407
425
  `[engine-accounts] provision ${kind}/${account.id} into ${container}: ` +
408
426
  `${err instanceof Error ? err.message : err} — this account won't authenticate in the task`,
@@ -410,6 +428,7 @@ export async function provisionEngineAccounts(
410
428
  }
411
429
  }
412
430
  }
431
+ return { copied, failed, mayHaveMutated };
413
432
  }
414
433
 
415
434
  // ---------------------------------------------------------------------------
@@ -0,0 +1,2 @@
1
+ /** Serializes Uai's in-container writers for managed Claude/Codex MCP files. */
2
+ export const MCP_CONFIG_LOCK_PATH = "/tmp/.uai-mcp-config.lock";
@@ -24,6 +24,7 @@ import { resolve } from "node:path";
24
24
 
25
25
  import { env } from "./env";
26
26
  import { dockerCli } from "./docker-exec";
27
+ import { MCP_CONFIG_LOCK_PATH } from "./mcp-config-lock";
27
28
  import { authHeaderFor, getConnection } from "./mcp-connections";
28
29
 
29
30
  export const MCP_GATEWAY_PORT = Number(process.env.UAI_MCP_GATEWAY_PORT ?? 5877);
@@ -275,27 +276,373 @@ export function startMcpGateway(): void {
275
276
  * Claude's /workspace/.mcp.json + Cursor's ~/.cursor/mcp.json (mcpServers)
276
277
  * and OpenCode's ~/.config/opencode/opencode.json (mcp). Creates the parent
277
278
  * dir when missing. */
278
- const MERGE_MCP_JSON = `
279
+ export const MERGE_MCP_JSON = `
279
280
  const fs = require("fs");
280
281
  const path = require("path");
282
+ const crypto = require("crypto");
281
283
  const p = process.argv[1];
282
284
  const key = process.argv[3] || "mcpServers";
283
- try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch {}
285
+ const plainObject = (value) =>
286
+ value !== null && typeof value === "object" && !Array.isArray(value);
287
+ fs.mkdirSync(path.dirname(p), { recursive: true });
288
+ let original = null;
284
289
  let j = {};
285
- let existed = true;
286
- try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { existed = false; }
287
- j[key] = j[key] || {};
290
+ try {
291
+ original = fs.readFileSync(p, "utf8");
292
+ j = JSON.parse(original);
293
+ } catch (error) {
294
+ if (!error || error.code !== "ENOENT") throw error;
295
+ }
296
+ if (!plainObject(j)) throw new Error("MCP config root must be an object");
297
+ if (j[key] === undefined) j[key] = {};
298
+ if (!plainObject(j[key])) {
299
+ throw new Error("MCP config " + key + " must be an object");
300
+ }
288
301
  let changed = false;
289
302
  for (const [k, v] of Object.entries(JSON.parse(process.argv[2]))) {
290
303
  if (JSON.stringify(j[key][k]) !== JSON.stringify(v)) { j[key][k] = v; changed = true; }
291
304
  }
292
- if (changed || !existed) fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\\n");
305
+ if (changed || original === null) {
306
+ const mode = original === null ? 0o644 : fs.statSync(p).mode & 0o777;
307
+ const tmp = path.join(
308
+ path.dirname(p),
309
+ "." + path.basename(p) + "." + process.pid + "." +
310
+ crypto.randomBytes(8).toString("hex") + ".tmp"
311
+ );
312
+ try {
313
+ fs.writeFileSync(tmp, JSON.stringify(j, null, 2) + "\\n", {
314
+ encoding: "utf8",
315
+ flag: "wx",
316
+ mode,
317
+ });
318
+ let current = null;
319
+ try {
320
+ current = fs.readFileSync(p, "utf8");
321
+ } catch (error) {
322
+ if (!error || error.code !== "ENOENT") throw error;
323
+ }
324
+ if (current !== original) {
325
+ throw new Error("MCP config changed concurrently");
326
+ }
327
+ fs.renameSync(tmp, p);
328
+ } finally {
329
+ try { fs.unlinkSync(tmp); } catch {}
330
+ }
331
+ }
332
+ `.trim();
333
+
334
+ /**
335
+ * Ensure the host-gateway MCP definitions in one Codex home.
336
+ *
337
+ * Codex itself is the TOML parser. This matters because a text search cannot
338
+ * distinguish `[mcp_servers.foo]` from a comment/string, and quoted or spaced
339
+ * spellings name the same TOML table. A same-name definition is accepted only
340
+ * when Codex reports the exact shape Uai writes; foreign definitions and
341
+ * malformed configs fail without being modified.
342
+ */
343
+ export const ENSURE_CODEX_MCP_TOML = String.raw`
344
+ const fs = require("fs");
345
+ const path = require("path");
346
+ const crypto = require("crypto");
347
+ const child = require("child_process");
348
+
349
+ const home = process.argv[1];
350
+ const desired = JSON.parse(process.argv[2]);
351
+ const codexBin = process.argv[3] || "codex";
352
+ const plainObject = (value) =>
353
+ value !== null && typeof value === "object" && !Array.isArray(value);
354
+ if (typeof home !== "string" || !path.isAbsolute(home)) {
355
+ throw new Error("Codex home must be an absolute path");
356
+ }
357
+ if (!plainObject(desired)) {
358
+ throw new Error("Codex MCP definitions must be an object");
359
+ }
360
+ for (const [slug, url] of Object.entries(desired)) {
361
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)) {
362
+ throw new Error("invalid Codex MCP slug");
363
+ }
364
+ if (typeof url !== "string") {
365
+ throw new Error("invalid Codex MCP URL");
366
+ }
367
+ }
368
+
369
+ fs.mkdirSync(home, { recursive: true });
370
+ const file = path.join(home, "config.toml");
371
+ let original = null;
372
+ try {
373
+ original = fs.readFileSync(file, "utf8");
374
+ } catch (error) {
375
+ if (!error || error.code !== "ENOENT") throw error;
376
+ }
377
+
378
+ function runCodex(args) {
379
+ return child.spawnSync(codexBin, args, {
380
+ encoding: "utf8",
381
+ env: Object.assign({}, process.env, { CODEX_HOME: home }),
382
+ maxBuffer: 1024 * 1024,
383
+ timeout: 5000,
384
+ killSignal: "SIGKILL",
385
+ });
386
+ }
387
+
388
+ function detail(result) {
389
+ const lines = String(result.stderr || "")
390
+ .split(/\r?\n/)
391
+ .map((line) => line.trim())
392
+ .filter(Boolean);
393
+ return String(lines.pop() || result.error || result.status);
394
+ }
395
+
396
+ function exactKeys(value, keys) {
397
+ return plainObject(value) &&
398
+ Object.keys(value).sort().join("\0") === keys.slice().sort().join("\0");
399
+ }
400
+
401
+ function managedDefinitionUrl(value, slug) {
402
+ const topKeys = [
403
+ "disabled_reason", "disabled_tools", "enabled", "enabled_tools", "name",
404
+ "startup_timeout_sec", "tool_timeout_sec", "transport"
405
+ ];
406
+ const transportKeys = ["args", "command", "cwd", "env", "env_vars", "type"];
407
+ if (!(exactKeys(value, topKeys) &&
408
+ value.name === slug &&
409
+ value.enabled === true &&
410
+ value.disabled_reason === null &&
411
+ value.enabled_tools === null &&
412
+ value.disabled_tools === null &&
413
+ value.startup_timeout_sec === null &&
414
+ value.tool_timeout_sec === null &&
415
+ exactKeys(value.transport, transportKeys) &&
416
+ value.transport.type === "stdio" &&
417
+ value.transport.command === "npx" &&
418
+ Array.isArray(value.transport.args) &&
419
+ value.transport.args.length === 4 &&
420
+ value.transport.args[0] === "-y" &&
421
+ value.transport.args[1] === "mcp-remote" &&
422
+ typeof value.transport.args[2] === "string" &&
423
+ value.transport.args[3] === "--allow-http" &&
424
+ value.transport.cwd === null &&
425
+ value.transport.env === null &&
426
+ Array.isArray(value.transport.env_vars) &&
427
+ value.transport.env_vars.length === 0)) {
428
+ return null;
429
+ }
430
+ return value.transport.args[2];
431
+ }
432
+
433
+ function exactDefinition(value, slug, url) {
434
+ return managedDefinitionUrl(value, slug) === url;
435
+ }
436
+
437
+ function gatewayIdentity(value, slug) {
438
+ let parsed;
439
+ try {
440
+ parsed = new URL(value);
441
+ } catch {
442
+ return null;
443
+ }
444
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
445
+ return null;
446
+ }
447
+ const segments = parsed.pathname.split("/").filter(Boolean);
448
+ if (segments.length !== 3 || segments[0] !== "t" || segments[2] !== slug) {
449
+ return null;
450
+ }
451
+ const token = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/.exec(segments[1]);
452
+ if (!token) return null;
453
+ return { origin: parsed.origin, taskId: token[1] };
454
+ }
455
+
456
+ function sameTaskGateway(oldUrl, desiredUrl, slug) {
457
+ const oldIdentity = gatewayIdentity(oldUrl, slug);
458
+ const desiredIdentity = gatewayIdentity(desiredUrl, slug);
459
+ return oldIdentity !== null &&
460
+ desiredIdentity !== null &&
461
+ oldIdentity.origin === desiredIdentity.origin &&
462
+ oldIdentity.taskId === desiredIdentity.taskId;
463
+ }
464
+
465
+ function escapeRegExp(value) {
466
+ return value.replace(/[.*+?^$(){}|[\]\\]/g, "\\$&");
467
+ }
468
+
469
+ /**
470
+ * Replace only the URL token inside a raw block Uai can prove it authored.
471
+ * The exact legacy three-line shape predates the marker. New blocks carry the
472
+ * ADR-057 marker. A quoted/spaced header, extra field, or neighboring body
473
+ * text is deliberately foreign even if Codex normalizes it to the same JSON.
474
+ */
475
+ function rewriteManagedRawBlock(text, slug, oldUrl, desiredUrl) {
476
+ const prefix =
477
+ "(^|\\r?\\n)\\[mcp_servers\\." + escapeRegExp(slug) + "\\]\\r?\\n" +
478
+ "command = \\\"npx\\\"\\r?\\n";
479
+ const legacyPattern = new RegExp(
480
+ prefix +
481
+ "args = \\[\\\"-y\\\", \\\"mcp-remote\\\", " +
482
+ escapeRegExp(JSON.stringify(oldUrl)) +
483
+ ", \\\"--allow-http\\\"\\](?=\\r?\\n|$)",
484
+ "g"
485
+ );
486
+ const markedPattern = new RegExp(
487
+ "(^|\\r?\\n)# uai ADR-057: host MCP gateway\\r?\\n" +
488
+ "\\[mcp_servers\\." + escapeRegExp(slug) + "\\]\\r?\\n" +
489
+ "command = \\\"npx\\\"\\r?\\n" +
490
+ "args = " +
491
+ escapeRegExp(JSON.stringify([
492
+ "-y", "mcp-remote", oldUrl, "--allow-http"
493
+ ])) +
494
+ "(?=\\r?\\n|$)",
495
+ "g"
496
+ );
497
+ const matches = [
498
+ ...Array.from(text.matchAll(legacyPattern)),
499
+ ...Array.from(text.matchAll(markedPattern)),
500
+ ].filter((match) => {
501
+ const after = (match.index || 0) + match[0].length;
502
+ const remainder = text.slice(after);
503
+ const nextHeader = remainder.search(
504
+ /^[ \t]*\[\[?.+\]\]?[ \t]*(?:#.*)?\r?$/m
505
+ );
506
+ const gap = nextHeader === -1 ? remainder : remainder.slice(0, nextHeader);
507
+ return gap.split(/\r?\n/).every((line) => {
508
+ const trimmed = line.trim();
509
+ return trimmed === "" || trimmed.startsWith("#");
510
+ });
511
+ });
512
+ if (matches.length !== 1) return null;
513
+ const match = matches[0];
514
+ const start = match.index || 0;
515
+ const replacement = match[0].replace(
516
+ JSON.stringify(oldUrl),
517
+ JSON.stringify(desiredUrl)
518
+ );
519
+ return text.slice(0, start) + replacement + text.slice(start + match[0].length);
520
+ }
521
+
522
+ function getDefinition(slug) {
523
+ const result = runCodex(["mcp", "get", slug, "--json"]);
524
+ if (result.status !== 0) {
525
+ throw new Error("codex mcp get " + slug + " failed: " + detail(result));
526
+ }
527
+ let value;
528
+ try {
529
+ value = JSON.parse(result.stdout);
530
+ } catch {
531
+ throw new Error("codex mcp get " + slug + " returned invalid JSON");
532
+ }
533
+ return value;
534
+ }
535
+
536
+ // One list call validates the whole TOML document and identifies truly absent
537
+ // servers. A malformed document therefore fails before any append.
538
+ const listedResult = runCodex(["mcp", "list", "--json"]);
539
+ if (listedResult.status !== 0) {
540
+ throw new Error("Codex config is invalid: " + detail(listedResult));
541
+ }
542
+ let listed;
543
+ try {
544
+ listed = JSON.parse(listedResult.stdout);
545
+ } catch {
546
+ throw new Error("codex mcp list returned invalid JSON");
547
+ }
548
+ if (!Array.isArray(listed) ||
549
+ !listed.every((entry) => plainObject(entry) && typeof entry.name === "string")) {
550
+ throw new Error("codex mcp list returned an invalid server list");
551
+ }
552
+
553
+ const missing = [];
554
+ const rewrites = [];
555
+ for (const [slug, url] of Object.entries(desired)) {
556
+ const matches = listed.filter((entry) => entry.name === slug);
557
+ if (matches.length === 0) {
558
+ missing.push([slug, url]);
559
+ continue;
560
+ }
561
+ if (matches.length !== 1) {
562
+ throw new Error("Codex MCP server " + slug + " is foreign - left unchanged");
563
+ }
564
+ const definition = getDefinition(slug);
565
+ if (exactDefinition(definition, slug, url)) continue;
566
+ const oldUrl = managedDefinitionUrl(definition, slug);
567
+ if (oldUrl === null || !sameTaskGateway(oldUrl, url, slug)) {
568
+ throw new Error("Codex MCP server " + slug + " is foreign - left unchanged");
569
+ }
570
+ rewrites.push([slug, oldUrl, url]);
571
+ }
572
+ if (missing.length === 0 && rewrites.length === 0) process.exit(0);
573
+
574
+ const newline = original && original.includes("\r\n") ? "\r\n" : "\n";
575
+ const blocks = missing.map(([slug, url]) => [
576
+ "# uai ADR-057: host MCP gateway",
577
+ "[mcp_servers." + slug + "]",
578
+ 'command = "npx"',
579
+ "args = " + JSON.stringify(["-y", "mcp-remote", url, "--allow-http"]),
580
+ "",
581
+ ].join(newline)).join(newline);
582
+ let next = original || "";
583
+ for (const [slug, oldUrl, desiredUrl] of rewrites) {
584
+ const rewritten = rewriteManagedRawBlock(next, slug, oldUrl, desiredUrl);
585
+ if (rewritten === null) {
586
+ throw new Error("Codex MCP server " + slug + " has foreign TOML text - left unchanged");
587
+ }
588
+ next = rewritten;
589
+ }
590
+ if (next.length > 0 && !next.endsWith("\n")) next += newline;
591
+ if (blocks.length > 0) {
592
+ if (next.length > 0 && !next.endsWith(newline + newline)) next += newline;
593
+ next += blocks;
594
+ }
595
+
596
+ const mode = original === null ? 0o644 : fs.statSync(file).mode & 0o777;
597
+ const tmp = path.join(
598
+ home,
599
+ "." + path.basename(file) + "." + process.pid + "." +
600
+ crypto.randomBytes(8).toString("hex") + ".tmp"
601
+ );
602
+ try {
603
+ fs.writeFileSync(tmp, next, {
604
+ encoding: "utf8",
605
+ flag: "wx",
606
+ mode,
607
+ });
608
+ let current = null;
609
+ try {
610
+ current = fs.readFileSync(file, "utf8");
611
+ } catch (error) {
612
+ if (!error || error.code !== "ENOENT") throw error;
613
+ }
614
+ if (current !== original) {
615
+ throw new Error("Codex config changed concurrently");
616
+ }
617
+ fs.renameSync(tmp, file);
618
+ } finally {
619
+ try { fs.unlinkSync(tmp); } catch {}
620
+ }
621
+
622
+ // Do not memoize a syntactically bad append/rewrite as success. Codex is again
623
+ // the authority, and every changed definition must normalize exactly.
624
+ for (const [slug, url] of [
625
+ ...missing,
626
+ ...rewrites.map(([slug, _oldUrl, url]) => [slug, url]),
627
+ ]) {
628
+ if (!exactDefinition(getDefinition(slug), slug, url)) {
629
+ throw new Error("Codex MCP server " + slug + " failed verification");
630
+ }
631
+ }
293
632
  `.trim();
294
633
 
295
634
  function shellQuote(value: string): string {
296
635
  return `'${value.replace(/'/g, `'\\''`)}'`;
297
636
  }
298
637
 
638
+ // The host must never win this timeout race: killing the local `docker exec`
639
+ // client does not kill its in-container child. The full writer (including its
640
+ // 10s lock wait and every engine-specific command) gets 25s before TERM and
641
+ // 5s more before KILL, below the 40s host backstop.
642
+ const MCP_CONFIG_WRITE_TIMEOUT_S = 25;
643
+ const MCP_CONFIG_WRITE_KILL_AFTER_S = 5;
644
+ const MCP_CONFIG_HOST_TIMEOUT_MS = 40_000;
645
+
299
646
  /**
300
647
  * Write the task's MCP configs inside the container, one shape per engine on
301
648
  * the roster — all pointing at the same host gateway URLs:
@@ -315,21 +662,33 @@ export async function setupMcpTaskConfig(
315
662
  containerName: string,
316
663
  connections: TaskMcpConnection[],
317
664
  engineKinds: string[],
318
- ): Promise<void> {
665
+ codexHomes: readonly string[] = ["/home/node/.codex"],
666
+ ): Promise<boolean> {
319
667
  // No early return on empty: the claude adapter passes
320
668
  // `--mcp-config /workspace/.mcp.json` unconditionally (ADR-057), so the
321
669
  // file must exist — an empty mcpServers map — even with no connections.
322
670
  try {
671
+ const has = (kind: string): boolean => engineKinds.includes(kind);
672
+ if (
673
+ has("codex") &&
674
+ connections.some(
675
+ (connection) =>
676
+ !/^[a-z0-9][a-z0-9-]{0,63}$/.test(connection.slug),
677
+ )
678
+ ) {
679
+ throw new Error("invalid Codex MCP connection slug");
680
+ }
323
681
  const acl = ensureTaskGatewayAcl(taskId, connections);
324
682
  const urlFor = (slug: string): string =>
325
683
  `http://host.docker.internal:${MCP_GATEWAY_PORT}/t/${acl.token}/${slug}`;
326
- const has = (kind: string): boolean => engineKinds.includes(kind);
327
684
 
328
685
  const claudeEntries: Record<string, unknown> = {};
686
+ const codexEntries: Record<string, string> = {};
329
687
  const cursorEntries: Record<string, unknown> = {};
330
688
  const opencodeEntries: Record<string, unknown> = {};
331
689
  for (const c of connections) {
332
690
  claudeEntries[c.slug] = { type: "http", url: urlFor(c.slug) };
691
+ codexEntries[c.slug] = urlFor(c.slug);
333
692
  // Cursor's mcp.json wants a bare { url } for remote (http/sse) servers.
334
693
  cursorEntries[c.slug] = { url: urlFor(c.slug) };
335
694
  // OpenCode: type:"remote" + oauth:false — the gateway needs no client
@@ -342,23 +701,30 @@ export async function setupMcpTaskConfig(
342
701
  oauth: false,
343
702
  };
344
703
  }
345
- const steps = [
346
- "mkdir -p /workspace/.claude",
704
+ // Browser migration touches these same Claude/Codex files. Keep every Uai
705
+ // writer in one critical section so neither side can erase the other's
706
+ // update between a read and its replacement/append.
707
+ const lockedWrites = [
347
708
  `[ -f /workspace/.claude/settings.json ] || printf '%s\\n' ${shellQuote(
348
709
  JSON.stringify({ enableAllProjectMcpServers: true }, null, 2),
349
710
  )} > /workspace/.claude/settings.json`,
350
- `node -e ${shellQuote(MERGE_MCP_JSON)} /workspace/.mcp.json ${shellQuote(
351
- JSON.stringify(claudeEntries),
352
- )}`,
353
- // Codex: stdio-only, so each connection is an mcp-remote shim.
354
- ...(has("codex")
355
- ? connections.map(
356
- (c) =>
357
- `grep -q "mcp_servers.${c.slug}]" /home/node/.codex/config.toml 2>/dev/null || printf '%s' ${shellQuote(
358
- `\n[mcp_servers.${c.slug}]\ncommand = "npx"\nargs = ["-y", "mcp-remote", "${urlFor(c.slug)}", "--allow-http"]\n`,
359
- )} >> /home/node/.codex/config.toml`,
711
+ `node -e ${shellQuote(
712
+ MERGE_MCP_JSON,
713
+ )} /workspace/.mcp.json ${shellQuote(JSON.stringify(claudeEntries))}`,
714
+ // Codex is stdio-only. Ask Codex itself to classify the TOML before
715
+ // atomically appending any missing mcp-remote shims.
716
+ ...(has("codex") && connections.length > 0
717
+ ? codexHomes.map(
718
+ (home) =>
719
+ `node -e ${shellQuote(ENSURE_CODEX_MCP_TOML)} ${shellQuote(home)} ${shellQuote(
720
+ JSON.stringify(codexEntries),
721
+ )}`,
360
722
  )
361
723
  : []),
724
+ ].join(" && ");
725
+ const writes = [
726
+ "mkdir -p /workspace/.claude",
727
+ `flock -w 10 ${MCP_CONFIG_LOCK_PATH} sh -lc ${shellQuote(lockedWrites)}`,
362
728
  // Cursor: reads ~/.cursor/mcp.json (the adapter passes --approve-mcps).
363
729
  ...(has("cursor") && connections.length > 0
364
730
  ? [
@@ -386,18 +752,24 @@ export async function setupMcpTaskConfig(
386
752
  ]
387
753
  : []),
388
754
  ].join(" && ");
755
+ const steps =
756
+ `timeout --kill-after=${MCP_CONFIG_WRITE_KILL_AFTER_S} ` +
757
+ `${MCP_CONFIG_WRITE_TIMEOUT_S} sh -lc ${shellQuote(writes)}`;
389
758
  const result = await dockerCli(
390
759
  ["exec", containerName, "sh", "-lc", steps],
391
- { timeoutMs: 20_000 },
760
+ { timeoutMs: MCP_CONFIG_HOST_TIMEOUT_MS },
392
761
  );
393
762
  if (result.status !== 0) {
394
763
  console.warn(
395
764
  `[mcp-gateway] task ${taskId}: config write failed: ${result.stderr.slice(0, 300)}`,
396
765
  );
766
+ return false;
397
767
  }
768
+ return true;
398
769
  } catch (err) {
399
770
  console.warn(
400
771
  `[mcp-gateway] task ${taskId}: setup failed: ${err instanceof Error ? err.message : err}`,
401
772
  );
773
+ return false;
402
774
  }
403
775
  }