@runuai/host 0.9.107 → 0.9.108

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,22 @@ 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 PROJECT scope writes
247
+ * /workspace/.grok/config.toml, honoured by every GROK_HOME and hot-reloaded,
248
+ * so per-account homes need no per-home write. The launcher has no single
249
+ * quotes by construction (see SERVER_LAUNCHER), so it survives the quoting. */
250
+ const GROK_BIN = "/home/node/.grok/bin/grok";
251
+ export const GROK_BROWSER_ADD_CMD =
252
+ `cd /workspace && ${GROK_BIN} mcp add browser -s project -t stdio ` +
253
+ `${SERVER_COMMAND} -- ${SERVER_ARGS.map((arg) => `'${arg}'`).join(" ")}`;
254
+
242
255
  const CLAUDE_SETTINGS_JSON = JSON.stringify(
243
256
  { enableAllProjectMcpServers: true },
244
257
  null,
@@ -562,6 +575,45 @@ function ensureClaudeBrowser() {
562
575
  return { configured: true, changed: true };
563
576
  }
564
577
 
578
+ function ensureCursorBrowser() {
579
+ const file = process.env.UAI_CURSOR_MCP_PATH;
580
+ if (!file) return { configured: true, changed: false };
581
+ const exists = fs.existsSync(file);
582
+ let original = null;
583
+ let document;
584
+ try {
585
+ original = exists ? fs.readFileSync(file, "utf8") : null;
586
+ document = exists ? JSON.parse(original) : { mcpServers: {} };
587
+ } catch {
588
+ notes.push("cursor mcp.json is unparseable - left alone");
589
+ return { configured: false, changed: false };
590
+ }
591
+ if (!plainObject(document)) {
592
+ notes.push("cursor mcp.json root is not an object - left alone");
593
+ return { configured: false, changed: false };
594
+ }
595
+ if (document.mcpServers === undefined) document.mcpServers = {};
596
+ if (!plainObject(document.mcpServers)) {
597
+ notes.push("cursor mcp.json mcpServers is not an object - left alone");
598
+ return { configured: false, changed: false };
599
+ }
600
+ const current = document.mcpServers.browser;
601
+ if (current !== undefined && equal(current, def)) {
602
+ return { configured: true, changed: false };
603
+ }
604
+ if (current !== undefined && !isManaged(current)) {
605
+ notes.push("cursor mcp.json browser entry is not uai-managed - left alone");
606
+ return { configured: true, changed: false };
607
+ }
608
+ document.mcpServers.browser = def;
609
+ fs.mkdirSync(path.dirname(file), { recursive: true });
610
+ if (!atomicWrite(file, JSON.stringify(document, null, 2) + "\n", original)) {
611
+ notes.push("cursor mcp.json changed concurrently - retrying later");
612
+ return { configured: false, changed: false };
613
+ }
614
+ return { configured: true, changed: true };
615
+ }
616
+
565
617
  function ensureClaudeSettings() {
566
618
  const file = process.env.UAI_CLAUDE_SETTINGS_PATH;
567
619
  if (!file || fs.existsSync(file)) return { configured: true, changed: false };
@@ -913,6 +965,9 @@ function safeOutcome(label, ensure) {
913
965
  for (const outcome of [
914
966
  safeOutcome("Claude browser config", ensureClaudeBrowser),
915
967
  safeOutcome("Claude settings", ensureClaudeSettings),
968
+ ...(process.env.UAI_CURSOR_MCP_PATH
969
+ ? [safeOutcome("Cursor browser config", ensureCursorBrowser)]
970
+ : []),
916
971
  ...codexHomes.map(function (home) {
917
972
  return safeOutcome(home, function () { return ensureCodexBrowser(home); });
918
973
  }),
@@ -1022,7 +1077,13 @@ export async function setupBrowserTesting(
1022
1077
  codexHomes: readonly string[] = hasCodex ? [DEFAULT_CODEX_HOME] : [],
1023
1078
  environment?: Pick<TaskEnvironmentHandle, "exec" | "launchDetachedSession"> &
1024
1079
  Partial<Pick<TaskEnvironmentHandle, "descriptor">>,
1080
+ /** The roster's agent kinds: cursor and grok get the browser server too
1081
+ * (2026-09-08 — until then only Claude/Codex were wired and both answered
1082
+ * "no browser MCP in this session" on the Studio). */
1083
+ engineKinds: readonly string[] = [],
1025
1084
  ): Promise<BrowserSetup> {
1085
+ const hasCursor = engineKinds.includes("cursor");
1086
+ const hasGrok = engineKinds.includes("grok");
1026
1087
  // Machine sessions run under MACHINE_RUNTIME_AUTHORITY_ENV, so every setup
1027
1088
  // exec here must too — the prewarm in particular. Warming npx under the
1028
1089
  // CONTAINER authority (private NPM_CONFIG_CACHE) leaves the session-default
@@ -1201,6 +1262,7 @@ export async function setupBrowserTesting(
1201
1262
  ...(selectedCodexHomes.length > 0
1202
1263
  ? { UAI_CODEX_HOMES: JSON.stringify(selectedCodexHomes) }
1203
1264
  : {}),
1265
+ ...(hasCursor ? { UAI_CURSOR_MCP_PATH: CURSOR_MCP_PATH } : {}),
1204
1266
  };
1205
1267
  const writeArgv = [
1206
1268
  "flock",
@@ -1238,6 +1300,7 @@ export async function setupBrowserTesting(
1238
1300
  ...(selectedCodexHomes.length > 0
1239
1301
  ? ["-e", `UAI_CODEX_HOMES=${JSON.stringify(selectedCodexHomes)}`]
1240
1302
  : []),
1303
+ ...(hasCursor ? ["-e", `UAI_CURSOR_MCP_PATH=${CURSOR_MCP_PATH}`] : []),
1241
1304
  ...runtimeAuthorityDockerExecArgs(),
1242
1305
  container,
1243
1306
  ...writeArgv,
@@ -1292,6 +1355,40 @@ export async function setupBrowserTesting(
1292
1355
  }
1293
1356
  }
1294
1357
 
1358
+ // Grok registers its servers through its own CLI (project scope, see
1359
+ // GROK_BROWSER_ADD_CMD) rather than a file we own; a failure here leaves
1360
+ // the task configured-false so the next ensure retries.
1361
+ if (hasGrok) {
1362
+ const grokArgv = ["sh", "-lc", GROK_BROWSER_ADD_CMD] as [string, ...string[]];
1363
+ const registered = environment
1364
+ ? await environment.exec({
1365
+ argv: grokArgv,
1366
+ cwd: "/workspace",
1367
+ env: authorityEnv,
1368
+ timeoutMs: 30_000,
1369
+ maxOutputBytes: 64 * 1024,
1370
+ })
1371
+ : await taskContainerCli(
1372
+ [
1373
+ "exec",
1374
+ "-w",
1375
+ "/workspace",
1376
+ ...runtimeAuthorityDockerExecArgs(),
1377
+ container,
1378
+ ...grokArgv,
1379
+ ],
1380
+ { timeoutMs: 30_000 },
1381
+ );
1382
+ const grokStatus =
1383
+ "exitCode" in registered ? registered.exitCode : registered.status;
1384
+ if (grokStatus !== 0) {
1385
+ configured = false;
1386
+ console.warn(
1387
+ `[browser] task ${taskId}: grok browser MCP registration failed (exit ${String(grokStatus)})`,
1388
+ );
1389
+ }
1390
+ }
1391
+
1295
1392
  // Chromium apt libs + the viewer stack packages, then start the stack.
1296
1393
  // The lock is a container-local flock acquired BEFORE detach. The worker
1297
1394
  // 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 project` writes /workspace/.grok/config.toml
1060
+ * (idempotent per slug; applies to every GROK_HOME).
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.
@@ -1150,13 +1153,17 @@ export async function setupMcpTaskConfig(
1150
1153
  )}`,
1151
1154
  ]
1152
1155
  : []),
1153
- // Grok: `grok mcp add` writes ~/.grok/config.toml. Idempotent + tolerant.
1156
+ // Grok: PROJECT-scope `grok mcp add` writes /workspace/.grok/config.toml,
1157
+ // which every GROK_HOME (the default or a per-account dir) picks up —
1158
+ // grok hot-reloads repo config. The binary lives at ~/.grok/bin (x.ai
1159
+ // installer); the old ~/.local/bin path failed silently behind `|| true`
1160
+ // and grok never saw a single MCP server (live 2026-09-08).
1154
1161
  ...(has("grok")
1155
1162
  ? connections.map(
1156
1163
  (c) =>
1157
- `/home/node/.local/bin/grok mcp add ${shellQuote(c.slug)} ${shellQuote(
1164
+ `(cd /workspace && ${GROK_BIN} mcp add ${shellQuote(c.slug)} ${shellQuote(
1158
1165
  urlFor(c.slug),
1159
- )} -t http -s user >/dev/null 2>&1 || true`,
1166
+ )} -t http -s project >/dev/null 2>&1) || true`,
1160
1167
  )
1161
1168
  : []),
1162
1169
  // OpenCode: global config `mcp` block (auto-discovered regardless of the
@@ -1025,12 +1025,15 @@ export class Orchestrator {
1025
1025
  requestedHasCodex,
1026
1026
  requestedHomes,
1027
1027
  channel.agentHandle,
1028
+ requestedKinds,
1028
1029
  )
1029
1030
  : await setupBrowserTesting(
1030
1031
  channel.taskId,
1031
1032
  channel.containerName,
1032
1033
  requestedHasCodex,
1033
1034
  requestedHomes,
1035
+ undefined,
1036
+ requestedKinds,
1034
1037
  );
1035
1038
  } catch (err) {
1036
1039
  console.warn(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.107",
3
+ "version": "0.9.108",
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>",