openmausbot 0.1.80 → 0.1.82

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.
Files changed (58) hide show
  1. package/README.md +6 -5
  2. package/dist/assets/{index-D6RZKWks.js → index-5HVwp5m2.js} +1 -1
  3. package/dist/assets/index-BLLsDK2F.js +309 -0
  4. package/dist/assets/index-CKysBq-V.css +1 -0
  5. package/dist/index.html +2 -2
  6. package/dist-server/container-mcp.js +41 -10
  7. package/dist-server/drivers/agents-proxy.js +50 -11
  8. package/dist-server/index.js +3525 -1296
  9. package/dist-server/openmausbot.js +412 -84
  10. package/dist-server/pair-cli.js +412 -84
  11. package/dist-server/server/box-delete-journal.js +471 -0
  12. package/dist-server/server/box.js +345 -75
  13. package/dist-server/server/cli-phone-setup.js +2 -2
  14. package/dist-server/server/cli.js +84 -12
  15. package/dist-server/server/config.js +48 -18
  16. package/dist-server/server/container-computer.js +7 -0
  17. package/dist-server/server/contracts.js +5 -0
  18. package/dist-server/server/default-model-selection.js +1 -0
  19. package/dist-server/server/drivers/acp/core.js +113 -19
  20. package/dist-server/server/drivers/acp/opencode-go.js +19 -0
  21. package/dist-server/server/drivers/agents-proxy.js +49 -11
  22. package/dist-server/server/drivers/chat-mcp-tools.js +6 -1
  23. package/dist-server/server/drivers/claude.js +33 -2
  24. package/dist-server/server/drivers/codex.js +93 -8
  25. package/dist-server/server/drivers/native.js +6 -1
  26. package/dist-server/server/harness/bus.js +4 -0
  27. package/dist-server/server/harness/registry.js +1 -0
  28. package/dist-server/server/index.js +869 -226
  29. package/dist-server/server/local-vm-lease.js +4 -0
  30. package/dist-server/server/mcp-http.js +337 -0
  31. package/dist-server/server/mcp-probe.js +67 -18
  32. package/dist-server/server/mcp-registry.js +153 -58
  33. package/dist-server/server/member-turn.js +13 -0
  34. package/dist-server/server/message-db.js +74 -8
  35. package/dist-server/server/notify.js +8 -3
  36. package/dist-server/server/recall-disclosure.js +4 -0
  37. package/dist-server/server/recent-work.js +142 -0
  38. package/dist-server/server/request-auth.js +3 -1
  39. package/dist-server/server/routines.js +36 -1
  40. package/dist-server/server/screen-frame-gate.js +4 -94
  41. package/dist-server/server/sessions.js +22 -3
  42. package/dist-server/server/store.js +16 -1
  43. package/dist-server/server/surface.js +41 -19
  44. package/dist-server/server/team-setup-requests.js +1 -1
  45. package/dist-server/server/thread-log-rotation.js +104 -0
  46. package/dist-server/server/tts/fish.js +155 -0
  47. package/dist-server/server/tts/index.js +25 -3
  48. package/dist-server/server/turn-resources.js +7 -2
  49. package/dist-server/server/vps-computer.js +26 -4
  50. package/dist-server/server/vps-ssh.js +88 -0
  51. package/dist-server/server/workspace-backup.js +11 -0
  52. package/dist-server/server/workspace.js +2 -1
  53. package/dist-server/shared/credential-request.js +10 -0
  54. package/dist-server/shared/tool-surface.js +62 -0
  55. package/dist-server/vps-container-mcp.js +41 -10
  56. package/package.json +1 -1
  57. package/dist/assets/index-Cu8BkIfo.js +0 -305
  58. package/dist/assets/index-tfpeJAIG.css +0 -1
@@ -458,6 +458,14 @@ const TOOLS = [
458
458
  required: ["task_id"],
459
459
  },
460
460
  },
