openmausbot 0.1.93 → 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 (140) hide show
  1. package/dist/assets/{DesktopViewer-CKNR3SEW.js → DesktopViewer-bxM7NV3N.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Ckrm1I1X.js → abnfDiagram-O67JEVCF-BhMdJhRJ.js} +1 -1
  3. package/dist/assets/{arc-BBdBuCgB.js → arc-B5itKDcx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CDdA3Hjs.js → architectureDiagram-NJMV4G6O-C5d7UY6U.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-AUrsx1YR.js → blockDiagram-BEXU5L5S-BrZIhMmu.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-BEqQ0Unh.js → c4Diagram-YGBWAQC7-7I0ArfbD.js} +1 -1
  7. package/dist/assets/channel-CdJX6i8k.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-CEEuvd4C.js → chunk-2Q5K7J3B-BnEsyN36.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-DyANCiHl.js → chunk-3FUC2YCW-BXATQ52s.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-Dx_Slpw9.js → chunk-5VM5RSS4-B0BpTkEx.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Dl7h7iiD.js → chunk-GTNCS2PH-BtLTz6FR.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-BLx07SPb.js → chunk-GWA4HPMP-D6gcZOYt.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BMxDj3n7.js → chunk-JWPE2WC7-DCLrosvf.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-CN66PT4H.js → chunk-NETBCI7D-B9JsknV9.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-6HwhsDKw.js → chunk-WEXAMYUT-CsoTxw7z.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-gWnLt7SN.js → chunk-XXDRQBXY-0F_frB0c.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CMS5l2ea.js → classDiagram-v2-NBCMYWYE-DkrpnflX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-CV7sQwIG.js → cose-bilkent-JH36ORCC-BNDwYBt9.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CSyl-7vl.js → cynefin-OW5HDTMX-JUkNr1Mk.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BCLKL6az.js → cynefinDiagram-VND7K2PF-DKeh9N4M.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DiaZs6S1.js → dagre-6A5THRUB-ve-6T_wX.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-BeTtmt3I.js → diagram-22UHCM2B-BuJ2d8rT.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CsdBG-QQ.js → diagram-3UASUU5V-BgOs_Ni-.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BczvdbR1.js → diagram-ATOU4E4O-DMK6VmcP.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DHTTTMhi.js → diagram-CDSNMT55-DksASlcY.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-RTjHyd8z.js → diagram-MLGK6HIB-Bgn8I5k6.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-BDenHEH_.js → diagram-MPIPVDR6-zK0jqO8N.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BSiRSbtL.js → ebnfDiagram-ZINNZB2B-vKrbzTwr.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BCAElss-.js → elk-276RUBZZ-Bzv2mPNf.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-C4_1ZPJe.js → erDiagram-OPXOYQCR-CvOyYuxu.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bcb4Z75S.js → flowDiagram-KWPJA3E3-CtbnDbc0.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiPOzxzb.js → ganttDiagram-FUAMR5RP-c4dMRYyG.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-B1wtF3ZS.js → gitGraphDiagram-X574FWY7-B34uaQho.js} +1 -1
  34. package/dist/assets/index-CPYrGsv1.js +610 -0
  35. package/dist/assets/{index-BkYwilkG.js → index-CPiqc_DN.js} +1 -1
  36. package/dist/assets/index-CzT42GR8.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-A19No71U.js → infoDiagram-VRGFBTTK-o64ma4CI.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js → ishikawaDiagram-OU5B5YK6-DV4C9BAm.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DAO0pVan.js → journeyDiagram-ZHPQQLJL-Bf0AHk1H.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-NepmKEe2.js → kanban-definition-PNTS6WVX-B1UMSKmE.js} +1 -1
  41. package/dist/assets/{linear-bq1PTDTW.js → linear-BpKhuyLm.js} +1 -1
  42. package/dist/assets/{mermaid.core-s4LTbQle.js → mermaid.core-B_BKNCw5.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-LtdbgHFH.js → mindmap-definition-NLK3R4M7-DeARruT1.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-tdbaCxiK.js → pegDiagram-GJSIUBJH-BOQEk_1D.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DvkbpW8l.js → pieDiagram-5QR66LMP-EIfoiTZo.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Do4KaH-E.js → quadrantDiagram-O4NWA36T-GgZwT-fi.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BQWULOAs.js → railroadDiagram-XR7U4H2S-D9rDQomj.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-w9kei1FU.js → requirementDiagram-PLB6GJNP-DAh1MIeo.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-D2gRy0e8.js → sankeyDiagram-IPEJSGJF-CPr6Uz64.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-a2sO5_Eo.js → sequenceDiagram-PO4LG4MO-BU6VIdcm.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CiPJmFEB.js → sizeCapture-INFHLROL-qF2HO41r.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-DAkUUlL7.js → stateDiagram-v2-GCMORJYK-DlJ8G4MD.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DANf2R8Y.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-BKpQmzrT.js → timeline-definition-EJHVYXUP-B7wzEQ0A.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-BtI7qLN-.js → usecaseDiagram-POWQR4AR-BvGgrxIP.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-gXJXrAG_.js → vennDiagram-UO4OBE2U-CEECdgQg.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-GU8Po5-n.js → wardleyDiagram-VNRHLVJA-XQHA0P_P.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-BF4J30g6.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 +21 -0
  63. package/dist-server/container-mcp.js +93 -76
  64. package/dist-server/drivers/agents-proxy.js +38 -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 +16942 -13191
  68. package/dist-server/local-computer.js +1 -0
  69. package/dist-server/mcp-gate.js +147 -19
  70. package/dist-server/mcp-remote-proxy.js +437 -0
  71. package/dist-server/openmausbot.js +8233 -7651
  72. package/dist-server/pair-cli.js +8233 -7651
  73. package/dist-server/permission-proxy.js +65 -1
  74. package/dist-server/proxy-paths.js +1 -0
  75. package/dist-server/server/auto-approve.js +33 -11
  76. package/dist-server/server/browser-live.js +14 -5
  77. package/dist-server/server/browser-runtime.js +6 -0
  78. package/dist-server/server/channel-queue.js +4 -1
  79. package/dist-server/server/cli.js +32 -12
  80. package/dist-server/server/cloud-move-http.js +9 -3
  81. package/dist-server/server/cloud-move.js +18 -0
  82. package/dist-server/server/computer-wait.js +33 -13
  83. package/dist-server/server/config.js +44 -10
  84. package/dist-server/server/decision-log.js +9 -6
  85. package/dist-server/server/delegations.js +47 -22
  86. package/dist-server/server/drivers/acp/core.js +57 -8
  87. package/dist-server/server/drivers/acp/grok.js +208 -3
  88. package/dist-server/server/drivers/agents-call.js +28 -0
  89. package/dist-server/server/drivers/agents-catalog.js +10 -1
  90. package/dist-server/server/drivers/boatagent.js +2 -0
  91. package/dist-server/server/drivers/chat-mcp-tools.js +27 -14
  92. package/dist-server/server/drivers/claude.js +18 -6
  93. package/dist-server/server/drivers/codex.js +84 -10
  94. package/dist-server/server/drivers/openai-chat.js +11 -3
  95. package/dist-server/server/drivers/pi.js +48 -4
  96. package/dist-server/server/environment.js +3 -0
  97. package/dist-server/server/guarded-requests.js +2 -1
  98. package/dist-server/server/index.js +1143 -254
  99. package/dist-server/server/live-call-controller.js +868 -0
  100. package/dist-server/server/live-call.js +216 -0
  101. package/dist-server/server/mcp-gate-config.js +43 -12
  102. package/dist-server/server/mcp-gate.js +127 -25
  103. package/dist-server/server/mcp-http.js +46 -8
  104. package/dist-server/server/mcp-remote-proxy.js +108 -0
  105. package/dist-server/server/new-bot-defaults.js +3 -0
  106. package/dist-server/server/peer-provenance.js +4 -2
  107. package/dist-server/server/permission-proxy.js +15 -1
  108. package/dist-server/server/plan-usage.js +797 -0
  109. package/dist-server/server/proxy-paths.js +1 -0
  110. package/dist-server/server/redact.js +2 -0
  111. package/dist-server/server/request-auth.js +3 -2
  112. package/dist-server/server/routes/live.js +98 -0
  113. package/dist-server/server/skill-library.js +207 -0
  114. package/dist-server/server/skills-library-migration.js +235 -0
  115. package/dist-server/server/skills.js +66 -3
  116. package/dist-server/server/steer-queue.js +13 -1
  117. package/dist-server/server/store.js +32 -4
  118. package/dist-server/server/surface.js +9 -3
  119. package/dist-server/server/team-backup.js +2 -1
  120. package/dist-server/server/thread-events.js +1 -1
  121. package/dist-server/server/tunnel.js +20 -1
  122. package/dist-server/server/turn-log.js +10 -0
  123. package/dist-server/server/turn-resources.js +124 -0
  124. package/dist-server/shared/ask-question.js +13 -0
  125. package/dist-server/shared/call-consent.js +28 -0
  126. package/dist-server/shared/live-approval.js +124 -0
  127. package/dist-server/shared/live-call.js +176 -0
  128. package/dist-server/shared/skill-md.js +10 -0
  129. package/dist-server/shared/team-backup.js +3 -0
  130. package/dist-server/shared/tool-scope-support.js +25 -0
  131. package/dist-server/shared/tool-scope.js +136 -0
  132. package/dist-server/src/lib/thread-refs.js +406 -0
  133. package/dist-server/vps-container-mcp.js +93 -76
  134. package/dist-server/workspace-backup.worker.js +89 -100
  135. package/enterprise/server/index.js +1 -0
  136. package/package.json +1 -1
  137. package/dist/assets/channel-BU94VpxE.js +0 -1
  138. package/dist/assets/index-Biji-bfL.css +0 -1
  139. package/dist/assets/index-DZlhoES0.js +0 -597
  140. package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +0 -8
@@ -0,0 +1,216 @@
1
+ // Live calls: OpenAI GPT-Live as the voice, the bot as the brain.
2
+ //
3
+ // The voice design doc (docs/voice-mode.md) rejected speech-to-speech models
4
+ // because "they replace the brain". GPT-Live's *client delegation* does not:
5
+ // the voice model only runs the conversation — listening while it speaks,
6
+ // taking interruptions, filling the wait — and hands every real request back
7
+ // to the application. Here that application is the harness, and the request
8
+ // becomes an ordinary turn on the bot the person called, with its own engine,
9
+ // tools, memory and approvals. Nothing about the bot changes.
10
+ //
11
+ // This file owns the one server-side step: exchanging the renderer's WebRTC
12
+ // offer for an answer. The OpenAI key never leaves the harness; the renderer
13
+ // only ever holds the SDP answer and the media connection it describes.
14
+ export const LIVE_MODEL = "gpt-live-1";
15
+ const OPENAI_BASE = "https://api.openai.com";
16
+ export const DEFAULT_LIVE_VOICE = "marin";
17
+ /** Largest SDP offer accepted from the renderer — a real offer is a few KB. */
18
+ export const MAX_SDP_BYTES = 64 * 1024;
19
+ /** The client's data channel is untrusted (it runs on a phone or in a
20
+ * renderer). It may only hang up; the harness sends every append over the
21
+ * sideband. Server events are limited to what captions and state need. */
22
+ export const LIVE_DATA_CHANNEL = {
23
+ allowed_client_events: ["session.close"],
24
+ allowed_server_events: [
25
+ { type: "session.started" },
26
+ { type: "session.input_transcript.delta" },
27
+ { type: "session.output_transcript.delta" },
28
+ { type: "session.closed" },
29
+ { type: "error" },
30
+ { type: "info" },
31
+ ],
32
+ };
33
+ /** Tests and fixtures point this at server/testing/fake-openai-live.ts.
34
+ * Only a loopback http URL is accepted, read at call time. */
35
+ export function liveBaseUrl(env = process.env) {
36
+ const override = env.OMB_OPENAI_LIVE_URL?.trim() ?? "";
37
+ return /^http:\/\/127\.0\.0\.1:\d{1,5}$/.test(override) ? override : OPENAI_BASE;
38
+ }
39
+ export function liveSessionsUrl(env = process.env) {
40
+ return `${liveBaseUrl(env)}/v1/live/sessions`;
41
+ }
42
+ export function liveAttachUrl(sessionId, env = process.env) {
43
+ return `${liveBaseUrl(env).replace(/^http/, "ws")}/v1/live/sessions/${encodeURIComponent(sessionId)}/attach`;
44
+ }
45
+ // Startup history is capped by the API at 128 messages / 8,192 tokens; stay
46
+ // well inside it. It is only there so "and the other one?" makes sense to
47
+ // the voice — the bot's own thread remains the source of truth.
48
+ const HISTORY_MESSAGES = 8;
49
+ const HISTORY_MESSAGE_CHARS = 600;
50
+ const HISTORY_TOTAL_CHARS = 6_000;
51
+ const CREATE_TIMEOUT_MS = 20_000;
52
+ export class LiveSessionError extends Error {
53
+ status;
54
+ constructor(message, status) {
55
+ super(message);
56
+ this.status = status;
57
+ }
58
+ }
59
+ /** Frontend instructions for the voice. Business rules stay with the bot;
60
+ * this only says who is speaking and when to hand work over. Structure
61
+ * follows the GPT-Live prompting guide's template. */
62
+ export function liveInstructions(bot) {
63
+ const name = oneLine(bot.name) || "the agent";
64
+ const title = oneLine(bot.title ?? "");
65
+ const description = oneLine(bot.description ?? "").slice(0, 400);
66
+ return [
67
+ `You are ${name}${title ? `, ${title}` : ""}, an AI agent that runs in OpenMausBot on the user's own computer.${description ? ` ${description}` : ""}`,
68
+ `To the user you are one assistant, ${name}, and you speak in the first person. Your work — looking things up, using your tools, files and memory, researching, deciding, and answering anything that needs facts this conversation does not hold — happens when you delegate. Delegating is how you think and act; it is not someone else.`,
69
+ "Never mention a backend, delegation, a voice layer, or another system or model doing the work, and never say you are only a voice. Say \"I\" about the work.",
70
+ "Never ask the user whether you may look something up or check something. When it needs checking, delegate at once and say briefly that you are checking.",
71
+ "Speak warmly and naturally, at an unhurried pace. Keep replies short: one or two sentences unless the user asks for more. Speak the language the user speaks.",
72
+ "",
73
+ "Backchannel policy: Use light backchannels. Acknowledge naturally without competing with the user.",
74
+ "Interruption policy: Stop speaking when the user interrupts. Listen to what they say.",
75
+ "Delegation policy:",
76
+ "Backend tools:",
77
+ `- ${name}: your own files, tools, memory, settings and this conversation's history. It researches, writes, changes things on the computer, and answers questions, including questions about yourself such as which AI model you run on, your settings, your tools and your memory.`,
78
+ "Delegate to the backend when:",
79
+ "- The user asks a question, asks for work, or gives an instruction.",
80
+ "- The user asks something about you that this conversation does not already answer, for example which AI model you run on.",
81
+ "- The user answers a question you asked, including yes or no to a permission request.",
82
+ "- A correction or a new detail changes work already requested.",
83
+ "Do not delegate to the backend when:",
84
+ "- You can answer from this conversation or from a result that is still current.",
85
+ "- The user only greets you, thanks you, or asks you to repeat something you already said.",
86
+ "- The user only asks whether you are still working or stuck.",
87
+ "- You cannot tell what they are asking for without a brief clarification.",
88
+ "Delegate before giving an answer that depends on backend work.",
89
+ "Do not guess the result while waiting. You may say briefly that you are on it, then wait for the update.",
90
+ "While you work you get quiet status notes: how long you have worked, how many steps you took, and your last step. Answer questions about progress from the latest note in one short sentence. Only say you are stuck when the note shows no new step for several minutes.",
91
+ "Never say that an action happened until the result reports it.",
92
+ "When a permission question comes up, ask it clearly and wait for a clear yes or no. Never answer it yourself.",
93
+ ].join("\n");
94
+ }
95
+ /** Recent thread text as startup history, newest last, inside the budget. */
96
+ export function liveInitialInput(history) {
97
+ const picked = [];
98
+ let total = 0;
99
+ for (const message of [...history].reverse()) {
100
+ if (picked.length >= HISTORY_MESSAGES)
101
+ break;
102
+ const text = message.text.replace(/\s+/g, " ").trim().slice(0, HISTORY_MESSAGE_CHARS);
103
+ if (!text)
104
+ continue;
105
+ if (total + text.length > HISTORY_TOTAL_CHARS)
106
+ break;
107
+ total += text.length;
108
+ picked.unshift({ role: message.role, text });
109
+ }
110
+ return picked.map((message) => message.role === "user"
111
+ ? { type: "message", role: "user", content: [{ type: "input_text", text: message.text }] }
112
+ : { type: "message", role: "assistant", content: [{ type: "output_text", text: message.text }] });
113
+ }
114
+ /** The configured voice name, or the default. OpenAI validates the name and
115
+ * rejects an unknown one with a clear 400 (see liveErrorMessage). */
116
+ export function liveVoice(voice) {
117
+ const name = voice?.trim().toLowerCase() ?? "";
118
+ return /^[a-z]{2,40}$/.test(name) ? name : DEFAULT_LIVE_VOICE;
119
+ }
120
+ /** POST /v1/live/sessions with client delegation. Returns the session id
121
+ * and the SDP answer. Errors carry a status and a message fit for the user —
122
+ * never the key, never OpenAI's raw body. */
123
+ export async function createLiveSession(input) {
124
+ const key = input.key.trim();
125
+ if (!key)
126
+ throw new LiveSessionError("Add an OpenAI API key to use Live calls.", 409);
127
+ if (!input.sdp.trim() || Buffer.byteLength(input.sdp) > MAX_SDP_BYTES) {
128
+ throw new LiveSessionError("The call could not start: the connection offer was invalid.", 400);
129
+ }
130
+ const initialInput = liveInitialInput(input.history);
131
+ const body = {
132
+ session: {
133
+ model: LIVE_MODEL,
134
+ instructions: liveInstructions(input.bot),
135
+ // client delegation: every request comes back to the harness, which
136
+ // runs it as a normal turn on the bot
137
+ delegation: { type: "client" },
138
+ audio: { output: { voice: liveVoice(input.voice) } },
139
+ client: { data_channel: LIVE_DATA_CHANNEL },
140
+ ...(initialInput.length ? { input: initialInput } : {}),
141
+ },
142
+ transport: { type: "webrtc", sdp: input.sdp },
143
+ };
144
+ let response;
145
+ try {
146
+ response = await (input.fetchImpl ?? fetch)(input.url ?? liveSessionsUrl(), {
147
+ method: "POST",
148
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
149
+ body: JSON.stringify(body),
150
+ signal: AbortSignal.timeout(input.timeoutMs ?? CREATE_TIMEOUT_MS),
151
+ });
152
+ }
153
+ catch (error) {
154
+ const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
155
+ throw new LiveSessionError(timedOut ? "OpenAI did not answer in time. Try the call again." : "Could not reach OpenAI. Check the internet connection.", 502);
156
+ }
157
+ if (!response.ok) {
158
+ const failure = await response.json().catch(() => null);
159
+ const param = typeof failure?.error?.param === "string" ? failure.error.param : undefined;
160
+ throw new LiveSessionError(liveErrorMessage(response.status, param), response.status === 429 ? 429 : 502);
161
+ }
162
+ const payload = await response.json().catch(() => null);
163
+ const sessionId = payload?.session?.id;
164
+ const sdp = payload?.transport?.sdp;
165
+ if (typeof sessionId !== "string" || typeof sdp !== "string" || !sdp.trim()) {
166
+ throw new LiveSessionError("OpenAI returned an unexpected answer. Try the call again.", 502);
167
+ }
168
+ return { sessionId, sdp };
169
+ }
170
+ export function liveErrorMessage(status, param) {
171
+ if (status === 400) {
172
+ return param && /voice/i.test(param)
173
+ ? "OpenAI rejected the voice for this call. Choose another voice."
174
+ : `OpenAI rejected the call settings (HTTP 400${param ? `, ${param.replace(/[^\w.]/g, "").slice(0, 60)}` : ""}).`;
175
+ }
176
+ if (status === 401)
177
+ return "OpenAI rejected the API key. Check the key for Live calls.";
178
+ if (status === 403)
179
+ return "This OpenAI project has no access to GPT-Live. Check the project's model access and billing.";
180
+ if (status === 404)
181
+ return "GPT-Live is not available for this OpenAI project.";
182
+ if (status === 429)
183
+ return "OpenAI is limiting Live sessions right now (rate limit or quota). Try again in a moment.";
184
+ if (status >= 500)
185
+ return "OpenAI had a problem starting the call. Try again in a moment.";
186
+ return `OpenAI could not start the call (HTTP ${status}).`;
187
+ }
188
+ function oneLine(value) {
189
+ return value.replace(/\s+/g, " ").trim();
190
+ }
191
+ /** One server.log line per finished Live call, from the call's counters
192
+ * (LiveCallController). Numbers and short codes only — never what anyone
193
+ * said. */
194
+ export function liveCallSummaryLine(body) {
195
+ const count = (value) => {
196
+ const n = Number(value);
197
+ return Number.isFinite(n) && n >= 0 ? Math.min(Math.round(n), 1_000_000) : 0;
198
+ };
199
+ const code = (value) => typeof value === "string" ? value.replace(/[^\w.-]/g, "").slice(0, 40) : "";
200
+ const errors = Array.isArray(body.errors) ? body.errors.slice(0, 5).map(code).filter(Boolean) : [];
201
+ return [
202
+ "[live] call ended",
203
+ `bot=${code(body.botId) || "?"}`,
204
+ `voice=${code(body.voice) || DEFAULT_LIVE_VOICE}`,
205
+ `client=${code(body.client) || "?"}`,
206
+ `seconds=${count(body.seconds)}`,
207
+ `delegations=${count(body.delegations)}`,
208
+ `sentToBot=${count(body.sentToBot)}`,
209
+ `answers=${count(body.answers)}`,
210
+ `approvals=${count(body.approvals)}`,
211
+ `notHeard=${count(body.notHeard)}`,
212
+ `replies=${count(body.replies)}`,
213
+ `end=${code(body.end) || "unknown"}`,
214
+ `errors=${errors.length ? errors.join(",") : "none"}`,
215
+ ].join(" ");
216
+ }
@@ -4,8 +4,10 @@
4
4
  // what a tool may put into a model's context is written once. The gate itself
5
5
  // is mcp-gate.ts; the policy it applies is mcp-trim.ts.
6
6
  import { join } from "node:path";
7
+ import { parseToolScope } from "../shared/tool-scope.js";
7
8
  import { DATA_DIR } from "./config.js";
8
9
  import { DEFAULT_RESULT_BUDGET } from "./mcp-trim.js";
10
+ import { remoteMcpSpec } from "./mcp-http.js";
9
11
  import { SPAWNED_PROXIES } from "./proxy-paths.js";
10
12
  /** Characters of a single tool result allowed into context, or 0 to mount
11
13
  * bot servers directly as before. `OMB_MCP_RESULT_BUDGET=0` is the escape
@@ -23,11 +25,26 @@ export function resultBudget(env = process.env) {
23
25
  export function spillDir(threadId) {
24
26
  return join(DATA_DIR, "tool-results", threadId.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 80) || "thread");
25
27
  }
28
+ /** Use the existing remote client when an engine requires a stdio descriptor. */
29
+ export function mcpStdioServer(server, options = {}) {
30
+ if (!server || typeof server !== "object" || Array.isArray(server))
31
+ return null;
32
+ const spec = server;
33
+ if (typeof spec.command === "string" && spec.command)
34
+ return spec;
35
+ const remote = remoteMcpSpec(server);
36
+ if (!remote)
37
+ return null;
38
+ return {
39
+ command: options.execPath ?? process.execPath,
40
+ args: [SPAWNED_PROXIES.mcpRemote],
41
+ env: { ...options.nodeEnv, OMB_REMOTE_MCP_SERVER: JSON.stringify(remote) },
42
+ };
43
+ }
26
44
  /** The gated form of one bot-owned server, or null to mount it unchanged.
27
45
  *
28
- * Only a stdio server can be gated: the gate stands between two processes,
29
- * and there is no process to stand between for an http/sse entry. Those are
30
- * mounted as they were — a known gap, not a silent one.
46
+ * An explicit selection also wraps remote servers in the stdio facade.
47
+ * Unrestricted remote servers keep their existing native transport.
31
48
  *
32
49
  * The upstream spec travels in the gate's `env`, which means it travels inside
33
50
  * the same 0600 MCP config file the driver already writes for exactly this
@@ -35,22 +52,36 @@ export function spillDir(threadId) {
35
52
  * to every process on the machine. */
36
53
  export function gateServer(input) {
37
54
  const { name, server, budget } = input;
38
- if (budget <= 0)
55
+ const parsed = parseToolScope(input.toolScope);
56
+ if (!parsed.ok)
57
+ throw new Error(parsed.error);
58
+ const scoped = parsed.scope !== undefined;
59
+ if (budget <= 0 && !scoped)
39
60
  return null;
40
- if (!server || typeof server !== "object" || Array.isArray(server))
61
+ if (!scoped && remoteMcpSpec(server))
41
62
  return null;
42
- const spec = server;
43
- if (typeof spec.command !== "string" || !spec.command)
63
+ const spec = mcpStdioServer(server, input);
64
+ if (!spec) {
65
+ if (scoped)
66
+ throw new Error("Tool selection requires a supported MCP server.");
44
67
  return null;
68
+ }
69
+ const env = {
70
+ OMB_GATE_NAME: name,
71
+ OMB_GATE_UPSTREAM: JSON.stringify({ command: spec.command, args: spec.args ?? [], env: spec.env ?? {} }),
72
+ OMB_GATE_SPILL_DIR: spillDir(input.threadId),
73
+ OMB_GATE_BUDGET: String(budget),
74
+ ...(scoped ? { OMB_GATE_TOOL_SCOPE: JSON.stringify(parsed.scope) } : {}),
75
+ };
76
+ if (input.configEnvName && (!scoped || !/^OMB_GATE_CONFIG_[a-f0-9]{64}$/.test(input.configEnvName))) {
77
+ throw new Error("Invalid private MCP gate configuration.");
78
+ }
45
79
  return {
46
80
  command: input.execPath ?? process.execPath,
47
- args: [SPAWNED_PROXIES.mcpGate],
81
+ args: [SPAWNED_PROXIES.mcpGate, ...(input.configEnvName ? ["--config-env", input.configEnvName] : [])],
48
82
  env: {
49
83
  ...input.nodeEnv,
50
- OMB_GATE_NAME: name,
51
- OMB_GATE_UPSTREAM: JSON.stringify({ command: spec.command, args: spec.args ?? [], env: spec.env ?? {} }),
52
- OMB_GATE_SPILL_DIR: spillDir(input.threadId),
53
- OMB_GATE_BUDGET: String(budget),
84
+ ...(input.configEnvName ? { [input.configEnvName]: JSON.stringify(env) } : env),
54
85
  },
55
86
  };
56
87
  }
@@ -2,8 +2,9 @@
2
2
  // conversation.
3
3
  //
4
4
  // The provider CLI mounts this instead of the bot's real MCP server. Every
5
- // JSON-RPC frame is relayed in both directions untouched, except the response
6
- // to a `tools/call`: an oversized result is cut to a budget (mcp-trim.ts), the
5
+ // JSON-RPC frame is relayed in both directions. With a tool selection, discovery
6
+ // is filtered and excluded calls are rejected before upstream execution.
7
+ // An oversized `tools/call` result is cut to a budget (mcp-trim.ts), the
7
8
  // untrimmed text is written to a file, and the model is told in the result
8
9
  // where that file is so it can read or grep the rest with its ordinary tools.
9
10
  //
@@ -18,29 +19,81 @@ import { spawn } from "node:child_process";
18
19
  import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
19
20
  import { join } from "node:path";
20
21
  import { createInterface } from "node:readline";
22
+ import { allowsTool, parseToolScope } from "../shared/tool-scope.js";
21
23
  import { resolveCliSpawn } from "./env-path.js";
22
24
  import { DEFAULT_RESULT_BUDGET, trimResultText, trimStructured } from "./mcp-trim.js";
23
25
  import { killCliTree } from "./procs.js";
24
- const NAME = process.env.OMB_GATE_NAME || "mcp";
25
- const SPILL_DIR = process.env.OMB_GATE_SPILL_DIR || "";
26
- const BUDGET = Number(process.env.OMB_GATE_BUDGET) > 0 ? Number(process.env.OMB_GATE_BUDGET) : DEFAULT_RESULT_BUDGET;
26
+ /** Codex imports env variables from one shared app-server environment. Each
27
+ * mount names its own private record; only the variable name reaches argv. */
28
+ function gateEnvironment() {
29
+ if (process.argv.length === 2)
30
+ return process.env;
31
+ try {
32
+ const key = process.argv[3];
33
+ if (process.argv.length !== 4 || process.argv[2] !== "--config-env" || !key || !/^OMB_GATE_CONFIG_[a-f0-9]{64}$/.test(key))
34
+ throw new Error();
35
+ const value = JSON.parse(process.env[key] ?? "");
36
+ if (!value || typeof value !== "object" || Array.isArray(value)
37
+ || !["OMB_GATE_NAME", "OMB_GATE_UPSTREAM", "OMB_GATE_TOOL_SCOPE"].every(name => typeof value[name] === "string")
38
+ || !Object.entries(value).every(([name, setting]) => GATE_ENV_KEYS.includes(name) && typeof setting === "string"))
39
+ throw new Error();
40
+ return value;
41
+ }
42
+ catch {
43
+ process.stderr.write("mcp-gate: invalid private configuration\n");
44
+ process.exit(1);
45
+ }
46
+ }
47
+ /** The gate's own settings never reach the upstream server's environment. */
48
+ const GATE_ENV_KEYS = ["OMB_GATE_NAME", "OMB_GATE_SPILL_DIR", "OMB_GATE_BUDGET", "OMB_GATE_UPSTREAM", "OMB_GATE_SPILL_HINT", "OMB_GATE_TOOL_SCOPE"];
49
+ const gateEnv = gateEnvironment();
50
+ const NAME = gateEnv.OMB_GATE_NAME || "mcp";
51
+ const SPILL_DIR = gateEnv.OMB_GATE_SPILL_DIR || "";
52
+ const rawBudget = Number(gateEnv.OMB_GATE_BUDGET);
53
+ const BUDGET = Number.isFinite(rawBudget) && rawBudget >= 0 ? rawBudget : DEFAULT_RESULT_BUDGET;
27
54
  /** Spilled results older than this are swept at startup: they exist for the
28
55
  * turn that produced them, not forever. */
29
56
  const SPILL_MAX_AGE_MS = 24 * 60 * 60_000;
30
57
  /** Whether the model is told where the untrimmed result was saved. Off by
31
58
  * default: offering the path measured WORSE than no trimming, because the
32
59
  * model reads the file back in. See TrimInput.spillHint. */
33
- const SPILL_HINT = process.env.OMB_GATE_SPILL_HINT === "1";
34
- /** The gate's own settings never reach the upstream server's environment. */
35
- const GATE_ENV_KEYS = ["OMB_GATE_NAME", "OMB_GATE_SPILL_DIR", "OMB_GATE_BUDGET", "OMB_GATE_UPSTREAM", "OMB_GATE_SPILL_HINT"];
60
+ const SPILL_HINT = gateEnv.OMB_GATE_SPILL_HINT === "1";
36
61
  function fail(message) {
37
62
  process.stderr.write(`mcp-gate(${NAME}): ${message}\n`);
38
63
  process.exit(1);
39
64
  }
65
+ function toolScope() {
66
+ const raw = gateEnv.OMB_GATE_TOOL_SCOPE;
67
+ if (raw === undefined)
68
+ return;
69
+ let value;
70
+ try {
71
+ value = JSON.parse(raw);
72
+ }
73
+ catch {
74
+ fail("invalid tool selection JSON");
75
+ }
76
+ const parsed = parseToolScope(value);
77
+ if (!parsed.ok || parsed.scope === undefined)
78
+ fail("invalid tool selection; check the bot's Access settings");
79
+ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(NAME))
80
+ fail("invalid MCP server identity for tool selection");
81
+ return parsed.scope;
82
+ }
83
+ function isRecord(value) {
84
+ return !!value && typeof value === "object" && !Array.isArray(value);
85
+ }
86
+ function requestKey(id) {
87
+ return typeof id === "string" || (typeof id === "number" && Number.isFinite(id))
88
+ ? `${typeof id}:${id}` : undefined;
89
+ }
90
+ function rejectRequest(id, code, message) {
91
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: requestKey(id) ? id : null, error: { code, message } })}\n`);
92
+ }
40
93
  function upstreamSpec() {
41
94
  let parsed;
42
95
  try {
43
- parsed = JSON.parse(process.env.OMB_GATE_UPSTREAM ?? "");
96
+ parsed = JSON.parse(gateEnv.OMB_GATE_UPSTREAM ?? "");
44
97
  }
45
98
  catch {
46
99
  fail("OMB_GATE_UPSTREAM is not valid JSON");
@@ -95,6 +148,8 @@ function spill(tool, text) {
95
148
  /** Rewrite one `tools/call` result in place. Returns true when anything was
96
149
  * actually trimmed, so the caller can report it on stderr. */
97
150
  function trimCallResult(result, tool) {
151
+ if (BUDGET === 0)
152
+ return false;
98
153
  const content = result.content;
99
154
  if (!Array.isArray(content))
100
155
  return false;
@@ -126,12 +181,15 @@ function trimCallResult(result, tool) {
126
181
  }
127
182
  return trimmed;
128
183
  }
184
+ // Validate before starting a process: corrupt policy must never become pass-through.
185
+ const scope = toolScope();
129
186
  const spec = upstreamSpec();
130
187
  if (SPILL_DIR)
131
188
  sweepSpill(SPILL_DIR);
132
189
  const childEnv = { ...process.env, ...spec.env };
133
- for (const key of GATE_ENV_KEYS)
134
- delete childEnv[key];
190
+ for (const key of Object.keys(childEnv))
191
+ if (key.startsWith("OMB_GATE_"))
192
+ delete childEnv[key];
135
193
  // The CLI used to spawn this server itself, on every platform, so the gate
136
194
  // has to spawn it exactly as well. On Windows CreateProcess cannot exec an
137
195
  // npm .cmd shim or a node-shebang script, which is what `npx -y mcp-remote`
@@ -170,20 +228,46 @@ child.on("error", (error) => {
170
228
  process.exit(1);
171
229
  });
172
230
  child.stderr.pipe(process.stderr);
173
- /** id -> tool name, for the calls whose answers are still in flight. */
231
+ /** JSON-RPC string and numeric IDs are separate, even when their text is equal. */
174
232
  const pending = new Map();
175
233
  // client -> server: verbatim, but remember which ids are tool calls
176
234
  createInterface({ input: process.stdin }).on("line", (line) => {
235
+ let message;
177
236
  if (line.trim()) {
178
237
  try {
179
- const message = JSON.parse(line);
180
- if (message.method === "tools/call" && message.id !== undefined && message.id !== null) {
181
- const params = message.params;
182
- pending.set(String(message.id), typeof params?.name === "string" ? params.name : "tool");
183
- }
238
+ const parsed = JSON.parse(line);
239
+ if (isRecord(parsed))
240
+ message = parsed;
184
241
  }
185
242
  catch {
186
- /* not our business to validate the client's frames */
243
+ if (scope)
244
+ return rejectRequest(null, -32700, "Invalid JSON-RPC frame");
245
+ }
246
+ if (scope && (!message || message.jsonrpc !== "2.0"))
247
+ return rejectRequest(message?.id, -32600, "Invalid JSON-RPC frame");
248
+ }
249
+ const id = requestKey(message?.id);
250
+ const params = isRecord(message?.params) ? message.params : undefined;
251
+ if (scope && message?.method === "tools/call") {
252
+ if (!id || typeof params?.name !== "string"
253
+ || (params.arguments !== undefined && !isRecord(params.arguments))) {
254
+ return rejectRequest(message.id, -32602, "Invalid tool call");
255
+ }
256
+ if (!allowsTool(scope, { kind: "mcp", server: NAME, name: params.name })) {
257
+ return rejectRequest(message.id, -32602, "Tool selection excludes this tool. Check the bot's Access settings.");
258
+ }
259
+ }
260
+ if (scope && message?.method === "tools/list" && !id) {
261
+ return rejectRequest(message.id, -32600, "Tool listing needs a request ID");
262
+ }
263
+ if (id && typeof message?.method === "string") {
264
+ if (scope && pending.has(id))
265
+ fail("duplicate request ID on a restricted connection");
266
+ if (message.method === "tools/call") {
267
+ pending.set(id, { kind: "call", tool: typeof params?.name === "string" ? params.name : "tool" });
268
+ }
269
+ else if (scope) {
270
+ pending.set(id, { kind: message.method === "tools/list" ? "list" : "other" });
187
271
  }
188
272
  }
189
273
  child.stdin.write(`${line}\n`);
@@ -195,29 +279,47 @@ createInterface({ input: child.stdout }).on("line", (line) => {
195
279
  return;
196
280
  let message;
197
281
  try {
198
- message = JSON.parse(line);
282
+ const parsed = JSON.parse(line);
283
+ if (isRecord(parsed))
284
+ message = parsed;
199
285
  }
200
286
  catch {
201
287
  // Not JSON the gate understands. Relay it exactly as it came: a frame the
202
288
  // gate cannot read is still the upstream server's answer to give.
289
+ if (scope)
290
+ fail("unreadable upstream frame on a restricted connection");
291
+ }
292
+ if (!message || (scope && message.jsonrpc !== "2.0")) {
293
+ if (scope)
294
+ fail("invalid upstream frame on a restricted connection");
203
295
  process.stdout.write(`${line}\n`);
204
296
  return;
205
297
  }
206
- const id = message.id === undefined || message.id === null ? undefined : String(message.id);
207
- const tool = id === undefined ? undefined : pending.get(id);
298
+ const id = typeof message.method === "string" ? undefined : requestKey(message.id);
299
+ const request = id === undefined ? undefined : pending.get(id);
300
+ if (scope && id !== undefined && !request)
301
+ fail("unrequested upstream response on a restricted connection");
208
302
  if (id !== undefined)
209
303
  pending.delete(id);
210
304
  const result = message.result;
211
- if (tool && result && typeof result === "object" && !Array.isArray(result)) {
305
+ if (scope && request?.kind === "list" && message.error === undefined) {
306
+ if (!isRecord(result) || !Array.isArray(result.tools)
307
+ || !result.tools.every((tool) => isRecord(tool) && typeof tool.name === "string" && tool.name.trim().length > 0)
308
+ || (result.nextCursor !== undefined && typeof result.nextCursor !== "string")) {
309
+ return rejectRequest(message.id, -32603, "Invalid tool catalog on a restricted connection");
310
+ }
311
+ result.tools = result.tools.filter((tool) => allowsTool(scope, { kind: "mcp", server: NAME, name: tool.name }));
312
+ }
313
+ if (request?.kind === "call" && isRecord(result)) {
212
314
  try {
213
- if (trimCallResult(result, tool)) {
214
- process.stderr.write(`mcp-gate(${NAME}): trimmed ${tool} to ${BUDGET} chars\n`);
315
+ if (trimCallResult(result, request.tool)) {
316
+ process.stderr.write(`mcp-gate(${NAME}): trimmed ${request.tool} to ${BUDGET} chars\n`);
215
317
  }
216
318
  }
217
319
  catch (error) {
218
320
  // A result the trimmer chokes on is relayed whole. Costing context is
219
321
  // recoverable; dropping a tool answer is not.
220
- process.stderr.write(`mcp-gate(${NAME}): could not trim ${tool}: ${String(error)}\n`);
322
+ process.stderr.write(`mcp-gate(${NAME}): could not trim ${request.tool}: ${String(error)}\n`);
221
323
  process.stdout.write(`${line}\n`);
222
324
  return;
223
325
  }