@runuai/host 0.8.41 → 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/agents/opencode.ts +13 -2
- package/lib/browser-testing.ts +986 -89
- package/lib/codex-auth.ts +22 -15
- package/lib/engine-accounts.ts +23 -4
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-gateway.ts +393 -21
- package/lib/orchestrator.ts +1351 -217
- package/lib/preview-sidecar.ts +123 -3
- package/package.json +1 -1
- package/scripts/agent/task-down.sh +16 -0
- package/src/index.ts +215 -97
- package/src/main.ts +19 -10
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.
|
|
10
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
167
|
-
* is both DB-active AND confirmed running by docker.
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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]
|
|
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
|
|
package/lib/engine-accounts.ts
CHANGED
|
@@ -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 —
|
|
384
|
-
*
|
|
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<
|
|
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))
|
|
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
|
// ---------------------------------------------------------------------------
|
package/lib/mcp-gateway.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
j
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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(
|
|
351
|
-
|
|
352
|
-
)}`,
|
|
353
|
-
// Codex
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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:
|
|
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
|
}
|