@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.
@@ -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 both engines get. */
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
@@ -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` writes ~/.grok/config.toml (idempotent per slug).
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` writes ~/.grok/config.toml. Idempotent + tolerant.
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
- ? connections.map(
1156
- (c) =>
1157
- `/home/node/.local/bin/grok mcp add ${shellQuote(c.slug)} ${shellQuote(
1158
- urlFor(c.slug),
1159
- )} -t http -s user >/dev/null 2>&1 || true`,
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).
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.107",
3
+ "version": "0.9.109",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",