openmausbot 0.1.92 → 0.1.94

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 (163) hide show
  1. package/dist/assets/{DesktopViewer-Do6lBa4C.js → DesktopViewer-bxM7NV3N.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Cd3X7Hxc.js → abnfDiagram-O67JEVCF-BhMdJhRJ.js} +1 -1
  3. package/dist/assets/{arc-DIYc7Ymx.js → arc-B5itKDcx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-DBmRGVyP.js → architectureDiagram-NJMV4G6O-C5d7UY6U.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CPFePSEE.js → blockDiagram-BEXU5L5S-BrZIhMmu.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DD_DO1qy.js → c4Diagram-YGBWAQC7-7I0ArfbD.js} +1 -1
  7. package/dist/assets/channel-CdJX6i8k.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BNEeNSjX.js → chunk-2Q5K7J3B-BnEsyN36.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BbinLaqH.js → chunk-3FUC2YCW-BXATQ52s.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BBgYrgDR.js → chunk-5VM5RSS4-B0BpTkEx.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-ezXj-kA-.js → chunk-GTNCS2PH-BtLTz6FR.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-9FxZ3diV.js → chunk-GWA4HPMP-D6gcZOYt.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-CO-40JpI.js → chunk-JWPE2WC7-DCLrosvf.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-BEhjslYE.js → chunk-NETBCI7D-B9JsknV9.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CNeq4n7_.js → chunk-WEXAMYUT-CsoTxw7z.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-OsCY7KSi.js → chunk-XXDRQBXY-0F_frB0c.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CloRUEtk.js → classDiagram-v2-NBCMYWYE-DkrpnflX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-D1BGZhp4.js → cose-bilkent-JH36ORCC-BNDwYBt9.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CUf4T2CN.js → cynefin-OW5HDTMX-JUkNr1Mk.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-DPEZrFGA.js → cynefinDiagram-VND7K2PF-DKeh9N4M.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-bDe6GIv5.js → dagre-6A5THRUB-ve-6T_wX.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-CdcUQz55.js → diagram-22UHCM2B-BuJ2d8rT.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-BZpqGg7c.js → diagram-3UASUU5V-BgOs_Ni-.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-DvGM6ugp.js → diagram-ATOU4E4O-DMK6VmcP.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DSTmMm0s.js → diagram-CDSNMT55-DksASlcY.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-15cucnMn.js → diagram-MLGK6HIB-Bgn8I5k6.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-tMbuaE6n.js → diagram-MPIPVDR6-zK0jqO8N.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BYxYzjHI.js → ebnfDiagram-ZINNZB2B-vKrbzTwr.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-v6bj852W.js → elk-276RUBZZ-Bzv2mPNf.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-5lBBlnd2.js → erDiagram-OPXOYQCR-CvOyYuxu.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-CZyl1zIj.js → flowDiagram-KWPJA3E3-CtbnDbc0.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiAI-ltZ.js → ganttDiagram-FUAMR5RP-c4dMRYyG.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BAZISkj3.js → gitGraphDiagram-X574FWY7-B34uaQho.js} +1 -1
  34. package/dist/assets/index-CPYrGsv1.js +610 -0
  35. package/dist/assets/{index-DGBaEXrM.js → index-CPiqc_DN.js} +1 -1
  36. package/dist/assets/index-CzT42GR8.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B6ooOy-Z.js → infoDiagram-VRGFBTTK-o64ma4CI.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js → ishikawaDiagram-OU5B5YK6-DV4C9BAm.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DWqlzIEe.js → journeyDiagram-ZHPQQLJL-Bf0AHk1H.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-JX6piDwL.js → kanban-definition-PNTS6WVX-B1UMSKmE.js} +1 -1
  41. package/dist/assets/{linear-CmOWzX0P.js → linear-BpKhuyLm.js} +1 -1
  42. package/dist/assets/{mermaid.core-CF1zwoQ-.js → mermaid.core-B_BKNCw5.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-CGh2K4fW.js → mindmap-definition-NLK3R4M7-DeARruT1.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CDwYN0s7.js → pegDiagram-GJSIUBJH-BOQEk_1D.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-1rfjJocB.js → pieDiagram-5QR66LMP-EIfoiTZo.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-tKPvPiok.js → quadrantDiagram-O4NWA36T-GgZwT-fi.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BHLm5vXR.js → railroadDiagram-XR7U4H2S-D9rDQomj.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CB45STp7.js → requirementDiagram-PLB6GJNP-DAh1MIeo.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-55QA_huC.js → sankeyDiagram-IPEJSGJF-CPr6Uz64.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-BckFmQHn.js → sequenceDiagram-PO4LG4MO-BU6VIdcm.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BFCOJF11.js → sizeCapture-INFHLROL-qF2HO41r.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-CZvmiVVN.js → stateDiagram-v2-GCMORJYK-DlJ8G4MD.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-BpDUCEKy.js → swimlanes-2SLR337P-DzYE8I54.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-U4GtLQ.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BpxHq6IK.js → timeline-definition-EJHVYXUP-B7wzEQ0A.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-Cj66vm02.js → usecaseDiagram-POWQR4AR-BvGgrxIP.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C9nRkWE_.js → vennDiagram-UO4OBE2U-CEECdgQg.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-D-me_nWj.js → wardleyDiagram-VNRHLVJA-XQHA0P_P.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Dc_fL4mk.js → xychartDiagram-PMCCYNJV-B5mLcZI2.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +1 -0
  62. package/dist-server/companion/src/routes.js +45 -1
  63. package/dist-server/container-mcp.js +99 -77
  64. package/dist-server/drivers/agents-proxy.js +40 -1
  65. package/dist-server/drivers/pi-mcp-extension.ts +245 -299
  66. package/dist-server/enterprise/server/index.js +1 -0
  67. package/dist-server/index.js +18361 -13794
  68. package/dist-server/local-computer-proxy.js +5 -1
  69. package/dist-server/local-computer.js +1 -0
  70. package/dist-server/mcp-gate.js +152 -20
  71. package/dist-server/mcp-remote-proxy.js +437 -0
  72. package/dist-server/openmausbot.js +8872 -7620
  73. package/dist-server/pair-cli.js +8872 -7620
  74. package/dist-server/permission-proxy.js +65 -1
  75. package/dist-server/proxy-paths.js +1 -0
  76. package/dist-server/server/auto-approve.js +33 -11
  77. package/dist-server/server/boat.js +3 -3
  78. package/dist-server/server/browser-live.js +14 -5
  79. package/dist-server/server/browser-runtime.js +6 -0
  80. package/dist-server/server/channel-queue.js +4 -1
  81. package/dist-server/server/cli.js +32 -12
  82. package/dist-server/server/cloud-move-http.js +9 -3
  83. package/dist-server/server/cloud-move.js +18 -0
  84. package/dist-server/server/cloud-owner.js +1 -1
  85. package/dist-server/server/computer-control.js +6 -0
  86. package/dist-server/server/computer-wait.js +33 -13
  87. package/dist-server/server/config.js +57 -10
  88. package/dist-server/server/container-computer.js +43 -32
  89. package/dist-server/server/decision-log.js +9 -6
  90. package/dist-server/server/delegations.js +47 -22
  91. package/dist-server/server/digest.js +12 -2
  92. package/dist-server/server/drivers/acp/core.js +229 -20
  93. package/dist-server/server/drivers/acp/cursor.js +13 -1
  94. package/dist-server/server/drivers/acp/grok.js +208 -3
  95. package/dist-server/server/drivers/acp/quiet-status.js +257 -0
  96. package/dist-server/server/drivers/acp/qwen.js +18 -0
  97. package/dist-server/server/drivers/agents-call.js +28 -0
  98. package/dist-server/server/drivers/agents-catalog.js +10 -1
  99. package/dist-server/server/drivers/boatagent.js +2 -0
  100. package/dist-server/server/drivers/builtIn.js +2 -0
  101. package/dist-server/server/drivers/cerebras.js +108 -0
  102. package/dist-server/server/drivers/chat-mcp-tools.js +45 -15
  103. package/dist-server/server/drivers/claude.js +35 -9
  104. package/dist-server/server/drivers/codex.js +84 -10
  105. package/dist-server/server/drivers/openai-chat.js +37 -5
  106. package/dist-server/server/drivers/pi.js +48 -4
  107. package/dist-server/server/drivers/prompt-split.js +2 -0
  108. package/dist-server/server/env-path.js +6 -2
  109. package/dist-server/server/environment.js +3 -0
  110. package/dist-server/server/guarded-requests.js +2 -1
  111. package/dist-server/server/index.js +1353 -311
  112. package/dist-server/server/live-call-controller.js +868 -0
  113. package/dist-server/server/live-call.js +216 -0
  114. package/dist-server/server/local-vm-stop-reason.js +26 -0
  115. package/dist-server/server/mcp-gate-config.js +43 -12
  116. package/dist-server/server/mcp-gate.js +127 -25
  117. package/dist-server/server/mcp-http.js +46 -8
  118. package/dist-server/server/mcp-remote-proxy.js +108 -0
  119. package/dist-server/server/new-bot-defaults.js +3 -0
  120. package/dist-server/server/peer-provenance.js +4 -2
  121. package/dist-server/server/permission-proxy.js +15 -1
  122. package/dist-server/server/plan-usage.js +797 -0
  123. package/dist-server/server/provider-key-check.js +2 -1
  124. package/dist-server/server/proxy-paths.js +1 -0
  125. package/dist-server/server/redact.js +2 -0
  126. package/dist-server/server/request-auth.js +3 -2
  127. package/dist-server/server/routes/desktop-viewer.js +50 -6
  128. package/dist-server/server/routes/live.js +98 -0
  129. package/dist-server/server/routines.js +6 -3
  130. package/dist-server/server/skill-library.js +207 -0
  131. package/dist-server/server/skills-library-migration.js +235 -0
  132. package/dist-server/server/skills.js +66 -3
  133. package/dist-server/server/steer-queue.js +13 -1
  134. package/dist-server/server/store.js +86 -4
  135. package/dist-server/server/surface.js +9 -3
  136. package/dist-server/server/team-backup.js +2 -1
  137. package/dist-server/server/thread-events.js +1 -1
  138. package/dist-server/server/tunnel.js +20 -1
  139. package/dist-server/server/turn-log.js +10 -0
  140. package/dist-server/server/turn-resources.js +124 -0
  141. package/dist-server/server/vps-computer.js +1 -1
  142. package/dist-server/server/workspace-backup.js +22 -0
  143. package/dist-server/server/workspace.js +1 -1
  144. package/dist-server/shared/approval-mode.js +1 -1
  145. package/dist-server/shared/ask-question.js +13 -0
  146. package/dist-server/shared/call-consent.js +28 -0
  147. package/dist-server/shared/live-approval.js +124 -0
  148. package/dist-server/shared/live-call.js +176 -0
  149. package/dist-server/shared/redact.js +1 -0
  150. package/dist-server/shared/skill-md.js +10 -0
  151. package/dist-server/shared/team-backup.js +3 -0
  152. package/dist-server/shared/tool-name.js +22 -0
  153. package/dist-server/shared/tool-scope-support.js +25 -0
  154. package/dist-server/shared/tool-scope.js +136 -0
  155. package/dist-server/src/lib/thread-refs.js +406 -0
  156. package/dist-server/vps-container-mcp.js +99 -77
  157. package/dist-server/workspace-backup.worker.js +103 -100
  158. package/enterprise/server/index.js +1 -0
  159. package/package.json +1 -1
  160. package/dist/assets/channel-Dtk5Mtic.js +0 -1
  161. package/dist/assets/index-Cdmjrl28.css +0 -1
  162. package/dist/assets/index-DR7YPMkW.js +0 -597
  163. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +0 -8
@@ -0,0 +1,108 @@
1
+ import { z } from "zod";
2
+ import { createOpenAIChatRuntime } from "./openai-chat.js";
3
+ // Cerebras serves open models on wafer-scale chips: same OpenAI chat
4
+ // contract, several times the tokens per second of a GPU cloud.
5
+ const DEFAULT_URL = "https://api.cerebras.ai/v1";
6
+ // The account tier is unknown: use the shared free-tier limit, not paid 128K.
7
+ // https://inference-docs.cerebras.ai/models/overview
8
+ const DEFAULT_MODELS = {
9
+ default: "gpt-oss-120b",
10
+ options: [
11
+ { id: "gpt-oss-120b", label: "GPT OSS 120B", contextWindow: 65536 },
12
+ { id: "qwen-3.8-27b", label: "Qwen3.8 27B", contextWindow: 65536 },
13
+ ],
14
+ };
15
+ const configSchema = z.object({
16
+ url: z.string().trim().url().default(DEFAULT_URL),
17
+ model: z.string().trim().min(1).optional(),
18
+ tools: z.boolean().optional(),
19
+ });
20
+ function decodeConfig(raw) {
21
+ const config = configSchema.parse(raw ?? {});
22
+ config.url = config.url.replace(/\/+$/, "");
23
+ const url = new URL(config.url);
24
+ if (url.protocol !== "https:" && !(url.protocol === "http:" &&
25
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
26
+ throw new Error("Cerebras requires HTTPS, except for a local test endpoint.");
27
+ }
28
+ return config;
29
+ }
30
+ const modelCard = z.object({ id: z.string().min(1) });
31
+ /** Cerebras answers "Only 'text' content type can be used" for these. */
32
+ const TEXT_ONLY_MODELS = /^(?:gpt-oss|llama3\.1-8b|llama-3\.3-70b)/i;
33
+ export const CerebrasDriver = {
34
+ driverKind: "cerebras",
35
+ metadata: { displayName: "Cerebras (API)", supportsMultipleInstances: true, access: "api" },
36
+ models: DEFAULT_MODELS,
37
+ install: {
38
+ docsUrl: "https://cloud.cerebras.ai/",
39
+ settings: "connections",
40
+ signInCommand: "Save a Cerebras API key in Settings → Connections, or set CEREBRAS_API_KEY on the server.",
41
+ },
42
+ decodeConfig,
43
+ defaultConfig: () => decodeConfig({}),
44
+ async create(input) {
45
+ const { config } = input;
46
+ const apiKey = (input.environment.CEREBRAS_API_KEY ?? process.env.CEREBRAS_API_KEY ?? "").trim();
47
+ const known = new Map(DEFAULT_MODELS.options.map((option) => [option.id, option]));
48
+ const withConfiguredModel = (options) => {
49
+ const preferred = config.model ?? DEFAULT_MODELS.default;
50
+ if (config.model && !options.some((option) => option.id === config.model)) {
51
+ options = [{ id: config.model, label: config.model }, ...options];
52
+ }
53
+ return { default: options.some((option) => option.id === preferred) ? preferred : options[0].id, options };
54
+ };
55
+ let catalog = withConfiguredModel(DEFAULT_MODELS.options);
56
+ const refreshModels = async () => {
57
+ if (!apiKey)
58
+ return;
59
+ try {
60
+ const response = await fetch(`${config.url}/models`, {
61
+ headers: { authorization: `Bearer ${apiKey}` },
62
+ redirect: "error",
63
+ signal: AbortSignal.timeout(8_000),
64
+ });
65
+ if (!response.ok)
66
+ return;
67
+ const json = await response.json();
68
+ const rows = Array.isArray(json) ? json : z.object({ data: z.array(z.unknown()) }).parse(json).data;
69
+ const options = [];
70
+ const seen = new Set();
71
+ for (const row of rows) {
72
+ const parsed = modelCard.safeParse(row);
73
+ if (!parsed.success || !parsed.data.id.trim() || seen.has(parsed.data.id))
74
+ continue;
75
+ seen.add(parsed.data.id);
76
+ options.push(known.get(parsed.data.id) ?? { id: parsed.data.id, label: parsed.data.id });
77
+ }
78
+ if (options.length)
79
+ catalog = withConfiguredModel(options);
80
+ }
81
+ catch {
82
+ // A failed refresh keeps the last usable catalog, including custom models.
83
+ }
84
+ };
85
+ if (apiKey)
86
+ void refreshModels();
87
+ return createOpenAIChatRuntime({
88
+ input, driverKind: "cerebras", apiKey, apiUrl: config.url,
89
+ tools: config.tools, models: () => catalog, refreshModels, reasoning: true,
90
+ reasoningReplayField: "reasoning",
91
+ computerUse: true, imageInput: (model) => !TEXT_ONLY_MODELS.test(model),
92
+ nudgeAnnouncedAction: true,
93
+ requestBody: (model, messages, stream) => ({
94
+ model, messages, stream,
95
+ ...(stream ? { stream_options: { include_usage: true } } : {}),
96
+ }),
97
+ httpErrorLabel: "Cerebras",
98
+ missingKeyError: "Save a Cerebras API key in Settings → Connections, or set CEREBRAS_API_KEY.",
99
+ unavailableReason: "No Cerebras API key — open Settings → Connections.",
100
+ timeoutMs: 180_000, billing: "metered", includeUsageInCompleted: true,
101
+ nativeLog: {
102
+ source: "cerebras.chat.completions",
103
+ outgoing: (_turn, messages, model) => ({ model, messageCount: messages.length }),
104
+ incoming: ({ text, reasoning, usage }) => ({ textLength: text.length, reasoningLength: reasoning.length, usage }),
105
+ },
106
+ });
107
+ },
108
+ };
@@ -8,6 +8,8 @@ import { augmentedPath } from "../env-path.js";
8
8
  import { killCliTree, spawnCli } from "../procs.js";
9
9
  import { chatImage } from "./chat-images.js";
10
10
  import { ChatBoatClient } from "./chat-boat-tools.js";
11
+ import { mcpStdioServer } from "../mcp-gate-config.js";
12
+ import { allowsTool, canUseMcpServer, parseToolScope } from "../../shared/tool-scope.js";
11
13
  /** The transport cannot safely continue this turn. A dispatched operation may
12
14
  * already have taken effect, so callers must not retry it through a new round. */
13
15
  export class ChatToolSessionError extends Error {
@@ -190,7 +192,7 @@ class ChatMcpClient {
190
192
  throw error;
191
193
  }
192
194
  }
193
- async tools(signal) {
195
+ async tools(signal, include = () => true) {
194
196
  const deadline = Date.now() + STARTUP_MS;
195
197
  const remaining = () => {
196
198
  if (Date.now() >= deadline)
@@ -212,7 +214,7 @@ class ChatMcpClient {
212
214
  const result = await this.call("tools/list", cursor ? { cursor } : {}, signal, remaining());
213
215
  if (!object(result) || !Array.isArray(result.tools))
214
216
  throw new Error("MCP tools/list returned an invalid result");
215
- tools.push(...result.tools);
217
+ tools.push(...result.tools.filter(include));
216
218
  if (tools.length > TOOL_COUNT)
217
219
  throw new Error("MCP tool count exceeds the 128-tool limit");
218
220
  if (result.nextCursor === undefined)
@@ -266,23 +268,46 @@ function boundedText(value) {
266
268
  end -= 1;
267
269
  return `${bytes.subarray(0, end).toString()}\n[MCP result truncated at 50KB; request less output.]`;
268
270
  }
269
- export async function mountChatTools(integrations, signal, computerUse = false) {
271
+ /** Optional fields a tool documents as "omit to use the default", where a
272
+ * blank string is an error instead. Smaller models fill optional fields with
273
+ * "" rather than leaving them out; dropping the blank restores the documented
274
+ * call. agent_browser_read: "Omit url to read the active tab." */
275
+ const BUILT_IN_BROWSER_BLANK_MEANS_OMITTED = {
276
+ agent_browser_read: ["url"],
277
+ };
278
+ function omitBlankDefaults(builtInBrowser, tool, args) {
279
+ if (!builtInBrowser || !object(args))
280
+ return;
281
+ for (const field of BUILT_IN_BROWSER_BLANK_MEANS_OMITTED[tool] ?? []) {
282
+ const value = args[field];
283
+ if (typeof value === "string" && !value.trim())
284
+ delete args[field];
285
+ }
286
+ }
287
+ export async function mountChatTools(integrations, signal, computerUse = false, toolScope) {
288
+ const parsed = parseToolScope(toolScope);
289
+ if (!parsed.ok)
290
+ throw new Error(parsed.error);
291
+ const scope = parsed.scope;
270
292
  const servers = [];
271
- if (computerUse && integrations?.computer)
293
+ const eligible = (server) => scope === undefined || canUseMcpServer(scope, server);
294
+ if (computerUse && integrations?.computer && eligible("computer"))
272
295
  servers.push(["computer", integrations.computer]);
273
- if (computerUse && integrations?.localComputer)
296
+ if (computerUse && integrations?.localComputer && eligible("computer"))
274
297
  servers.push(["computer", integrations.localComputer]);
275
- if (computerUse && integrations?.browser)
298
+ if (computerUse && integrations?.browser && eligible("browser"))
276
299
  servers.push(["browser", integrations.browser]);
277
- if (integrations?.agents)
300
+ if (integrations?.agents && eligible("agents"))
278
301
  servers.push(["agents", integrations.agents]);
279
- if (integrations?.composio)
302
+ if (integrations?.composio && eligible("composio"))
280
303
  servers.push(["composio", integrations.composio]);
281
- // this client starts its servers and talks over stdio; a remote (url)
282
- // entry is skipped here and reaches Claude and Codex bots
283
304
  for (const [name, server] of Object.entries(integrations?.custom ?? {})) {
284
- if ("command" in server)
285
- servers.push([name, server]);
305
+ if (!eligible(name))
306
+ continue;
307
+ const stdio = mcpStdioServer(server, { nodeEnv: { ELECTRON_RUN_AS_NODE: "1" } });
308
+ if (!stdio)
309
+ throw new Error("MCP server configuration is invalid");
310
+ servers.push([name, { command: stdio.command, args: stdio.args ?? [], env: stdio.env ?? {} }]);
286
311
  }
287
312
  if (servers.length > 32)
288
313
  throw new Error("MCP server count exceeds the 32-server limit");
@@ -316,12 +341,14 @@ export async function mountChatTools(integrations, signal, computerUse = false)
316
341
  // image delivery, including custom servers. Text stays bounded below.
317
342
  const client = "boxId" in descriptor ? new ChatBoatClient(descriptor) : new ChatMcpClient(descriptor, computerUse);
318
343
  clients.push(client);
319
- return { name, client, tools: await client.tools(signal) };
344
+ const include = (tool) => scope === undefined || (object(tool) && typeof tool.name === "string" && allowsTool(scope, { kind: "mcp", server: name, name: tool.name }));
345
+ const tools = client instanceof ChatMcpClient ? await client.tools(signal, include) : (await client.tools()).filter(include);
346
+ return { name, client, builtInBrowser: descriptor === integrations?.browser, tools };
320
347
  }));
321
348
  for (const mount of mounts) {
322
349
  if (mount.status === "rejected")
323
350
  throw mount.reason;
324
- const { name: server, client, tools } = mount.value;
351
+ const { name: server, client, builtInBrowser, tools } = mount.value;
325
352
  const originalNames = new Set();
326
353
  for (const tool of tools) {
327
354
  if (!object(tool) || typeof tool.name !== "string" || !tool.name.trim() || originalNames.has(tool.name))
@@ -352,7 +379,7 @@ export async function mountChatTools(integrations, signal, computerUse = false)
352
379
  const suffix = `_${index}`;
353
380
  name = base.slice(0, 64 - suffix.length) + suffix;
354
381
  }
355
- registered.set(name, { client, name: tool.name, schema });
382
+ registered.set(name, { client, server, builtInBrowser, name: tool.name, schema });
356
383
  definitions.push({ type: "function", function: { name, description, parameters } });
357
384
  if (Buffer.byteLength(JSON.stringify(definitions)) > CATALOG_BYTES)
358
385
  throw new Error("MCP tool catalog exceeds the 1MB limit");
@@ -371,6 +398,9 @@ export async function mountChatTools(integrations, signal, computerUse = false)
371
398
  const tool = registered.get(name);
372
399
  if (!tool)
373
400
  throw new Error("The requested tool was not advertised for this turn");
401
+ if (scope !== undefined && !allowsTool(scope, { kind: "mcp", server: tool.server, name: tool.name }))
402
+ throw new Error("Tool selection excludes this tool");
403
+ omitBlankDefaults(tool.builtInBrowser, tool.name, args);
374
404
  if (!object(args) || !tool.schema(args))
375
405
  throw new Error("Tool arguments do not match the advertised input schema; use its required fields and types");
376
406
  };
@@ -20,6 +20,8 @@ import { brokerSocketPath, describeSpawnFailure, execCli, killCliTree, spawnCli
20
20
  import { classifyResumeFailure, mayReplay, recoveryPromptFor } from "../resume-recovery.js";
21
21
  import { ClaudeLoginController } from "./claude-login-auth.js";
22
22
  import { gateServer, resultBudget } from "../mcp-gate-config.js";
23
+ import { canUseMcpServer } from "../../shared/tool-scope.js";
24
+ import { assertToolScopeSupported } from "../../shared/tool-scope-support.js";
23
25
  import { newEventId, newId } from "../contracts.js";
24
26
  import { askInputSummary, commandSummary, toolDetailPreview } from "../tool-summary.js";
25
27
  import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS } from "./retry.js";
@@ -1032,11 +1034,18 @@ export const ClaudeDriver = {
1032
1034
  // harness snapshots every instance whenever it describes them — app
1033
1035
  // load, the Engines page, and right after `claude update`, which is
1034
1036
  // exactly when the answer changes — so a turn normally finds it filled.
1035
- // Most turns before any snapshot assume a current CLI. A coordinated
1036
- // turn checks first because the snapshot-refresh flag is newer than the
1037
+ // Most flags before any snapshot assume a current CLI; autocompact
1038
+ // requires confirmed help support. A coordinated turn checks first
1039
+ // because the snapshot-refresh flag is newer than the
1037
1040
  // other context controls and an unknown flag would reject that request.
1038
1041
  let cliVersion = null;
1039
1042
  let cliVersionChecked = false;
1043
+ // Whether `claude --help` lists --autocompact, read once per CLI version
1044
+ // by snapshot(). The flag is not in every build above its version floor
1045
+ // (2.1.129 rejects it), so the listing wins over the floor; null until
1046
+ // probed, or when the probe fails.
1047
+ let cliHasAutocompact = null;
1048
+ let cliHelpVersion = null;
1040
1049
  const readCliVersion = (env) => new Promise((resolve) => {
1041
1050
  execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout.trim() || null));
1042
1051
  });
@@ -1120,6 +1129,7 @@ export const ClaudeDriver = {
1120
1129
  // is a fresh sendTurn, and the attempt cap must survive across launches
1121
1130
  const retryState = new Map();
1122
1131
  const sendTurn = async (turn, logicalTurnId) => {
1132
+ turn = { ...turn, toolScope: assertToolScopeSupported(DRIVER_KIND, turn.toolScope) };
1123
1133
  if (config.managedModels && (!turn.model || !config.managedModels.includes(turn.model)))
1124
1134
  throw new Error("This model is not assigned to this workspace.");
1125
1135
  if (config.requireApiKey && !input.environment.ANTHROPIC_API_KEY)
@@ -1193,7 +1203,7 @@ export const ClaudeDriver = {
1193
1203
  args.push("--disallowedTools", config.disallowedTools.join(","));
1194
1204
  }
1195
1205
  const turnEnvironment = environment();
1196
- if ((turn.refreshSystemPrompt || turn.guestConfined) && !cliVersionChecked) {
1206
+ if ((turn.refreshSystemPrompt || turn.guestConfined || turn.toolScope !== undefined) && !cliVersionChecked) {
1197
1207
  const version = await readCliVersion(turnEnvironment);
1198
1208
  if (version) {
1199
1209
  cliVersion = parseClaudeCliVersion(version);
@@ -1209,6 +1219,9 @@ export const ClaudeDriver = {
1209
1219
  if (replaysUserMessages)
1210
1220
  args.push("--replay-user-messages");
1211
1221
  const isolated = !inheritsUserConfig(turnEnvironment);
1222
+ if (turn.toolScope !== undefined && (!isolated || turn.mcpFromUserConfig || cliVersion === null || !claudeCliSupports(cliVersion, "--strict-mcp-config"))) {
1223
+ throw new Error("Claude cannot confirm a restricted MCP configuration for this account. Disable inherited Claude MCP servers and use a current, identifiable Claude CLI before using tool selection.");
1224
+ }
1212
1225
  if (isolated) {
1213
1226
  // A bot gets the tools and instructions its owner gave it, not
1214
1227
  // whatever this machine's Claude Code happens to be set up with.
@@ -1230,7 +1243,7 @@ export const ClaudeDriver = {
1230
1243
  args.push("--setting-sources", "project");
1231
1244
  }
1232
1245
  const compactWindow = autoCompactWindow(turnEnvironment);
1233
- if (compactWindow && claudeCliSupports(cliVersion, "--autocompact")) {
1246
+ if (compactWindow && cliHasAutocompact === true) {
1234
1247
  args.push("--autocompact", compactWindow);
1235
1248
  }
1236
1249
  // An old pair conversation can still carry its first assignment in
@@ -1343,11 +1356,16 @@ export const ClaudeDriver = {
1343
1356
  // permission broker, computer, browser, agents, dweb) are already
1344
1357
  // bounded and are deliberately left alone.
1345
1358
  const budget = resultBudget(turnEnvironment);
1346
- for (const name of botOwned) {
1347
- const gated = gateServer({ name, server: mcpServers[name], threadId, budget, nodeEnv: NODE_ENV_FLAG });
1359
+ for (const name of turn.toolScope === undefined ? botOwned : Object.keys(mcpServers)) {
1360
+ if (!canUseMcpServer(turn.toolScope, name)) {
1361
+ delete mcpServers[name];
1362
+ continue;
1363
+ }
1364
+ const gated = gateServer({ name, server: mcpServers[name], threadId, budget: botOwned.has(name) ? budget : 0, nodeEnv: NODE_ENV_FLAG, toolScope: turn.toolScope });
1348
1365
  if (gated)
1349
- mcpServers[name] = gated;
1366
+ mcpServers[name] = { ...gated, ...(mcpServers[name]?.alwaysLoad === true ? { alwaysLoad: true } : {}) };
1350
1367
  }
1368
+ allowed.splice(0, allowed.length, ...allowed.filter((name) => Object.hasOwn(mcpServers, name.slice("mcp__".length))));
1351
1369
  // Keep ask_user available even in Full access. Native bypass skips
1352
1370
  // permission prompts, not questions requiring a person's answer.
1353
1371
  let broker;
@@ -1355,7 +1373,8 @@ export const ClaudeDriver = {
1355
1373
  if (permissionMode !== "bypassPermissions") {
1356
1374
  args.push("--permission-prompt-tool", "mcp__ogb__approve");
1357
1375
  }
1358
- mcpServers.ogb = { command: process.execPath, args: [PERM_PROXY_PATH, socketPath], env: { ...NODE_ENV_FLAG }, alwaysLoad: true };
1376
+ const permissionEnv = { ...NODE_ENV_FLAG, ...(turn.toolScope !== undefined ? { OMB_PERMISSION_TOOL_SCOPE: JSON.stringify(turn.toolScope) } : {}) };
1377
+ mcpServers.ogb = { command: process.execPath, args: [PERM_PROXY_PATH, socketPath], env: permissionEnv, alwaysLoad: true };
1359
1378
  allowed.push("mcp__ogb");
1360
1379
  // A guest's turn pre-allows only the harness's own tools: anything
1361
1380
  // else (the browser can open a file: address) asks the owner first.
@@ -1568,7 +1587,7 @@ export const ClaudeDriver = {
1568
1587
  // the base path: the nonce is not part of the spawn contract, and a
1569
1588
  // retained session keeps its own broker object anyway.
1570
1589
  if (broker.socketPath !== socketPath && mcpConfigPath) {
1571
- mcpServers.ogb = { command: process.execPath, args: [PERM_PROXY_PATH, broker.socketPath], env: { ...NODE_ENV_FLAG }, alwaysLoad: true };
1590
+ mcpServers.ogb = { command: process.execPath, args: [PERM_PROXY_PATH, broker.socketPath], env: permissionEnv, alwaysLoad: true };
1572
1591
  }
1573
1592
  }
1574
1593
  // Write once, only after the broker has selected its real endpoint.
@@ -2263,6 +2282,13 @@ export const ClaudeDriver = {
2263
2282
  return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
2264
2283
  cliVersion = parseClaudeCliVersion(version);
2265
2284
  cliVersionChecked = true;
2285
+ if (version !== cliHelpVersion) {
2286
+ const help = await new Promise((resolve) => {
2287
+ execCli(config.cli, ["--help"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout));
2288
+ });
2289
+ cliHasAutocompact = help === null ? null : /^\s*--autocompact\b/m.test(help);
2290
+ cliHelpVersion = version;
2291
+ }
2266
2292
  const update = claudeCliUpdate(version, config.cli);
2267
2293
  const warning = claudeInheritWarning(env);
2268
2294
  if (config.requireApiKey) {
@@ -38,6 +38,9 @@ import { classifyResumeFailure, mayReplay, recoveryPromptFor } from "../resume-r
38
38
  import { extractMcpImages } from "../mcp-tool-images.js";
39
39
  import { parseProtocolAskQuestions, questionAnswersById, questionChoices } from "../../shared/ask-question.js";
40
40
  import { codexVersionBehind, readLatestCodexRelease } from "./codex-release.js";
41
+ import { canUseMcpServer } from "../../shared/tool-scope.js";
42
+ import { assertToolScopeSupported } from "../../shared/tool-scope-support.js";
43
+ import { gateServer } from "../mcp-gate-config.js";
41
44
  export { decodeCodexSelection, readCodexModelCatalog, STATIC_CODEX_MODELS } from "./codex-catalog.js";
42
45
  const DRIVER_KIND = "codex";
43
46
  export function codexUserError(value, chatgptPlan) {
@@ -621,6 +624,7 @@ export const CodexDriver = {
621
624
  createdAt: new Date().toISOString(),
622
625
  });
623
626
  const sendTurn = async (turn) => {
627
+ turn = { ...turn, toolScope: assertToolScopeSupported(DRIVER_KIND, turn.toolScope) };
624
628
  const generation = planGeneration;
625
629
  const assertPlanCurrent = () => {
626
630
  if (disposed)
@@ -691,48 +695,81 @@ export const CodexDriver = {
691
695
  if (planToken)
692
696
  env.OPENMAUSBOT_CHATGPT_TOKEN = planToken;
693
697
  const appServerArgs = ["app-server", ...(plan ? chatgptPlanCodexArgs() : config.managed ? managedCodexArgs(config.managed) : codexLocalProviderArgs(env, turn.model)), ...codexToolSurfaceArgs(),
698
+ // Native snapshots can restore inherited variables after the shell
699
+ // policy has filtered them. Scoped MCP gate settings must stay private.
700
+ ...(turn.toolScope === undefined ? [] : ["-c", "features.shell_snapshot=false"]),
694
701
  // A guest-driven turn (SendTurnInput.guestConfined): no shell and
695
702
  // no file reads, whatever the person's own config says (-c wins
696
703
  // over config files). The turn also starts with no environment.
697
704
  ...(turn.guestConfined ? GUEST_CONFINED_CODEX_ARGS : [])];
705
+ const selectedMcp = new Map();
706
+ const selectedApprovals = new Map();
707
+ const scopedServer = (name, mountName, server) => {
708
+ if (!canUseMcpServer(turn.toolScope, name))
709
+ return null;
710
+ return turn.toolScope === undefined ? server : gateServer({ name, server, threadId, budget: 0, toolScope: turn.toolScope, nodeEnv: { ELECTRON_RUN_AS_NODE: "1" },
711
+ configEnvName: `OMB_GATE_CONFIG_${createHash("sha256").update(mountName).digest("hex")}` });
712
+ };
713
+ const mountSelected = (name, mountName, server, preApproved = true) => {
714
+ const selected = scopedServer(name, mountName, server);
715
+ if (selected) {
716
+ selectedMcp.set(mountName, selected);
717
+ selectedApprovals.set(mountName, preApproved);
718
+ mountMcpServer(appServerArgs, env, mountName, selected, preApproved);
719
+ if (turn.toolScope !== undefined && !preApproved)
720
+ appServerArgs.push("-c", `mcp_servers.${mountName}.default_tools_approval_mode="prompt"`);
721
+ }
722
+ };
698
723
  if (turn.integrations?.composio) {
699
- mountMcpServer(appServerArgs, env, "openmausbot_connectors", turn.integrations.composio);
724
+ mountSelected("composio", "openmausbot_connectors", turn.integrations.composio);
700
725
  }
701
726
  if (turn.integrations?.agents) {
702
- mountMcpServer(appServerArgs, env, "agents", turn.integrations.agents);
727
+ mountSelected("agents", "agents", turn.integrations.agents);
703
728
  }
704
729
  if (turn.integrations?.localComputer) {
705
730
  // The host daemon and isolated Local VM both arrive as a direct Cua
706
731
  // Driver stdio MCP server. Codex sees the same computer tool surface.
707
- mountMcpServer(appServerArgs, env, "computer", turn.integrations.localComputer);
732
+ mountSelected("computer", "computer", turn.integrations.localComputer);
708
733
  }
709
734
  if (turn.integrations?.browser) {
710
- mountMcpServer(appServerArgs, env, "browser", turn.integrations.browser);
735
+ mountSelected("browser", "browser", turn.integrations.browser);
711
736
  }
712
737
  // A custom server named like one in the user's own config.toml would
713
738
  // be merged with it by the `-c` override — a stdio command over a
714
739
  // remote url is "invalid configuration" and kills the turn before the
715
740
  // model is asked. Such a server mounts under a name of its own.
716
741
  const declaredInCodexConfig = codexConfigMcpServerNames(env);
742
+ if (turn.toolScope !== undefined) {
743
+ for (const name of declaredInCodexConfig)
744
+ appServerArgs.push("-c", `mcp_servers.${name}.enabled=false`);
745
+ }
717
746
  for (const [name, server] of Object.entries(turn.integrations?.custom ?? {})) {
718
747
  // codex speaks streamable HTTP to a remote server, not the older
719
748
  // SSE transport: such an entry still reaches Claude bots, and is
720
749
  // left out here rather than mounted as something it is not
721
- if ("url" in server && server.type === "sse") {
750
+ if (turn.toolScope === undefined && "url" in server && server.type === "sse") {
722
751
  noteSkippedSseServer(name);
723
752
  continue;
724
753
  }
725
754
  const mountName = mountedMcpServerName(name, declaredInCodexConfig);
726
755
  if (mountName !== name)
727
756
  noteRenamedMcpServer(name, mountName);
728
- mountMcpServer(appServerArgs, env, mountName, server, false);
757
+ mountSelected(name, mountName, server, false);
729
758
  }
730
759
  if (turn.integrations?.phone) {
731
- const bridge = turn.integrations.phone;
732
- Object.assign(env, bridge.env);
733
- const prefix = "mcp_servers.openmausbot_phone";
734
- appServerArgs.push("-c", `${prefix}.command=${JSON.stringify(bridge.command)}`, "-c", `${prefix}.args=${JSON.stringify(bridge.args)}`, "-c", `${prefix}.env_vars=${JSON.stringify(Object.keys(bridge.env))}`, "-c", `${prefix}.default_tools_approval_mode="auto"`);
760
+ if (turn.toolScope !== undefined) {
761
+ mountSelected("phone", "openmausbot_phone", turn.integrations.phone);
762
+ }
763
+ else {
764
+ const bridge = turn.integrations.phone;
765
+ Object.assign(env, bridge.env);
766
+ const prefix = "mcp_servers.openmausbot_phone";
767
+ appServerArgs.push("-c", `${prefix}.command=${JSON.stringify(bridge.command)}`, "-c", `${prefix}.args=${JSON.stringify(bridge.args)}`, "-c", `${prefix}.env_vars=${JSON.stringify(Object.keys(bridge.env))}`, "-c", `${prefix}.default_tools_approval_mode="auto"`);
768
+ }
735
769
  }
770
+ const selectionConfig = turn.toolScope === undefined ? {} : { config: { mcp_servers: Object.fromEntries([...selectedMcp].map(([name, server]) => [name, "command" in server ? {
771
+ command: server.command, args: server.args, env_vars: Object.keys(server.env), env: {}, default_tools_approval_mode: selectedApprovals.get(name) ? "auto" : "prompt",
772
+ } : {}])) } };
736
773
  const commandCwd = permissionLaunchCwd(turn.cwd ?? homedir());
737
774
  const child = spawnCli(config.cli, appServerArgs, {
738
775
  cwd: turn.cwd ?? homedir(),
@@ -1487,6 +1524,41 @@ export const CodexDriver = {
1487
1524
  ? "Codex rejected its configuration as invalid. Check ~/.codex/config.toml (or CODEX_HOME) for a broken entry, then retry."
1488
1525
  : "Could not read Codex configuration; cannot safely update bot instructions. Retry after checking Codex.");
1489
1526
  }
1527
+ if (turn.toolScope !== undefined) {
1528
+ if (effectiveConfig?.features?.shell_snapshot !== false) {
1529
+ throw new Error("Codex could not disable inherited shell snapshots. No prompt was sent.");
1530
+ }
1531
+ // Gate descriptors belong to MCP children, never native shell tools.
1532
+ // Extend the effective policy for both new and resumed threads so
1533
+ // local, Company and ChatGPT turns retain the person's exclusions.
1534
+ const rawPolicy = effectiveConfig?.shell_environment_policy;
1535
+ if (rawPolicy !== undefined && (!rawPolicy || typeof rawPolicy !== "object" || Array.isArray(rawPolicy))) {
1536
+ throw new Error("Codex could not confirm its shell environment policy. No prompt was sent.");
1537
+ }
1538
+ const policy = (rawPolicy ?? {});
1539
+ const excluded = policy.exclude === undefined ? [] : policy.exclude;
1540
+ if (!Array.isArray(excluded) || excluded.some(name => typeof name !== "string")) {
1541
+ throw new Error("Codex could not confirm its shell environment exclusions. No prompt was sent.");
1542
+ }
1543
+ selectionConfig.config["shell_environment_policy.exclude"] = [...new Set([...excluded, "OMB_GATE_CONFIG_*"])];
1544
+ const catalog = effectiveConfig?.mcp_servers;
1545
+ if (!catalog || typeof catalog !== "object" || Array.isArray(catalog))
1546
+ throw new Error("Codex could not confirm its selected MCP configuration. No prompt was sent.");
1547
+ for (const [name, entry] of Object.entries(catalog)) {
1548
+ if (entry && typeof entry === "object" && entry.enabled === false)
1549
+ continue;
1550
+ const expected = selectedMcp.get(name);
1551
+ const actual = entry;
1552
+ if (!expected || !("command" in expected) || actual?.command !== expected.command
1553
+ || JSON.stringify(actual?.args ?? []) !== JSON.stringify(expected.args ?? [])
1554
+ || !Array.isArray(actual?.env_vars) || JSON.stringify([...actual.env_vars].sort()) !== JSON.stringify(Object.keys(expected.env).sort())
1555
+ || actual.default_tools_approval_mode !== (selectedApprovals.get(name) ? "auto" : "prompt")
1556
+ || (actual.env != null && (typeof actual.env !== "object" || Array.isArray(actual.env) || Object.keys(actual.env).length > 0))
1557
+ || (actual.tools != null && (typeof actual.tools !== "object" || Array.isArray(actual.tools) || Object.keys(actual.tools).length > 0))) {
1558
+ throw new Error("Codex has an MCP server outside the selected configuration. Disable native MCP entries for this account before using tool selection. No prompt was sent.");
1559
+ }
1560
+ }
1561
+ }
1490
1562
  // Proven before the turn starts: a Codex that did not take the
1491
1563
  // overrides runs nothing for a guest.
1492
1564
  if (turn.guestConfined && !codexShellDisabled(effectiveConfig)) {
@@ -1534,6 +1606,7 @@ export const CodexDriver = {
1534
1606
  let promptText = turn.text;
1535
1607
  if (cursor) {
1536
1608
  const resumeThread = () => request("thread/resume", {
1609
+ ...selectionConfig,
1537
1610
  threadId: cursor,
1538
1611
  developerInstructions,
1539
1612
  model: selection.model,
@@ -1581,6 +1654,7 @@ export const CodexDriver = {
1581
1654
  }
1582
1655
  if (!codexThreadId) {
1583
1656
  const startThread = () => request("thread/start", {
1657
+ ...selectionConfig,
1584
1658
  developerInstructions,
1585
1659
  cwd: turn.cwd ?? homedir(),
1586
1660
  model: selection.model,
@@ -1,5 +1,6 @@
1
1
  import { newEventId, newId } from "../contracts.js";
2
2
  import { ASK_USER_TOOL, ASK_USER_TOOL_DEFINITION, askQuestionSummary, parseAskQuestions, questionChoices } from "../../shared/ask-question.js";
3
+ import { allowsTool, parseToolScope } from "../../shared/tool-scope.js";
3
4
  import { redactSecretsInText } from "../redact.js";
4
5
  import { toolDetailPreview } from "../tool-summary.js";
5
6
  import { ChatToolSessionError, mountChatTools } from "./chat-mcp-tools.js";
@@ -30,6 +31,17 @@ const usageFrom = (usage) => usage
30
31
  const asError = (value) => value instanceof Error ? value : new Error(String(value));
31
32
  class UnsupportedChatToolsError extends Error {
32
33
  }
34
+ const NUDGE_ANNOUNCED_ACTION = "You said what you would do next but called no tool. Do it now with your tools, or reply with your final answer if nothing is left to do.";
35
+ /** A short reply that only announces a next step, with nothing to answer. */
36
+ export function announcesAction(text) {
37
+ const reply = text.trim();
38
+ if (!reply || reply.length > 400 || reply.includes("?"))
39
+ return false;
40
+ return /(?:[:\u2014\u2013]|\.\.\.|\u2026)$/.test(reply) ||
41
+ /^(?:checking|let me|i'll|i will|i'm going to|i am going to|opening|pulling|looking|searching|navigating|reading|fetching|loading|now (?:i'll|let me|opening|checking|pulling|reading))\b/i.test(reply);
42
+ }
43
+ const TEXT_ONLY_ATTACHMENT_NOTE = "[The person attached image(s), but this model cannot see images. Say so if the request depends on them.]";
44
+ const TEXT_ONLY_SCREENSHOT_NOTE = "[Screenshot not shown: this model cannot see images. Read the page with a snapshot or a text tool instead.]";
33
45
  function rejectsToolsParameter(status, body) {
34
46
  if (status !== 400 && status !== 422)
35
47
  return false;
@@ -282,6 +294,7 @@ export function createOpenAIChatRuntime(options) {
282
294
  clearTimeout(idleTimer);
283
295
  }
284
296
  };
297
+ const seesImages = (turn) => options.imageInput?.(turn.model || options.models().default) ?? true;
285
298
  const messagesFor = (turn) => {
286
299
  // The system message is the head of the resent prefix, so only the
287
300
  // stable half belongs there: a volatile edit must not re-price the
@@ -302,7 +315,9 @@ export function createOpenAIChatRuntime(options) {
302
315
  role: message.role,
303
316
  content: message.text,
304
317
  })),
305
- { role: "user", content: options.computerUse ? chatUserContent(userTurn) : userTurn.text },
318
+ { role: "user", content: !options.computerUse ? userTurn.text
319
+ : seesImages(turn) ? chatUserContent(userTurn)
320
+ : userTurn.images?.length ? `${userTurn.text}\n\n${TEXT_ONLY_ATTACHMENT_NOTE}` : userTurn.text },
306
321
  ];
307
322
  };
308
323
  const sendTurn = async (turn) => {
@@ -381,15 +396,21 @@ export function createOpenAIChatRuntime(options) {
381
396
  let toolFailed = false;
382
397
  const denials = [];
383
398
  const seenCalls = new Set();
399
+ let nudged = false;
384
400
  try {
385
- tools = await mountChatTools(options.tools === false ? undefined : turn.integrations, abort.signal, options.computerUse);
386
- let optionalQuestionOnly = options.tools !== false && tools.definitions.length === 0;
401
+ const parsed = parseToolScope(turn.toolScope);
402
+ if (!parsed.ok)
403
+ throw new Error(parsed.error);
404
+ const scope = parsed.scope;
405
+ tools = await mountChatTools(options.tools === false ? undefined : turn.integrations, abort.signal, options.computerUse, scope);
406
+ const questionAllowed = options.tools !== false && allowsTool(scope, { kind: "native", name: ASK_USER_TOOL });
407
+ let optionalQuestionOnly = questionAllowed && tools.definitions.length === 0;
387
408
  // The runtime's one built-in tool rides the same list: ask_user is
388
409
  // how a chat-completions engine reaches a person. An MCP server that
389
410
  // squats the name cannot shadow it — dispatch intercepts the name
390
411
  // before validate — but the definition is then skipped so the list
391
412
  // never advertises two.
392
- if (options.tools !== false && !tools.definitions.some((definition) => definition.function.name === ASK_USER_TOOL)) {
413
+ if (questionAllowed && !tools.definitions.some((definition) => definition.function.name === ASK_USER_TOOL)) {
393
414
  tools.definitions.push(ASK_USER_TOOL_DEFINITION);
394
415
  }
395
416
  for (let round = 0; round < 16; round++) {
@@ -490,6 +511,11 @@ export function createOpenAIChatRuntime(options) {
490
511
  if (completion.finishReason && completion.finishReason !== "stop") {
491
512
  throw new ChatProtocolError(`provider did not finish the response (${completion.finishReason})`);
492
513
  }
514
+ if (options.nudgeAnnouncedAction && !nudged && tools.definitions.length && announcesAction(completion.text)) {
515
+ nudged = true;
516
+ messages.push({ role: "assistant", content: completion.text }, { role: "user", content: NUDGE_ANNOUNCED_ACTION });
517
+ continue;
518
+ }
493
519
  if (toolFailed) {
494
520
  stopReason = "tool_error";
495
521
  throw new ChatProtocolError("One or more tool operations failed or were denied. See the tool results; the final response is not an execution receipt.");
@@ -508,7 +534,7 @@ export function createOpenAIChatRuntime(options) {
508
534
  if (seenCalls.size > MAX_CHAT_TOOL_CALLS)
509
535
  throw new ChatProtocolError("tool-call limit reached");
510
536
  messages.push({ role: "assistant", content: completion.text || null, tool_calls: completion.toolCalls,
511
- ...(completion.protocolReasoning && !reasoningReplayRejected.has(model) ? { reasoning_content: completion.protocolReasoning } : {}),
537
+ ...(completion.protocolReasoning && !reasoningReplayRejected.has(model) ? { [options.reasoningReplayField ?? "reasoning_content"]: completion.protocolReasoning } : {}),
512
538
  ...(completion.protocolReasoningDetails.length ? { reasoning_details: completion.protocolReasoningDetails } : {}),
513
539
  });
514
540
  const screenshotParts = [];
@@ -529,6 +555,8 @@ export function createOpenAIChatRuntime(options) {
529
555
  throw new ChatProtocolError("tool arguments must be a JSON object");
530
556
  const inputPreview = preview(args);
531
557
  if (call.function.name === ASK_USER_TOOL) {
558
+ if (!questionAllowed || !allowsTool(scope, { kind: "native", name: ASK_USER_TOOL }))
559
+ throw new Error("Tool selection excludes this tool");
532
560
  // A question is the person's card, not a permission, so it is
533
561
  // handled before the gate below: under Full access that gate
534
562
  // would auto-run an unanswered ask, and under Ask it would
@@ -573,6 +601,10 @@ export function createOpenAIChatRuntime(options) {
573
601
  started = true;
574
602
  if (allowed) {
575
603
  result = await tools.execute(call.function.name, args, abort.signal);
604
+ if (result.images?.length && !seesImages(turn)) {
605
+ const { images: _dropped, ...rest } = result;
606
+ result = { ...rest, text: `${rest.text}\n${TEXT_ONLY_SCREENSHOT_NOTE}` };
607
+ }
576
608
  if (result.images?.length) {
577
609
  try {
578
610
  assertImageTransport(options.apiUrl);