@runuai/host 0.9.107 → 0.9.109
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/browser-testing.ts +117 -1
- package/lib/mcp-gateway.ts +21 -8
- package/lib/orchestrator.ts +24 -0
- package/package.json +1 -1
package/lib/browser-testing.ts
CHANGED
|
@@ -236,9 +236,32 @@ const SERVER_COMMAND = "sh";
|
|
|
236
236
|
/** Exported for the test that locks the pin/browser invariants. */
|
|
237
237
|
export const SERVER_ARGS: [string, string] = ["-lc", SERVER_LAUNCHER];
|
|
238
238
|
|
|
239
|
-
/** The `browser` server definition
|
|
239
|
+
/** The `browser` server definition every engine gets. */
|
|
240
240
|
const SERVER_DEF = { command: SERVER_COMMAND, args: SERVER_ARGS };
|
|
241
241
|
|
|
242
|
+
/** Cursor reads stdio servers from ~/.cursor/mcp.json (the adapter passes
|
|
243
|
+
* `--approve-mcps`); same JSON shape as Claude's project file. */
|
|
244
|
+
const CURSOR_MCP_PATH = "/home/node/.cursor/mcp.json";
|
|
245
|
+
|
|
246
|
+
/** Grok: `grok mcp add` (add-or-update) in USER scope, once per GROK_HOME a
|
|
247
|
+
* task may run under (the default ~/.grok and each ~/.grok-acct-<id>) —
|
|
248
|
+
* `GROK_HOME` redirects the user config. PROJECT scope looked simpler but
|
|
249
|
+
* grok refuses repo-local servers in an untrusted folder, and /workspace is
|
|
250
|
+
* not a git repo so nothing ever trusts it (live 2026-09-08: "folder
|
|
251
|
+
* untrusted … re-run with --trust", no such flag headless). The launcher has
|
|
252
|
+
* no single quotes by construction (see SERVER_LAUNCHER). */
|
|
253
|
+
const GROK_BIN = "/home/node/.grok/bin/grok";
|
|
254
|
+
export const DEFAULT_GROK_HOME = "/home/node/.grok";
|
|
255
|
+
export function grokBrowserAddCommand(home: string): string {
|
|
256
|
+
if (!/^\/home\/node\/\.grok(-acct-[A-Za-z0-9_-]+)?$/.test(home)) {
|
|
257
|
+
throw new Error("invalid Grok home");
|
|
258
|
+
}
|
|
259
|
+
return (
|
|
260
|
+
`GROK_HOME=${home} ${GROK_BIN} mcp add browser -s user -t stdio ` +
|
|
261
|
+
`${SERVER_COMMAND} -- ${SERVER_ARGS.map((arg) => `'${arg}'`).join(" ")}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
242
265
|
const CLAUDE_SETTINGS_JSON = JSON.stringify(
|
|
243
266
|
{ enableAllProjectMcpServers: true },
|
|
244
267
|
null,
|
|
@@ -562,6 +585,45 @@ function ensureClaudeBrowser() {
|
|
|
562
585
|
return { configured: true, changed: true };
|
|
563
586
|
}
|
|
564
587
|
|
|
588
|
+
function ensureCursorBrowser() {
|
|
589
|
+
const file = process.env.UAI_CURSOR_MCP_PATH;
|
|
590
|
+
if (!file) return { configured: true, changed: false };
|
|
591
|
+
const exists = fs.existsSync(file);
|
|
592
|
+
let original = null;
|
|
593
|
+
let document;
|
|
594
|
+
try {
|
|
595
|
+
original = exists ? fs.readFileSync(file, "utf8") : null;
|
|
596
|
+
document = exists ? JSON.parse(original) : { mcpServers: {} };
|
|
597
|
+
} catch {
|
|
598
|
+
notes.push("cursor mcp.json is unparseable - left alone");
|
|
599
|
+
return { configured: false, changed: false };
|
|
600
|
+
}
|
|
601
|
+
if (!plainObject(document)) {
|
|
602
|
+
notes.push("cursor mcp.json root is not an object - left alone");
|
|
603
|
+
return { configured: false, changed: false };
|
|
604
|
+
}
|
|
605
|
+
if (document.mcpServers === undefined) document.mcpServers = {};
|
|
606
|
+
if (!plainObject(document.mcpServers)) {
|
|
607
|
+
notes.push("cursor mcp.json mcpServers is not an object - left alone");
|
|
608
|
+
return { configured: false, changed: false };
|
|
609
|
+
}
|
|
610
|
+
const current = document.mcpServers.browser;
|
|
611
|
+
if (current !== undefined && equal(current, def)) {
|
|
612
|
+
return { configured: true, changed: false };
|
|
613
|
+
}
|
|
614
|
+
if (current !== undefined && !isManaged(current)) {
|
|
615
|
+
notes.push("cursor mcp.json browser entry is not uai-managed - left alone");
|
|
616
|
+
return { configured: true, changed: false };
|
|
617
|
+
}
|
|
618
|
+
document.mcpServers.browser = def;
|
|
619
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
620
|
+
if (!atomicWrite(file, JSON.stringify(document, null, 2) + "\n", original)) {
|
|
621
|
+
notes.push("cursor mcp.json changed concurrently - retrying later");
|
|
622
|
+
return { configured: false, changed: false };
|
|
623
|
+
}
|
|
624
|
+
return { configured: true, changed: true };
|
|
625
|
+
}
|
|
626
|
+
|
|
565
627
|
function ensureClaudeSettings() {
|
|
566
628
|
const file = process.env.UAI_CLAUDE_SETTINGS_PATH;
|
|
567
629
|
if (!file || fs.existsSync(file)) return { configured: true, changed: false };
|
|
@@ -913,6 +975,9 @@ function safeOutcome(label, ensure) {
|
|
|
913
975
|
for (const outcome of [
|
|
914
976
|
safeOutcome("Claude browser config", ensureClaudeBrowser),
|
|
915
977
|
safeOutcome("Claude settings", ensureClaudeSettings),
|
|
978
|
+
...(process.env.UAI_CURSOR_MCP_PATH
|
|
979
|
+
? [safeOutcome("Cursor browser config", ensureCursorBrowser)]
|
|
980
|
+
: []),
|
|
916
981
|
...codexHomes.map(function (home) {
|
|
917
982
|
return safeOutcome(home, function () { return ensureCodexBrowser(home); });
|
|
918
983
|
}),
|
|
@@ -1022,7 +1087,18 @@ export async function setupBrowserTesting(
|
|
|
1022
1087
|
codexHomes: readonly string[] = hasCodex ? [DEFAULT_CODEX_HOME] : [],
|
|
1023
1088
|
environment?: Pick<TaskEnvironmentHandle, "exec" | "launchDetachedSession"> &
|
|
1024
1089
|
Partial<Pick<TaskEnvironmentHandle, "descriptor">>,
|
|
1090
|
+
/** The roster's agent kinds: cursor and grok get the browser server too
|
|
1091
|
+
* (2026-09-08 — until then only Claude/Codex were wired and both answered
|
|
1092
|
+
* "no browser MCP in this session" on the Studio). */
|
|
1093
|
+
engineKinds: readonly string[] = [],
|
|
1094
|
+
/** Every GROK_HOME the task may run under (default + account homes). */
|
|
1095
|
+
grokHomes: readonly string[] = [DEFAULT_GROK_HOME],
|
|
1025
1096
|
): Promise<BrowserSetup> {
|
|
1097
|
+
const hasCursor = engineKinds.includes("cursor");
|
|
1098
|
+
const hasGrok = engineKinds.includes("grok");
|
|
1099
|
+
const selectedGrokHomes = hasGrok
|
|
1100
|
+
? [...new Set(grokHomes.length > 0 ? grokHomes : [DEFAULT_GROK_HOME])]
|
|
1101
|
+
: [];
|
|
1026
1102
|
// Machine sessions run under MACHINE_RUNTIME_AUTHORITY_ENV, so every setup
|
|
1027
1103
|
// exec here must too — the prewarm in particular. Warming npx under the
|
|
1028
1104
|
// CONTAINER authority (private NPM_CONFIG_CACHE) leaves the session-default
|
|
@@ -1201,6 +1277,7 @@ export async function setupBrowserTesting(
|
|
|
1201
1277
|
...(selectedCodexHomes.length > 0
|
|
1202
1278
|
? { UAI_CODEX_HOMES: JSON.stringify(selectedCodexHomes) }
|
|
1203
1279
|
: {}),
|
|
1280
|
+
...(hasCursor ? { UAI_CURSOR_MCP_PATH: CURSOR_MCP_PATH } : {}),
|
|
1204
1281
|
};
|
|
1205
1282
|
const writeArgv = [
|
|
1206
1283
|
"flock",
|
|
@@ -1238,6 +1315,7 @@ export async function setupBrowserTesting(
|
|
|
1238
1315
|
...(selectedCodexHomes.length > 0
|
|
1239
1316
|
? ["-e", `UAI_CODEX_HOMES=${JSON.stringify(selectedCodexHomes)}`]
|
|
1240
1317
|
: []),
|
|
1318
|
+
...(hasCursor ? ["-e", `UAI_CURSOR_MCP_PATH=${CURSOR_MCP_PATH}`] : []),
|
|
1241
1319
|
...runtimeAuthorityDockerExecArgs(),
|
|
1242
1320
|
container,
|
|
1243
1321
|
...writeArgv,
|
|
@@ -1292,6 +1370,44 @@ export async function setupBrowserTesting(
|
|
|
1292
1370
|
}
|
|
1293
1371
|
}
|
|
1294
1372
|
|
|
1373
|
+
// Grok registers its servers through its own CLI (project scope, see
|
|
1374
|
+
// GROK_BROWSER_ADD_CMD) rather than a file we own; a failure here leaves
|
|
1375
|
+
// the task configured-false so the next ensure retries.
|
|
1376
|
+
if (hasGrok) {
|
|
1377
|
+
const grokArgv = [
|
|
1378
|
+
"sh",
|
|
1379
|
+
"-lc",
|
|
1380
|
+
selectedGrokHomes.map(grokBrowserAddCommand).join(" && "),
|
|
1381
|
+
] as [string, ...string[]];
|
|
1382
|
+
const registered = environment
|
|
1383
|
+
? await environment.exec({
|
|
1384
|
+
argv: grokArgv,
|
|
1385
|
+
cwd: "/workspace",
|
|
1386
|
+
env: authorityEnv,
|
|
1387
|
+
timeoutMs: 30_000,
|
|
1388
|
+
maxOutputBytes: 64 * 1024,
|
|
1389
|
+
})
|
|
1390
|
+
: await taskContainerCli(
|
|
1391
|
+
[
|
|
1392
|
+
"exec",
|
|
1393
|
+
"-w",
|
|
1394
|
+
"/workspace",
|
|
1395
|
+
...runtimeAuthorityDockerExecArgs(),
|
|
1396
|
+
container,
|
|
1397
|
+
...grokArgv,
|
|
1398
|
+
],
|
|
1399
|
+
{ timeoutMs: 30_000 },
|
|
1400
|
+
);
|
|
1401
|
+
const grokStatus =
|
|
1402
|
+
"exitCode" in registered ? registered.exitCode : registered.status;
|
|
1403
|
+
if (grokStatus !== 0) {
|
|
1404
|
+
configured = false;
|
|
1405
|
+
console.warn(
|
|
1406
|
+
`[browser] task ${taskId}: grok browser MCP registration failed (exit ${String(grokStatus)})`,
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1295
1411
|
// Chromium apt libs + the viewer stack packages, then start the stack.
|
|
1296
1412
|
// The lock is a container-local flock acquired BEFORE detach. The worker
|
|
1297
1413
|
// inherits its open file description, so failures and even SIGKILL release
|
package/lib/mcp-gateway.ts
CHANGED
|
@@ -1042,6 +1042,8 @@ function shellQuote(value: string): string {
|
|
|
1042
1042
|
// client does not kill its in-container child. The full writer (including its
|
|
1043
1043
|
// 10s lock wait and every engine-specific command) gets 25s before TERM and
|
|
1044
1044
|
// 5s more before KILL, below the 40s host backstop.
|
|
1045
|
+
/** The grok CLI inside task containers/machines (x.ai installer path). */
|
|
1046
|
+
const GROK_BIN = "/home/node/.grok/bin/grok";
|
|
1045
1047
|
const MCP_CONFIG_WRITE_TIMEOUT_S = 25;
|
|
1046
1048
|
const MCP_CONFIG_WRITE_KILL_AFTER_S = 5;
|
|
1047
1049
|
const MCP_CONFIG_HOST_TIMEOUT_MS = 40_000;
|
|
@@ -1054,7 +1056,8 @@ const MCP_CONFIG_HOST_TIMEOUT_MS = 40_000;
|
|
|
1054
1056
|
* - Codex — mcp-remote shims appended once per slug to config.toml.
|
|
1055
1057
|
* - Cursor — ~/.cursor/mcp.json ({ url } form; the adapter passes
|
|
1056
1058
|
* `--approve-mcps`).
|
|
1057
|
-
* - Grok — `grok mcp add`
|
|
1059
|
+
* - Grok — `grok mcp add -s user` into every GROK_HOME the task may use
|
|
1060
|
+
* (idempotent per slug; project scope is refused in an untrusted folder).
|
|
1058
1061
|
* - OpenCode — ~/.config/opencode/opencode.json `mcp` block (type:"remote",
|
|
1059
1062
|
* oauth:false so it treats the gateway URL as a plain server; the path
|
|
1060
1063
|
* token is the auth). Written only when the task has connections.
|
|
@@ -1068,6 +1071,8 @@ export async function setupMcpTaskConfig(
|
|
|
1068
1071
|
codexHomes: readonly string[] = ["/home/node/.codex"],
|
|
1069
1072
|
environment?: Pick<TaskEnvironmentHandle, "exec"> &
|
|
1070
1073
|
Partial<Pick<TaskEnvironmentHandle, "descriptor">>,
|
|
1074
|
+
/** Every GROK_HOME the task may run under (default + account homes). */
|
|
1075
|
+
grokHomes: readonly string[] = ["/home/node/.grok"],
|
|
1071
1076
|
): Promise<boolean> {
|
|
1072
1077
|
// No early return on empty: the claude adapter passes
|
|
1073
1078
|
// `--mcp-config /workspace/.mcp.json` unconditionally (ADR-057), so the
|
|
@@ -1150,14 +1155,22 @@ export async function setupMcpTaskConfig(
|
|
|
1150
1155
|
)}`,
|
|
1151
1156
|
]
|
|
1152
1157
|
: []),
|
|
1153
|
-
// Grok: `grok mcp add`
|
|
1158
|
+
// Grok: USER-scope `grok mcp add` once per GROK_HOME the task may run
|
|
1159
|
+
// under (GROK_HOME redirects the user config). Project scope is refused
|
|
1160
|
+
// for an untrusted folder and /workspace is no git repo (live
|
|
1161
|
+
// 2026-09-08). The binary lives at ~/.grok/bin (x.ai installer); the
|
|
1162
|
+
// old ~/.local/bin path failed silently behind `|| true`.
|
|
1154
1163
|
...(has("grok")
|
|
1155
|
-
?
|
|
1156
|
-
(
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1164
|
+
? grokHomes
|
|
1165
|
+
.filter((home) => /^\/home\/node\/\.grok(-acct-[A-Za-z0-9_-]+)?$/.test(home))
|
|
1166
|
+
.flatMap((home) =>
|
|
1167
|
+
connections.map(
|
|
1168
|
+
(c) =>
|
|
1169
|
+
`GROK_HOME=${home} ${GROK_BIN} mcp add ${shellQuote(c.slug)} ${shellQuote(
|
|
1170
|
+
urlFor(c.slug),
|
|
1171
|
+
)} -t http -s user >/dev/null 2>&1 || true`,
|
|
1172
|
+
),
|
|
1173
|
+
)
|
|
1161
1174
|
: []),
|
|
1162
1175
|
// OpenCode: global config `mcp` block (auto-discovered regardless of the
|
|
1163
1176
|
// exec cwd, and independent of any per-account OPENCODE_DATA_DIR).
|
package/lib/orchestrator.ts
CHANGED
|
@@ -101,6 +101,7 @@ import {
|
|
|
101
101
|
} from "./agent-cli";
|
|
102
102
|
import {
|
|
103
103
|
DEFAULT_CODEX_HOME,
|
|
104
|
+
DEFAULT_GROK_HOME,
|
|
104
105
|
setupBrowserTesting,
|
|
105
106
|
type BrowserSetup,
|
|
106
107
|
} from "./browser-testing";
|
|
@@ -814,6 +815,16 @@ export class Orchestrator {
|
|
|
814
815
|
return [...new Set([DEFAULT_CODEX_HOME, ...homes])];
|
|
815
816
|
}
|
|
816
817
|
|
|
818
|
+
/** Every GROK_HOME a task in `scope` may run under: the default plus each
|
|
819
|
+
* sign-in account's isolated home (API-key accounts use the default). */
|
|
820
|
+
private grokHomes(hasGrok: boolean, scope: string): string[] {
|
|
821
|
+
if (!hasGrok) return [];
|
|
822
|
+
const homes = resolveEngineAccounts("grok", {}, scope)
|
|
823
|
+
.map((account) => account.configDir?.containerDir)
|
|
824
|
+
.filter((home): home is string => Boolean(home));
|
|
825
|
+
return [...new Set([DEFAULT_GROK_HOME, ...homes])];
|
|
826
|
+
}
|
|
827
|
+
|
|
817
828
|
private noteProvisionMutation(
|
|
818
829
|
channel: Channel,
|
|
819
830
|
waitForBrowserReadiness: boolean,
|
|
@@ -1025,12 +1036,17 @@ export class Orchestrator {
|
|
|
1025
1036
|
requestedHasCodex,
|
|
1026
1037
|
requestedHomes,
|
|
1027
1038
|
channel.agentHandle,
|
|
1039
|
+
requestedKinds,
|
|
1040
|
+
this.grokHomes(requestedKinds.includes("grok"), channel.engineAccountScope),
|
|
1028
1041
|
)
|
|
1029
1042
|
: await setupBrowserTesting(
|
|
1030
1043
|
channel.taskId,
|
|
1031
1044
|
channel.containerName,
|
|
1032
1045
|
requestedHasCodex,
|
|
1033
1046
|
requestedHomes,
|
|
1047
|
+
undefined,
|
|
1048
|
+
requestedKinds,
|
|
1049
|
+
this.grokHomes(requestedKinds.includes("grok"), channel.engineAccountScope),
|
|
1034
1050
|
);
|
|
1035
1051
|
} catch (err) {
|
|
1036
1052
|
console.warn(
|
|
@@ -1187,10 +1203,15 @@ export class Orchestrator {
|
|
|
1187
1203
|
const codexHomes = engineKinds.includes("codex")
|
|
1188
1204
|
? this.codexHomes(true, channel.engineAccountScope)
|
|
1189
1205
|
: [];
|
|
1206
|
+
const grokHomes = this.grokHomes(
|
|
1207
|
+
engineKinds.includes("grok"),
|
|
1208
|
+
channel.engineAccountScope,
|
|
1209
|
+
);
|
|
1190
1210
|
const fingerprint = JSON.stringify({
|
|
1191
1211
|
connections,
|
|
1192
1212
|
engineKinds,
|
|
1193
1213
|
codexHomes,
|
|
1214
|
+
...(grokHomes.length > 0 ? { grokHomes } : {}),
|
|
1194
1215
|
});
|
|
1195
1216
|
const dirtyEpoch = channel.mcpConfigDirtyEpoch;
|
|
1196
1217
|
const repairingProvisionedConfig =
|
|
@@ -1237,6 +1258,7 @@ export class Orchestrator {
|
|
|
1237
1258
|
engineKinds,
|
|
1238
1259
|
codexHomes,
|
|
1239
1260
|
providerEnvironment,
|
|
1261
|
+
grokHomes,
|
|
1240
1262
|
)
|
|
1241
1263
|
: await setupMcpTaskConfig(
|
|
1242
1264
|
channel.taskId,
|
|
@@ -1244,6 +1266,8 @@ export class Orchestrator {
|
|
|
1244
1266
|
connections,
|
|
1245
1267
|
engineKinds,
|
|
1246
1268
|
codexHomes,
|
|
1269
|
+
undefined,
|
|
1270
|
+
grokHomes,
|
|
1247
1271
|
);
|
|
1248
1272
|
if (!configured || !this.isActiveChannel(channel)) return;
|
|
1249
1273
|
channel.configGeneration += 1;
|