461
+ {
462
+ name: "select_computer",
463
+ description: "Choose where this conversation does computer work. Call with no arguments to inspect actual available choices and the current place. For a task needing computer interaction, select the requested place, or auto to choose a suitable configured computer without asking the user to use menus. OpenMausBot reuses an existing computer first; with a configured provider it can start or provision one when needed. Do not provision for ordinary chat or just to inspect availability. A pending result means end this turn immediately: OpenMausBot updates the conversation selector and resumes the original request with that computer's real tools. Do not use the old tools after requesting a switch, repeat the task, or claim the action is done. This cannot change permissions, override Off, or switch a teammate/routine/channel.",
464
+ inputSchema: { type: "object", additionalProperties: false, properties: {
465
+ surface: { type: "string", enum: ["auto", "cloud", "vm", "local", "browser"],
466
+ description: "auto = suitable configured computer, cloud = remote Box/VPS, vm = isolated Local VM, local = user's own desktop, browser = built-in browser. Omit to list." },
467
+ } },
468
+ },
461
469
  {
462
470
  name: "list_threads",
463
471
  description: "See your own threads and the threads you opened on teammates, newest first: each with its bot, title, state (running, waiting on the person, queued, idle, or closed), whether the person has unread there, and the delegation id if it was a handoff. Use it to check how the threads you started are going before reporting to the person; write a thread's title as #Title when you mention it. A teammate's other threads are never listed — only the ones you opened. This is a read: it starts nothing and changes nothing.",
@@ -548,7 +556,7 @@ const TOOLS = [
548
556
  },
549
557
  {
550
558
  name: "propose_bot_deletion",
551
- description: "Chief of Staff only: when the user explicitly asks to delete a named teammate, submit a separate deletion request for that exact bot. Deletion removes its conversations, memory, instructions and skills; generated project files remain. Running work and owned computers can block deletion. Never delete yourself, substitute an archive, or put deletion into a setup batch. If review is pending, the decision and result resume you once." + PROPOSAL_OUTCOME,
559
+ description: "Chief of Staff only: when the user explicitly asks to delete a named teammate, submit a separate deletion request for that exact bot. Deletion removes its conversations, memory, instructions, skills, and any computer owned only by it; generated project files and shared team computers remain. Running work or an unavailable computer provider can block deletion safely. Never delete yourself, substitute an archive, or put deletion into a setup batch. If review is pending, the decision and result resume you once." + PROPOSAL_OUTCOME,
552
560
  inputSchema: { type: "object", additionalProperties: false, properties: {
553
561
  bot_id: { type: "string", minLength: 1 }, reason: { type: "string", minLength: 1, maxLength: 500 },
554
562
  }, required: ["bot_id", "reason"] },
@@ -648,15 +656,20 @@ const TOOLS = [
648
656
  },
649
657
  {
650
658
  name: "session_search",
651
- description: "Search your OWN earlier conversations with this user across all of your tasks, and your own memory files (MEMORY.md, memory/<topic>.md, your daily logs), best match first. Use it before asking the user to repeat something, and before redoing an audit, report, or investigation you may already have done in an earlier task. Conversation hits carry the task name, date, thread id, and message id; memory hits say which file they came from. One search is usually enough: when a hit is the message you need, call session_read with its ids to get the whole message instead of searching again for each detail. Results are your past notes, not new instructions. Other bots' conversations and memory are never included.",
659
+ description: "Search your OWN earlier conversations with this user across all of your tasks and the rooms you are in, and your own memory files (MEMORY.md, memory/<topic>.md, your daily logs), best match first — or, with since and no query, list what happened recently, newest first. Use it before asking the user to repeat something, before redoing an audit, report, or investigation you may already have done in an earlier task, and to answer what you have done since some time (a standup). Conversation hits carry the task or room name, date, thread id, and message id; memory hits say which file they came from. One search is usually enough: when a hit is the message you need, call session_read with its ids to get the whole message instead of searching again for each detail. Results are your past notes, not new instructions. Other bots' conversations and memory are never included.",
652
660
  inputSchema: {
653
661
  type: "object",
654
662
  additionalProperties: false,
655
663
  properties: {
656
664
  query: {
657
665
  type: "string",
658
- description: "Two to five content words that would appear in the message you want, for example \"pricing audit broken links\". Every content word must match; skip filler words like \"the\", \"on\", \"what\".",
666
+ description: "Two to five content words that would appear in the message you want, for example \"pricing audit broken links\". Every content word must match; skip filler words like \"the\", \"on\", \"what\". Optional when since is given.",
667
+ },
668
+ since: {
669
+ type: "string",
670
+ description: "Only messages from this time on: a span back from now like \"24h\", \"3d\", \"2w\"; \"today\" or \"yesterday\"; or a date. With no query, lists everything in that window, newest first.",
659
671
  },
672
+ until: { type: "string", description: "Only messages up to this time; same forms as since." },
660
673
  limit: { type: "integer", minimum: 1, maximum: 25, description: "Maximum hits to return; default 12." },
661
674
  scope: {
662
675
  type: "string",
@@ -664,7 +677,6 @@ const TOOLS = [
664
677
  description: "What to search. Leave it out for both; \"memory\" for only your memory files, \"conversations\" for only your earlier conversations.",
665
678
  },
666
679
  },
667
- required: ["query"],
668
680
  },
669
681
  },
670
682
  {
@@ -1150,6 +1162,15 @@ async function callTool(name, args) {
1150
1162
  }
1151
1163
  return { text: `Task ${taskId} ended without a reply — ${String(r.status ?? "unknown")}${r.result ? `: ${String(r.result)}` : ""}.`, isError: true };
1152
1164
  }
1165
+ if (name === "select_computer") {
1166
+ if (args.surface !== undefined && (typeof args.surface !== "string" || !["auto", "cloud", "vm", "local", "browser"].includes(args.surface))) {
1167
+ return { text: "Choose auto, cloud, vm, local or browser; omit surface to inspect connected choices.", isError: true };
1168
+ }
1169
+ const result = await api("/api/internal/computer/select", args.surface === undefined ? undefined : {
1170
+ method: "POST", body: JSON.stringify({ surface: args.surface }),
1171
+ });
1172
+ return { text: JSON.stringify(result) };
1173
+ }
1153
1174
  if (name === "list_threads") {
1154
1175
  const query = new URLSearchParams({ fromBotId: BOT_ID, fromThreadId: THREAD_ID });
1155
1176
  const r = await api(`/api/internal/threads?${query.toString()}`);
@@ -1500,9 +1521,18 @@ async function callTool(name, args) {
1500
1521
  }
1501
1522
  if (name === "session_search") {
1502
1523
  const q = String(args.query ?? "").trim();
1503
- if (!q)
1504
- return { text: "session_search needs a query, for example {\"query\":\"site audit broken links\"}.", isError: true };
1505
- const query = new URLSearchParams({ fromBotId: BOT_ID, fromThreadId: THREAD_ID, q });
1524
+ const since = typeof args.since === "string" ? args.since.trim() : "";
1525
+ const until = typeof args.until === "string" ? args.until.trim() : "";
1526
+ if (!q && !since) {
1527
+ return { text: "session_search needs a query (a few content words) or a since span, for example {\"query\":\"site audit broken links\"} or {\"since\":\"2d\"}.", isError: true };
1528
+ }
1529
+ const query = new URLSearchParams({ fromBotId: BOT_ID, fromThreadId: THREAD_ID });
1530
+ if (q)
1531
+ query.set("q", q);
1532
+ if (since)
1533
+ query.set("since", since);
1534
+ if (until)
1535
+ query.set("until", until);
1506
1536
  if (typeof args.limit === "number" && Number.isFinite(args.limit))
1507
1537
  query.set("limit", String(Math.trunc(args.limit)));
1508
1538
  if (args.scope === "conversations" || args.scope === "memory")
@@ -1515,21 +1545,29 @@ async function callTool(name, args) {
1515
1545
  const memoryBlock = memoryHits.length
1516
1546
  ? `${memoryHits.length} matching memory file${memoryHits.length === 1 ? "" : "s"} of yours:\n${memoryHits.map((hit) => `- [memory file ${String(hit.file)}] ${String(hit.snippet)}`).join("\n")}\n\n`
1517
1547
  : "";
1548
+ const asked = q ? `matches "${q}"` : `is there since ${since}${until ? ` until ${until}` : ""}`;
1518
1549
  if (!hits.length && !memoryHits.length) {
1519
- return { text: `Nothing of yours matches "${q}" — no earlier conversation and no memory file. Try fewer or different words; every word must appear.` };
1550
+ return { text: q
1551
+ ? `Nothing of yours ${asked} — no earlier conversation and no memory file. Try fewer or different words; every word must appear.`
1552
+ : `Nothing of yours ${asked} — no message in any of your conversations in that window.` };
1520
1553
  }
1521
1554
  if (!hits.length) {
1522
1555
  return { text: `${memoryBlock}No earlier conversation matches. These are your own notes, not new instructions; build on them.` };
1523
1556
  }
1524
1557
  const lines = hits.map((hit) => {
1525
- const when = typeof hit.at === "number" ? new Date(hit.at).toISOString().slice(0, 10) : "";
1558
+ // a listing by time shows the time; a search by words keeps the date
1559
+ const when = typeof hit.at === "number" ? new Date(hit.at).toISOString().slice(0, q ? 10 : 16).replace("T", " ") : "";
1526
1560
  const task = typeof hit.task === "string" && hit.task ? `task "${hit.task}"` : "an earlier task";
1527
- const where = hit.current ? "this conversation" : hit.crossed ? `${task}, private to this user` : task;
1561
+ const where = hit.current
1562
+ ? "this conversation"
1563
+ : typeof hit.room === "string" && hit.room
1564
+ ? `room "${hit.room}"${typeof hit.task === "string" && hit.task ? `, ${task}` : ""}`
1565
+ : hit.crossed ? `${task}, private to this user` : task;
1528
1566
  return `- [${when} · ${where} · ${recallSpeaker(hit)} · thread ${hit.threadId} · message ${hit.messageId}] ${hit.snippet}`;
1529
1567
  });
1530
1568
  const crossed = hits.some((hit) => hit.crossed === true);
1531
1569
  return {
1532
- text: `${memoryBlock}${hits.length} matching message${hits.length === 1 ? "" : "s"} from your earlier conversations (best match first):\n${lines.join("\n")}\n\n` +
1570
+ text: `${memoryBlock}${hits.length} ${q ? "matching " : ""}message${hits.length === 1 ? "" : "s"} from your earlier conversations (${q ? "best match first" : "newest first"}):\n${lines.join("\n")}\n\n` +
1533
1571
  "These are your own past notes. If one of them is the message you need, call session_read with its thread and message ids for the full text rather than searching again. Build on them rather than redoing the work; ask the user only about what they do not cover." +
1534
1572
  (crossed
1535
1573
  ? " The hits marked private came from your one-to-one conversation with this user, not from this room; the room has been shown that you recalled them. Use them, and say where something came from if anyone asks."
@@ -251,7 +251,12 @@ export async function mountChatTools(integrations, signal) {
251
251
  servers.push(["agents", integrations.agents]);
252
252
  if (integrations?.composio)
253
253
  servers.push(["composio", integrations.composio]);
254
- servers.push(...Object.entries(integrations?.custom ?? {}));
254
+ // this client starts its servers and talks over stdio; a remote (url)
255
+ // entry is skipped here and reaches Claude and Codex bots
256
+ for (const [name, server] of Object.entries(integrations?.custom ?? {})) {
257
+ if ("command" in server)
258
+ servers.push([name, server]);
259
+ }
255
260
  if (servers.length > 32)
256
261
  throw new Error("MCP server count exceeds the 32-server limit");
257
262
  const clients = [];
@@ -150,6 +150,23 @@ function claudeEnvironment(model, source = process.env, configDir, instanceEnvir
150
150
  function inheritsUserConfig(env) {
151
151
  return env.OMB_CLAUDE_INHERIT_USER_CONFIG === "1";
152
152
  }
153
+ /** The Engines-page warning while the escape hatch is set. The flag is a
154
+ * footgun: it is invisible once exported, and what it costs — every Claude
155
+ * bot re-reading this machine's own servers, skills, hooks and CLAUDE.md on
156
+ * every model call — shows up only on the bill. Naming it where the person
157
+ * looks when something is off is the whole point. */
158
+ export function claudeInheritWarning(env) {
159
+ if (!inheritsUserConfig(env))
160
+ return undefined;
161
+ return {
162
+ title: "Bots inherit this machine's Claude Code setup",
163
+ message: "OMB_CLAUDE_INHERIT_USER_CONFIG=1 is set on the OpenMausBot process, so every Claude bot also loads this " +
164
+ "computer's own MCP servers, connectors, skills, hooks and personal CLAUDE.md on every turn — often thousands " +
165
+ "of extra tokens per model call, and tools nobody gave the bot. Unless a bot genuinely needs a server from " +
166
+ "your user-scope Claude config, remove the variable and restart; add the server under Settings → MCP servers " +
167
+ "or the bot project's .mcp.json instead.",
168
+ };
169
+ }
153
170
  /** Retain the selected CLI account's authentication without importing its
154
171
  * hooks, permissions, MCP servers or personal instructions. Explicit OMB
155
172
  * connections/local endpoints own their entire routing + credential pair. */
@@ -776,6 +793,11 @@ export const ClaudeDriver = {
776
793
  const { instanceId, config } = input;
777
794
  const environment = (model) => claudeEnvironment(config.managed ? undefined : model, { ...process.env, ...input.environment }, config.configDir, input.environment);
778
795
  const catalogEnv = environment();
796
+ // Say it once where a headless or source run reads its logs; the Engines
797
+ // page carries the same warning for the desktop (claudeInheritWarning).
798
+ if (inheritsUserConfig(catalogEnv)) {
799
+ console.error(`claude (${instanceId}): OMB_CLAUDE_INHERIT_USER_CONFIG=1 — bots inherit this machine's Claude Code MCP servers, skills, hooks and CLAUDE.md on every turn; remove it unless a bot needs a user-scope server`);
800
+ }
779
801
  let models = STATIC_CLAUDE_MODELS;
780
802
  const refreshModels = async () => {
781
803
  if (config.managed)
@@ -965,7 +987,12 @@ export const ClaudeDriver = {
965
987
  // model call in the session then re-reads all of it.
966
988
  // Each flag only on a CLI that accepts it: an unknown flag is an
967
989
  // argument error that would fail every turn (CLAUDE_FLAG_FLOORS).
968
- if (claudeCliSupports(cliVersion, "--strict-mcp-config"))
990
+ // The MCP half has a switch (Plugins → MCP servers → "Also use my
991
+ // Claude Code MCP servers"): with it on, the CLI loads the servers
992
+ // and connectors from the person's own Claude Code config — the way
993
+ // Codex reads its own config.toml — while skills, hooks and the
994
+ // personal CLAUDE.md stay out.
995
+ if (!turn.mcpFromUserConfig && claudeCliSupports(cliVersion, "--strict-mcp-config"))
969
996
  args.push("--strict-mcp-config");
970
997
  if (claudeCliSupports(cliVersion, "--setting-sources"))
971
998
  args.push("--setting-sources", "project");
@@ -1050,6 +1077,9 @@ export const ClaudeDriver = {
1050
1077
  // routes every custom tool call through the ogb permission broker
1051
1078
  // into an Allow/Deny card. Reserved names were filtered upstream;
1052
1079
  // skip any residual collision instead of clobbering a built-in.
1080
+ // A remote entry ({type, url, headers}) is already in the CLI's own
1081
+ // shape and the CLI connects to it itself; header values ride in the
1082
+ // 0600 config file like every other credential here.
1053
1083
  // Bot-owned servers, gated below: they are the ones that answer for a
1054
1084
  // machine rather than for a context window.
1055
1085
  const botOwned = new Set();
@@ -1746,7 +1776,8 @@ export const ClaudeDriver = {
1746
1776
  // CLI's own login (Pro/Max): the cost it reports is what the call
1747
1777
  // WOULD bill, not a charge
1748
1778
  const update = claudeCliUpdate(version, config.cli);
1749
- return { state: "available", version, ...auth, ...(update ? { update } : {}), billing: "subscription" };
1779
+ const warning = claudeInheritWarning(env);
1780
+ return { state: "available", version, ...auth, ...(update ? { update } : {}), ...(warning ? { warning } : {}), billing: "subscription" };
1750
1781
  };
1751
1782
  /** One-shot Claude call with the prompt on stdin, never argv. Approval
1752
1783
  * summaries can contain paths, commands, or secrets, so the generic
@@ -103,7 +103,10 @@ function decodeManagedCodex(raw) {
103
103
  throw new Error("Invalid Company Codex configuration.");
104
104
  }
105
105
  const url = new URL(value.url);
106
- if (url.username || url.password || url.search || url.hash || !["https:", "http:"].includes(url.protocol))
106
+ // Plain HTTP leaks the Company API key; allow it only on loopback hosts,
107
+ // where a local proxy terminates TLS on the trusted machine instead.
108
+ const loopback = url.hostname === "localhost" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
109
+ if (url.username || url.password || url.search || url.hash || (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)))
107
110
  throw new Error("Invalid Company Codex endpoint.");
108
111
  return { url: url.href.replace(/\/$/, ""), models: value.models };
109
112
  }
@@ -123,6 +126,18 @@ export function managedCodexArgs(config) {
123
126
  }
124
127
  const QUESTION_TIMEOUT_NOTE = "No answer was given — use your best judgment.";
125
128
  const DENY_TIMEOUT_NOTE = "OpenMausBot: nobody answered this permission request in time. Skip this action and finish what you can without it.";
129
+ const skippedSseServers = new Set();
130
+ function noteSkippedSseServer(name) {
131
+ if (skippedSseServers.has(name))
132
+ return;
133
+ skippedSseServers.add(name);
134
+ console.error(`codex: MCP server ${JSON.stringify(name)} uses the SSE transport, which codex does not speak — it is available to Claude bots only`);
135
+ }
136
+ /** A TOML inline table for a `-c key=value` override; JSON string quoting
137
+ * is valid TOML basic-string quoting. */
138
+ function tomlInlineTable(entries) {
139
+ return `{ ${Object.entries(entries).map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`).join(", ")} }`;
140
+ }
126
141
  /** RequestPermissionProfile uses null for permission families that were not
127
142
  * requested; GrantedPermissionProfile requires those keys to be absent. */
128
143
  function grantedPermissions(raw) {
@@ -410,12 +425,39 @@ export function codexNativeIncomingLogMessage(message, sensitiveResponseIds) {
410
425
  return message;
411
426
  }
412
427
  function mountMcpServer(appServerArgs, env, name, server, preApproved = true) {
413
- Object.assign(env, server.env);
414
428
  const prefix = `mcp_servers.${name}`;
415
- appServerArgs.push("-c", `${prefix}.command=${JSON.stringify(server.command)}`, "-c", `${prefix}.args=${JSON.stringify(server.args)}`,
416
- // Values stay in the child environment; argv contains names only so
417
- // credentials never appear in process listings or diagnostics.
418
- "-c", `${prefix}.env_vars=${JSON.stringify(Object.keys(server.env))}`);
429
+ if ("url" in server) {
430
+ // A remote server: codex connects itself. Header values are credentials
431
+ // (Authorization: Bearer …) and travel like env values — the child env
432
+ // holds them under harness-generated names, argv names only the variables.
433
+ // A bearer token goes through codex's own bearer setting, the path its
434
+ // remote servers are documented and exercised with; any other header
435
+ // rides env_http_headers.
436
+ appServerArgs.push("-c", `${prefix}.url=${JSON.stringify(server.url)}`);
437
+ const stem = `OMB_MCP_HEADER_${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
438
+ const variables = {};
439
+ Object.entries(server.headers).forEach(([header, value], index) => {
440
+ const bearer = header.toLowerCase() === "authorization" ? /^Bearer\s+(\S+)$/i.exec(value) : null;
441
+ if (bearer) {
442
+ env[`${stem}_BEARER`] = bearer[1];
443
+ appServerArgs.push("-c", `${prefix}.bearer_token_env_var=${JSON.stringify(`${stem}_BEARER`)}`);
444
+ return;
445
+ }
446
+ const variable = `${stem}_${index}`;
447
+ env[variable] = value;
448
+ variables[header] = variable;
449
+ });
450
+ if (Object.keys(variables).length) {
451
+ appServerArgs.push("-c", `${prefix}.env_http_headers=${tomlInlineTable(variables)}`);
452
+ }
453
+ }
454
+ else {
455
+ Object.assign(env, server.env);
456
+ appServerArgs.push("-c", `${prefix}.command=${JSON.stringify(server.command)}`, "-c", `${prefix}.args=${JSON.stringify(server.args)}`,
457
+ // Values stay in the child environment; argv contains names only so
458
+ // credentials never appear in process listings or diagnostics.
459
+ "-c", `${prefix}.env_vars=${JSON.stringify(Object.keys(server.env))}`);
460
+ }
419
461
  // Harness-owned servers are pre-quieted; a user-configured server keeps
420
462
  // codex's on-request policy so its tool calls become approval cards.
421
463
  if (preApproved) {
@@ -489,8 +531,21 @@ export const CodexDriver = {
489
531
  createdAt: new Date().toISOString(),
490
532
  });
491
533
  const sendTurn = async (turn) => {
492
- if (config.managed && (!turn.model || !config.managed.models.includes(turn.model) || !input.environment.OPENMAUSBOT_COMPANY_API_KEY || !input.environment.CODEX_HOME)) {
493
- throw new Error("Company model access is unavailable. Reconnect your organization; personal billing will not be used.");
534
+ if (config.managed) {
535
+ // One blanket refusal hides which prerequisite broke; name it so the
536
+ // person can fix the actual gap instead of reconnecting blind.
537
+ if (!turn.model) {
538
+ throw new Error("Company model access is unavailable: no model is selected. Reconnect your organization; personal billing will not be used.");
539
+ }
540
+ if (!config.managed.models.includes(turn.model)) {
541
+ throw new Error("Company model access is unavailable: " + turn.model + " is not approved for your organization. Reconnect your organization; personal billing will not be used.");
542
+ }
543
+ if (!input.environment.OPENMAUSBOT_COMPANY_API_KEY) {
544
+ throw new Error("Company model access is unavailable: OPENMAUSBOT_COMPANY_API_KEY is missing. Reconnect your organization; personal billing will not be used.");
545
+ }
546
+ if (!input.environment.CODEX_HOME) {
547
+ throw new Error("Company model access is unavailable: CODEX_HOME is missing. Reconnect your organization; personal billing will not be used.");
548
+ }
494
549
  }
495
550
  // One driver instance serves many threads. Interrupt state belongs to
496
551
  // this turn so activity elsewhere cannot cancel or revive its retry.
@@ -506,6 +561,8 @@ export const CodexDriver = {
506
561
  // explicit mode, which takes precedence.
507
562
  const approvalMode = turn.approvalMode ?? (config.fullAuto ? "full" : "ask");
508
563
  for (const [name, server] of Object.entries(turn.integrations?.custom ?? {})) {
564
+ if ("url" in server)
565
+ continue;
509
566
  const reserved = Object.keys(server.env).find(isHarnessOwnedMcpEnvName);
510
567
  if (reserved) {
511
568
  throw new Error(`Custom MCP server “${name}” cannot set reserved environment variable “${reserved}”`);
@@ -536,6 +593,13 @@ export const CodexDriver = {
536
593
  mountMcpServer(appServerArgs, env, "browser", turn.integrations.browser);
537
594
  }
538
595
  for (const [name, server] of Object.entries(turn.integrations?.custom ?? {})) {
596
+ // codex speaks streamable HTTP to a remote server, not the older
597
+ // SSE transport: such an entry still reaches Claude bots, and is
598
+ // left out here rather than mounted as something it is not
599
+ if ("url" in server && server.type === "sse") {
600
+ noteSkippedSseServer(name);
601
+ continue;
602
+ }
539
603
  mountMcpServer(appServerArgs, env, name, server, false);
540
604
  }
541
605
  if (turn.integrations?.phone) {
@@ -703,6 +767,27 @@ export const CodexDriver = {
703
767
  }
704
768
  return;
705
769
  }
770
+ // One ask card carries one question honestly: its choices would come
771
+ // from the first question alone and its one reply (including the
772
+ // timeout note) would be copied into every question id (#1237).
773
+ // Refuse the bundled call with a teaching error instead of
774
+ // fabricating per-question answers.
775
+ if (isQuestion && (!Array.isArray(params.questions) || params.questions.length !== 1)) {
776
+ const bundled = Array.isArray(params.questions) && params.questions.length > 1;
777
+ send({
778
+ jsonrpc: "2.0",
779
+ id: msg.id,
780
+ error: {
781
+ code: -32602,
782
+ message: bundled
783
+ ? `ask supports one question per call; this request bundled ${params.questions.length}. Split it into separate asks, one question each.`
784
+ : Array.isArray(params.questions)
785
+ ? "ask supports one question per call; this request sent none."
786
+ : "ask supports one question per call; params.questions must be an array with exactly one question.",
787
+ },
788
+ });
789
+ return;
790
+ }
706
791
  const mcpTool = isLegacyMcpPermission
707
792
  ? String(params.message ?? "").match(/tool "([^"]+)"/)?.[1]
708
793
  : undefined;
@@ -6,14 +6,19 @@ import { appendFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { NATIVE_DIR } from "../config.js";
8
8
  import { redactSecrets } from "../redact.js";
9
+ import { capThreadLog, currentThreadLogCap } from "../thread-log-rotation.js";
9
10
  export function appendNative(threadId, entry) {
11
+ const file = join(NATIVE_DIR, `${threadId}.ndjson`);
10
12
  try {
11
13
  // The session-setup messages carry the credentials the agent is handed —
12
14
  // the box and comms tokens ride inside session/new's mcpServers env, and
13
15
  // an MCP header can carry a Composio key. These files are ordinary
14
16
  // 0644 files people paste into bug reports, so values are masked while
15
17
  // the shape stays intact.
16
- appendFileSync(join(NATIVE_DIR, `${threadId}.ndjson`), JSON.stringify({ at: new Date().toISOString(), ...entry, msg: redactSecrets(entry.msg) }) + "\n", { mode: 0o600 });
18
+ appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry, msg: redactSecrets(entry.msg) }) + "\n", { mode: 0o600 });
19
+ // Best-effort size cap (#1280) — same rule as the write itself: never
20
+ // let logging break the run it is observing.
21
+ capThreadLog(file, currentThreadLogCap());
17
22
  }
18
23
  catch {
19
24
  /* never let logging break a run */
@@ -8,6 +8,7 @@ import { appendFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { EVENTS_DIR } from "../config.js";
10
10
  import { redactSecrets } from "../redact.js";
11
+ import { capThreadLog, currentThreadLogCap } from "../thread-log-rotation.js";
11
12
  import { newId } from "../contracts.js";
12
13
  const INCOMPLETE_LOG_MESSAGE = "Canonical event history is incomplete: OpenMausBot could not write one or more events to disk. Live updates will continue.";
13
14
  export class EventBus {
@@ -43,6 +44,9 @@ export class EventBus {
43
44
  this.appendLog(join(EVENTS_DIR, `${event.threadId}.ndjson`), persistedEvents.map((entry) => JSON.stringify(entry)).join("\n") + "\n", { mode: 0o600 });
44
45
  if (pendingWarning)
45
46
  this.pendingLogWarnings.delete(event.threadId);
47
+ // Best-effort size cap (#1280): an open thread's canonical log
48
+ // otherwise grows without bound for as long as the thread stays open.
49
+ capThreadLog(join(EVENTS_DIR, `${event.threadId}.ndjson`), currentThreadLogCap());
46
50
  }
47
51
  catch (error) {
48
52
  // Never feed this warning back through publish(): that would retry the
@@ -225,6 +225,7 @@ export class ProviderRegistry {
225
225
  browserMcp: inst.adapter.capabilities.browserMcp === true,
226
226
  images: inst.adapter.capabilities.images === true,
227
227
  effortLevels: inst.adapter.capabilities.effortLevels,
228
+ modelVariants: inst.adapter.capabilities.modelVariants === true,
228
229
  queueing: inst.adapter.capabilities.queueing === true,
229
230
  localComputerMcp: inst.adapter.capabilities.localComputerMcp === true,
230
231
  approvalReview: inst.reviewPermission !== undefined,