open-managed-agents 0.1.0

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 (197) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +248 -0
  3. package/bin/oma.mjs +499 -0
  4. package/dist/scripts/alpha-openai-compatible-fixture.mjs +134 -0
  5. package/dist/scripts/alpha-smoke-models.mjs +92 -0
  6. package/dist/scripts/alpha-smoke.mjs +581 -0
  7. package/dist/scripts/oma-doctor.js +230 -0
  8. package/dist/scripts/oma-models.js +299 -0
  9. package/dist/scripts/oma-workspaces.js +133 -0
  10. package/dist/src/control-plane/admin/auth.js +43 -0
  11. package/dist/src/control-plane/admin/routes.js +76 -0
  12. package/dist/src/control-plane/admin/service.js +102 -0
  13. package/dist/src/control-plane/admission.js +90 -0
  14. package/dist/src/control-plane/agents/routes.js +63 -0
  15. package/dist/src/control-plane/agents/service.js +687 -0
  16. package/dist/src/control-plane/agents/store.js +391 -0
  17. package/dist/src/control-plane/agents/types.js +1 -0
  18. package/dist/src/control-plane/api-constants.js +3 -0
  19. package/dist/src/control-plane/app.js +907 -0
  20. package/dist/src/control-plane/console/auth.js +68 -0
  21. package/dist/src/control-plane/console/static.js +112 -0
  22. package/dist/src/control-plane/deployment-runtime-config.js +328 -0
  23. package/dist/src/control-plane/deployment-runtime-event-coordinator.js +25 -0
  24. package/dist/src/control-plane/deployment-runtime-turn-guard.js +14 -0
  25. package/dist/src/control-plane/deployment-session-coordinator.js +25 -0
  26. package/dist/src/control-plane/deployment-session-output-coordinator.js +27 -0
  27. package/dist/src/control-plane/deployment-storage.js +354 -0
  28. package/dist/src/control-plane/egress/image.js +48 -0
  29. package/dist/src/control-plane/egress/policy.js +571 -0
  30. package/dist/src/control-plane/egress/presets.js +77 -0
  31. package/dist/src/control-plane/egress/proxy.js +72 -0
  32. package/dist/src/control-plane/egress/ssrf.js +106 -0
  33. package/dist/src/control-plane/egress/utils/debug.js +14 -0
  34. package/dist/src/control-plane/egress/vendor/http-proxy.js +335 -0
  35. package/dist/src/control-plane/egress/vendor/mitm-ca.js +226 -0
  36. package/dist/src/control-plane/egress/vendor/mitm-leaf.js +124 -0
  37. package/dist/src/control-plane/egress/vendor/parent-proxy.js +438 -0
  38. package/dist/src/control-plane/egress/vendor/request-filter.js +105 -0
  39. package/dist/src/control-plane/egress/vendor/sandbox-config.js +1 -0
  40. package/dist/src/control-plane/egress/vendor/tls-terminate-proxy.js +269 -0
  41. package/dist/src/control-plane/environments/routes.js +54 -0
  42. package/dist/src/control-plane/environments/service.js +112 -0
  43. package/dist/src/control-plane/environments/store.js +132 -0
  44. package/dist/src/control-plane/environments/types.js +1 -0
  45. package/dist/src/control-plane/errors.js +81 -0
  46. package/dist/src/control-plane/events/broadcaster.js +217 -0
  47. package/dist/src/control-plane/events/constants.js +2 -0
  48. package/dist/src/control-plane/events/persist.js +39 -0
  49. package/dist/src/control-plane/events/request.js +190 -0
  50. package/dist/src/control-plane/events/routes.js +159 -0
  51. package/dist/src/control-plane/events/runtime-helpers.js +134 -0
  52. package/dist/src/control-plane/events/service.js +1985 -0
  53. package/dist/src/control-plane/events/session-guards.js +29 -0
  54. package/dist/src/control-plane/events/sse.js +6 -0
  55. package/dist/src/control-plane/events/store.js +772 -0
  56. package/dist/src/control-plane/events/tool-persistence.js +102 -0
  57. package/dist/src/control-plane/events/types.js +21 -0
  58. package/dist/src/control-plane/files/routes.js +121 -0
  59. package/dist/src/control-plane/files/service.js +65 -0
  60. package/dist/src/control-plane/files/store-common.js +87 -0
  61. package/dist/src/control-plane/files/store-local.js +488 -0
  62. package/dist/src/control-plane/files/store-memory.js +270 -0
  63. package/dist/src/control-plane/files/store.js +2 -0
  64. package/dist/src/control-plane/files/types.js +7 -0
  65. package/dist/src/control-plane/http.js +25 -0
  66. package/dist/src/control-plane/ids.js +43 -0
  67. package/dist/src/control-plane/logging.js +263 -0
  68. package/dist/src/control-plane/models/auth-storage-backend.js +238 -0
  69. package/dist/src/control-plane/models/catalog.js +78 -0
  70. package/dist/src/control-plane/models/config-security.js +376 -0
  71. package/dist/src/control-plane/models/deployment-config.js +93 -0
  72. package/dist/src/control-plane/models/routes.js +32 -0
  73. package/dist/src/control-plane/models/service.js +138 -0
  74. package/dist/src/control-plane/observability/instruments.js +65 -0
  75. package/dist/src/control-plane/observability/metrics.js +125 -0
  76. package/dist/src/control-plane/observability/routes.js +95 -0
  77. package/dist/src/control-plane/openapi/document.js +282 -0
  78. package/dist/src/control-plane/openapi/routes.js +78 -0
  79. package/dist/src/control-plane/request-idempotency.js +85 -0
  80. package/dist/src/control-plane/secrets/envelope.js +132 -0
  81. package/dist/src/control-plane/secrets/master-key.js +44 -0
  82. package/dist/src/control-plane/secrets/routes.js +20 -0
  83. package/dist/src/control-plane/secrets/service.js +71 -0
  84. package/dist/src/control-plane/secrets/store.js +212 -0
  85. package/dist/src/control-plane/secrets/types.js +3 -0
  86. package/dist/src/control-plane/sessions/pi/custom-tools.js +146 -0
  87. package/dist/src/control-plane/sessions/pi/mcp/bridge.js +527 -0
  88. package/dist/src/control-plane/sessions/pi/mcp/client.js +157 -0
  89. package/dist/src/control-plane/sessions/pi/mcp/credential.js +28 -0
  90. package/dist/src/control-plane/sessions/pi/mcp/fetch.js +69 -0
  91. package/dist/src/control-plane/sessions/pi/mcp/probe.js +202 -0
  92. package/dist/src/control-plane/sessions/pi/mcp/runtime.js +13 -0
  93. package/dist/src/control-plane/sessions/pi/runner.js +1104 -0
  94. package/dist/src/control-plane/sessions/pi/sandbox/cma-glob.js +388 -0
  95. package/dist/src/control-plane/sessions/pi/sandbox/cma-grep.js +124 -0
  96. package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +347 -0
  97. package/dist/src/control-plane/sessions/pi/sandbox/docker.js +1589 -0
  98. package/dist/src/control-plane/sessions/pi/sandbox/glob.js +48 -0
  99. package/dist/src/control-plane/sessions/pi/sandbox/image.js +7 -0
  100. package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +1677 -0
  101. package/dist/src/control-plane/sessions/pi/sandbox/provider.js +561 -0
  102. package/dist/src/control-plane/sessions/pi/sandbox/selection.js +168 -0
  103. package/dist/src/control-plane/sessions/pi/span-normalizer.js +82 -0
  104. package/dist/src/control-plane/sessions/pi/tool-permissions.js +392 -0
  105. package/dist/src/control-plane/sessions/pi/translator.js +100 -0
  106. package/dist/src/control-plane/sessions/request.js +164 -0
  107. package/dist/src/control-plane/sessions/resources.js +63 -0
  108. package/dist/src/control-plane/sessions/routes.js +88 -0
  109. package/dist/src/control-plane/sessions/serialize.js +24 -0
  110. package/dist/src/control-plane/sessions/service.js +733 -0
  111. package/dist/src/control-plane/sessions/store.js +538 -0
  112. package/dist/src/control-plane/sessions/types.js +1 -0
  113. package/dist/src/control-plane/skills/archive.js +169 -0
  114. package/dist/src/control-plane/skills/routes.js +41 -0
  115. package/dist/src/control-plane/skills/service.js +29 -0
  116. package/dist/src/control-plane/skills/store.js +233 -0
  117. package/dist/src/control-plane/skills/types.js +5 -0
  118. package/dist/src/control-plane/sqlite-transaction.js +15 -0
  119. package/dist/src/control-plane/vaults/mcp-oauth-validate.js +165 -0
  120. package/dist/src/control-plane/vaults/oauth-refresh-ticker.js +43 -0
  121. package/dist/src/control-plane/vaults/oauth-refresh.js +448 -0
  122. package/dist/src/control-plane/vaults/routes.js +66 -0
  123. package/dist/src/control-plane/vaults/service.js +553 -0
  124. package/dist/src/control-plane/vaults/store.js +691 -0
  125. package/dist/src/control-plane/vaults/types.js +1 -0
  126. package/dist/src/control-plane/wake-loop.js +65 -0
  127. package/dist/src/control-plane/wiring.js +92 -0
  128. package/dist/src/control-plane/workspace.js +4 -0
  129. package/dist/src/control-plane/workspaces/store.js +158 -0
  130. package/dist/src/egress-proxy-main.js +103 -0
  131. package/dist/src/main.js +124 -0
  132. package/dist/src/types/agents.js +1 -0
  133. package/dist/src/types/common.js +1 -0
  134. package/dist/src/types/environments.js +1 -0
  135. package/dist/src/types/events.js +121 -0
  136. package/dist/src/types/files.js +1 -0
  137. package/dist/src/types/json.js +19 -0
  138. package/dist/src/types/sessions.js +1 -0
  139. package/dist/ui/managed-agents-console/README.md +52 -0
  140. package/dist/ui/managed-agents-console/docs/agents.md +23 -0
  141. package/dist/ui/managed-agents-console/docs/console.md +23 -0
  142. package/dist/ui/managed-agents-console/docs/dreams.md +5 -0
  143. package/dist/ui/managed-agents-console/docs/environments.md +27 -0
  144. package/dist/ui/managed-agents-console/docs/events.md +19 -0
  145. package/dist/ui/managed-agents-console/docs/files.md +17 -0
  146. package/dist/ui/managed-agents-console/docs/github.md +11 -0
  147. package/dist/ui/managed-agents-console/docs/integrations.md +19 -0
  148. package/dist/ui/managed-agents-console/docs/memory.md +11 -0
  149. package/dist/ui/managed-agents-console/docs/migration.md +17 -0
  150. package/dist/ui/managed-agents-console/docs/multiagent.md +11 -0
  151. package/dist/ui/managed-agents-console/docs/outcomes.md +11 -0
  152. package/dist/ui/managed-agents-console/docs/overview.md +41 -0
  153. package/dist/ui/managed-agents-console/docs/permissions.md +24 -0
  154. package/dist/ui/managed-agents-console/docs/quickstart.md +51 -0
  155. package/dist/ui/managed-agents-console/docs/reference.md +27 -0
  156. package/dist/ui/managed-agents-console/docs/sandbox-reference.md +19 -0
  157. package/dist/ui/managed-agents-console/docs/sandbox-security.md +15 -0
  158. package/dist/ui/managed-agents-console/docs/scheduled-deployments.md +5 -0
  159. package/dist/ui/managed-agents-console/docs/self-hosted-sandboxes.md +15 -0
  160. package/dist/ui/managed-agents-console/docs/session-operations.md +21 -0
  161. package/dist/ui/managed-agents-console/docs/sessions.md +21 -0
  162. package/dist/ui/managed-agents-console/docs/skills.md +15 -0
  163. package/dist/ui/managed-agents-console/docs/tools.md +23 -0
  164. package/dist/ui/managed-agents-console/docs/vaults.md +19 -0
  165. package/dist/ui/managed-agents-console/docs/webhooks.md +11 -0
  166. package/dist/ui/managed-agents-console/index.html +47 -0
  167. package/dist/ui/managed-agents-console/serve.mjs +116 -0
  168. package/dist/ui/managed-agents-console/src/agents-files.jsx +273 -0
  169. package/dist/ui/managed-agents-console/src/api.js +1043 -0
  170. package/dist/ui/managed-agents-console/src/app.jsx +633 -0
  171. package/dist/ui/managed-agents-console/src/auth.jsx +215 -0
  172. package/dist/ui/managed-agents-console/src/console.css +733 -0
  173. package/dist/ui/managed-agents-console/src/data.js +188 -0
  174. package/dist/ui/managed-agents-console/src/detail.jsx +719 -0
  175. package/dist/ui/managed-agents-console/src/docs.jsx +200 -0
  176. package/dist/ui/managed-agents-console/src/environments.jsx +365 -0
  177. package/dist/ui/managed-agents-console/src/forms.jsx +494 -0
  178. package/dist/ui/managed-agents-console/src/icons.jsx +48 -0
  179. package/dist/ui/managed-agents-console/src/skills.jsx +21 -0
  180. package/dist/ui/managed-agents-console/src/sse.js +185 -0
  181. package/dist/ui/managed-agents-console/src/states.jsx +90 -0
  182. package/dist/ui/managed-agents-console/src/tweaks-panel.jsx +541 -0
  183. package/dist/ui/managed-agents-console/src/ui.jsx +190 -0
  184. package/dist/ui/managed-agents-console/src/vaults-data.js +89 -0
  185. package/dist/ui/managed-agents-console/src/vaults.jsx +171 -0
  186. package/dist/ui/managed-agents-console/vendor/babel.min.js +4 -0
  187. package/dist/ui/managed-agents-console/vendor/react-dom.production.min.js +267 -0
  188. package/dist/ui/managed-agents-console/vendor/react.production.min.js +31 -0
  189. package/dist/ui/openapi-docs/VENDOR.md +27 -0
  190. package/dist/ui/openapi-docs/index.html +16 -0
  191. package/dist/ui/openapi-docs/swagger-initializer.js +14 -0
  192. package/dist/ui/openapi-docs/vendor/LICENSE +202 -0
  193. package/dist/ui/openapi-docs/vendor/NOTICE +2 -0
  194. package/dist/ui/openapi-docs/vendor/swagger-ui-bundle.js +2 -0
  195. package/dist/ui/openapi-docs/vendor/swagger-ui-standalone-preset.js +2 -0
  196. package/dist/ui/openapi-docs/vendor/swagger-ui.css +3 -0
  197. package/package.json +53 -0
@@ -0,0 +1,527 @@
1
+ // MCP tool bridge (plan 0122 §4.4) — modeled on the tool-permission path:
2
+ // the `sevt_*` id is bound BEFORE the tool executes (via
3
+ // PiToolPermissionBridge.publishMcpToolUse, which also owns confirmations),
4
+ // and every persisted `agent.mcp_tool_use` gets exactly one terminal
5
+ // `agent.mcp_tool_result` — success, in-band error, deny, confirmation
6
+ // timeout, abort, call timeout, or transport failure.
7
+ //
8
+ // The custom-tool bridge contributes only the `defineTool` registration
9
+ // shape and text-block content mapping; its resolve-on-user-round-trip event
10
+ // pattern is NOT the MCP model (review finding, plan §9 cluster A).
11
+ import { defineTool } from "@earendil-works/pi-coding-agent";
12
+ import { scrubKnownSecrets } from "../../../logging.js";
13
+ import { isMcpAuthError } from "./client.js";
14
+ import { getMcpAuthSnapshot, } from "./credential.js";
15
+ export const DEFAULT_MCP_OUTPUT_CAP_BYTES = 400 * 1024;
16
+ // Upstream default for MCP toolsets is always_ask (mcp-connector.md Tip) —
17
+ // unlike the builtin toolset's always_allow.
18
+ const DEFAULT_MCP_ACCESS = { enabled: true, permission: "ask" };
19
+ /**
20
+ * The model-visible Pi name. A convention, not a guarantee — collisions
21
+ * (with custom tools, or `a`+`b__c` vs `a__b`+`c`) are rejected at session
22
+ * build (runner collision assert), never silently shadowed.
23
+ */
24
+ export function mcpPiToolName(serverName, toolName) {
25
+ return `mcp__${serverName}__${toolName}`;
26
+ }
27
+ /**
28
+ * Build Pi ToolDefinitions for one connected server's discovered tools,
29
+ * filtered by the mcp_toolset config. Registration-disabled tools are absent
30
+ * from Pi's surface entirely.
31
+ */
32
+ export function createMcpToolDefinitions(opts) {
33
+ const capBytes = opts.outputCapBytes ?? DEFAULT_MCP_OUTPUT_CAP_BYTES;
34
+ const out = [];
35
+ for (const tool of opts.connection.tools) {
36
+ // Discovery output is server-controlled too (review #170, Opus): the
37
+ // bearer rides the listTools request, so a hostile server can echo the
38
+ // credential into tool metadata that reaches the model WITHOUT any
39
+ // call. Descriptions are free text → scrubbed. A NAME carrying the
40
+ // credential cannot be scrubbed without breaking call routing — it is
41
+ // an exfil attempt, not a tool: refuse to register it.
42
+ if (scrub(tool.name, knownSecrets(opts)) !== tool.name)
43
+ continue;
44
+ const access = opts.access?.(opts.workspaceId, opts.sessionId, opts.connection.serverName, tool.name, opts.agentContext) ?? DEFAULT_MCP_ACCESS;
45
+ if (!access.enabled)
46
+ continue;
47
+ const piName = mcpPiToolName(opts.connection.serverName, tool.name);
48
+ out.push({
49
+ ...defineTool({
50
+ name: piName,
51
+ label: piName,
52
+ description: scrub(tool.description ?? tool.name, knownSecrets(opts)),
53
+ // Arbitrary third-party JSON Schema; Pi validates with TypeBox.
54
+ // Servers re-validate in-band anyway (probe 46), so a permissive
55
+ // fallback at execute time keeps a hostile schema from wedging the
56
+ // call — see executeMcpTool.
57
+ parameters: tool.inputSchema,
58
+ execute: async (piToolCallId, params, signal) => executeMcpTool({
59
+ opts,
60
+ bareName: tool.name,
61
+ permission: access.permission,
62
+ piToolCallId,
63
+ params,
64
+ signal,
65
+ capBytes,
66
+ }),
67
+ }),
68
+ executionMode: "parallel",
69
+ });
70
+ }
71
+ return out;
72
+ }
73
+ async function executeMcpTool(args) {
74
+ const { opts, bareName, permission, piToolCallId, signal, capBytes } = args;
75
+ const input = isRecord(args.params) ? args.params : {};
76
+ // 1+2: emit oma.mcp_tool_use; the sevt_* id binds before execution and the
77
+ // ask-path registers in the shared pending-confirmation store.
78
+ const materialized = await opts.permissionBridge.publishMcpToolUse({
79
+ workspaceId: opts.workspaceId,
80
+ sessionId: opts.sessionId,
81
+ mcpServerName: opts.connection.serverName,
82
+ toolName: bareName,
83
+ piToolCallId,
84
+ input,
85
+ permission,
86
+ signal,
87
+ getEmitter: opts.getEmitter,
88
+ });
89
+ // Terminal-result rule caveat: if the handle was already evicted (runtime
90
+ // loss mid-flight), getEmitter() is undefined and the result cannot be
91
+ // persisted — same recovery contract as builtin tools (the lost-runtime
92
+ // terminalization path synthesizes agent.mcp_tool_result later).
93
+ const emitResult = (content, isError) => {
94
+ opts.getEmitter()?.({
95
+ type: "oma.mcp_tool_result",
96
+ mcpToolUseId: materialized.toolUseId,
97
+ content,
98
+ isError,
99
+ });
100
+ };
101
+ const failWith = (message, outcome) => {
102
+ emitResult([{ type: "text", text: message }], true);
103
+ opts.onToolCall?.(outcome);
104
+ throw new Error(message);
105
+ };
106
+ // 3: permission gate.
107
+ if (permission === "deny") {
108
+ failWith(`MCP tool ${bareName} is denied by policy`, "denied");
109
+ }
110
+ if (permission === "ask") {
111
+ if (!materialized.confirmation) {
112
+ failWith(`MCP tool ${bareName} confirmation was not registered`, "error");
113
+ }
114
+ let confirmation;
115
+ try {
116
+ confirmation = await materialized.confirmation;
117
+ }
118
+ catch (error) {
119
+ const timedOut = error.omaConfirmationTimeout ===
120
+ true;
121
+ failWith(toError(error).message, timedOut ? "timeout" : "error");
122
+ throw error; // unreachable; failWith throws
123
+ }
124
+ if (confirmation.result === "deny") {
125
+ failWith(confirmation.denyMessage ?? `MCP tool ${bareName} was denied`, "denied");
126
+ }
127
+ }
128
+ // 4: the call. In-band failures resolve with isError (probe 46); a
129
+ // rejection here is transport-class and also marks the connection failed —
130
+ // UNLESS the Pi signal aborted (user interrupt): that is not a server
131
+ // failure and must not tear down the warm handle or hit the retry budget
132
+ // (review 0122-M1, Opus finding 2).
133
+ let outcome;
134
+ try {
135
+ outcome = await opts.connection.callTool(bareName, input, signal);
136
+ }
137
+ catch (error) {
138
+ let err = toError(error);
139
+ const rejected = getMcpAuthSnapshot(err);
140
+ if (rejected?.identity.authType === "mcp_oauth" &&
141
+ opts.connection.credential !== undefined &&
142
+ isMcpAuthError(err)) {
143
+ let refreshed;
144
+ try {
145
+ refreshed = await opts.connection.credential.forceRefresh(rejected);
146
+ }
147
+ catch {
148
+ refreshed = { status: "failed", reason: "refresh_adapter_error" };
149
+ }
150
+ if (refreshed.status === "ready") {
151
+ if (signal?.aborted === true) {
152
+ err = Object.assign(new Error(`MCP tool ${bareName} aborted`), {
153
+ name: "AbortError",
154
+ });
155
+ }
156
+ else {
157
+ try {
158
+ outcome = await opts.connection.callTool(bareName, input, signal);
159
+ }
160
+ catch (retryError) {
161
+ err = toError(retryError);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ if (outcome !== undefined) {
167
+ // Continue through the normal success/in-band-error path below. The
168
+ // first auth rejection is deliberately invisible to failure accounting.
169
+ }
170
+ else {
171
+ const aborted = signal?.aborted === true || err.name === "AbortError";
172
+ if (!aborted) {
173
+ opts.onTransportFailure?.(opts.connection.serverName, err);
174
+ }
175
+ const timedOut = err.code === -32001; // McpError RequestTimeout
176
+ // The SDK embeds server response bodies in rejection messages (probe
177
+ // 49's captured message contains the fixture's 401 body verbatim), so
178
+ // this string is server-controlled: scrub before it persists/throws.
179
+ failWith(scrub(`MCP tool ${bareName} failed: ${err.message}`, knownSecrets(opts)), aborted ? "aborted" : timedOut ? "timeout" : "error");
180
+ throw err; // unreachable
181
+ }
182
+ }
183
+ // 5: terminal result — scrubbed (server-controlled content) then capped
184
+ // over ALL normalized blocks (review C). Scrub BEFORE the cap: truncation
185
+ // could split a token and leave an unmatchable prefix behind.
186
+ const blocks = capContent(scrubContentBlocks(normalizeMcpContent(outcome.content), knownSecrets(opts)), capBytes);
187
+ emitResult(blocks, outcome.isError);
188
+ if (outcome.isError) {
189
+ opts.onToolCall?.("error");
190
+ throw new Error(textOf(blocks) || `MCP tool ${bareName} returned an error`);
191
+ }
192
+ opts.onToolCall?.("ok");
193
+ // 6: model-facing return — same text-block contract as custom tools.
194
+ return {
195
+ content: blocks.map((block) => block.type === "text" && typeof block.text === "string"
196
+ ? { type: "text", text: block.text }
197
+ : { type: "text", text: JSON.stringify(block) }),
198
+ details: {},
199
+ };
200
+ }
201
+ /** text blocks pass through; anything else is JSON-stringified text. */
202
+ export function normalizeMcpContent(content) {
203
+ return content.map((block) => {
204
+ if (isRecord(block) &&
205
+ block.type === "text" &&
206
+ typeof block.text === "string") {
207
+ return { type: "text", text: block.text };
208
+ }
209
+ return { type: "text", text: JSON.stringify(block) };
210
+ });
211
+ }
212
+ function scrub(text, knownSecrets) {
213
+ return knownSecrets === undefined || knownSecrets.length === 0
214
+ ? text
215
+ : scrubKnownSecrets(text, knownSecrets);
216
+ }
217
+ /** Normalization flattens every block to text, so scrubbing `.text` covers
218
+ * the whole result surface (incl. stringified non-text blocks). */
219
+ function scrubContentBlocks(blocks, knownSecrets) {
220
+ if (knownSecrets === undefined || knownSecrets.length === 0)
221
+ return blocks;
222
+ return blocks.map((block) => block.type === "text" && typeof block.text === "string"
223
+ ? { type: "text", text: scrubKnownSecrets(block.text, knownSecrets) }
224
+ : block);
225
+ }
226
+ /**
227
+ * Byte cap applied after normalization, over every block (text and
228
+ * stringified non-text alike), before persistence AND the model-visible
229
+ * return. Truncation is explicit, never silent.
230
+ */
231
+ export function capContent(blocks, capBytes) {
232
+ let total = 0;
233
+ for (const block of blocks) {
234
+ total += Buffer.byteLength(block.type === "text" && typeof block.text === "string"
235
+ ? block.text
236
+ : JSON.stringify(block), "utf8");
237
+ }
238
+ if (total <= capBytes)
239
+ return blocks;
240
+ const out = [];
241
+ let budget = capBytes;
242
+ for (const block of blocks) {
243
+ if (budget <= 0)
244
+ break;
245
+ const text = block.type === "text" && typeof block.text === "string"
246
+ ? block.text
247
+ : JSON.stringify(block);
248
+ const bytes = Buffer.from(text, "utf8");
249
+ if (bytes.byteLength <= budget) {
250
+ out.push({ type: "text", text });
251
+ budget -= bytes.byteLength;
252
+ continue;
253
+ }
254
+ out.push({ type: "text", text: bytes.subarray(0, budget).toString("utf8") });
255
+ budget = 0;
256
+ }
257
+ out.push({ type: "text", text: `[truncated by oma: ${total} bytes total]` });
258
+ return out;
259
+ }
260
+ function textOf(blocks) {
261
+ return blocks
262
+ .map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "")
263
+ .join("\n")
264
+ .trim();
265
+ }
266
+ /**
267
+ * Store-backed access resolver from the agent's mcp_toolset config
268
+ * (mirrors createStoreBackedBuiltinToolAccessResolver). `configs[].name`
269
+ * matching is by bare tool name, case-sensitive; a config entry naming a
270
+ * tool the server doesn't expose is silently ignored by construction (it
271
+ * simply never matches a discovered tool).
272
+ */
273
+ export function createStoreBackedMcpToolAccessResolver(opts) {
274
+ return (workspaceId, sessionId, mcpServerName, toolName, context) => {
275
+ const session = opts.sessions.retrieveAny(workspaceId, sessionId);
276
+ const agentId = session?.agent.id ?? context?.agentId;
277
+ const agentVersion = session?.agent.version ?? context?.agentVersion;
278
+ if (!agentId || agentVersion === undefined) {
279
+ return { enabled: false, permission: "deny" };
280
+ }
281
+ const agent = opts.agents.retrieveVersion(workspaceId, agentId, agentVersion);
282
+ if (!agent)
283
+ return { enabled: false, permission: "deny" };
284
+ const toolset = agent.tools.find((tool) => tool.type === "mcp_toolset" && tool.mcp_server_name === mcpServerName);
285
+ if (!toolset)
286
+ return { enabled: false, permission: "deny" };
287
+ const config = toolset.configs?.find((item) => item.name === toolName);
288
+ return {
289
+ enabled: config?.enabled ?? toolset.default_config?.enabled ?? true,
290
+ permission: mcpPolicyToPermission(config?.permission_policy?.type ??
291
+ toolset.default_config?.permission_policy?.type ??
292
+ "always_ask"),
293
+ };
294
+ };
295
+ }
296
+ function mcpPolicyToPermission(policy) {
297
+ if (policy === "always_allow")
298
+ return "allow";
299
+ if (policy === "always_ask")
300
+ return "ask";
301
+ if (policy === "never_allow")
302
+ return "deny";
303
+ return "deny";
304
+ }
305
+ export function createStoreBackedMcpServersProvider(opts) {
306
+ return (workspaceId, sessionId, context) => {
307
+ const session = opts.sessions.retrieveAny(workspaceId, sessionId);
308
+ const agentId = session?.agent.id ?? context?.agentId;
309
+ const agentVersion = session?.agent.version ?? context?.agentVersion;
310
+ if (!agentId || agentVersion === undefined)
311
+ return [];
312
+ const agent = opts.agents.retrieveVersion(workspaceId, agentId, agentVersion);
313
+ if (!agent)
314
+ return [];
315
+ // Only servers referenced by an mcp_toolset are connectable; validation
316
+ // guarantees the cross-reference for new agents, and pre-0122 rows with
317
+ // dangling entries degrade to "declared but toolset-less" = skipped.
318
+ const referenced = new Set(agent.tools
319
+ .filter((tool) => tool.type === "mcp_toolset")
320
+ .map((tool) => tool.mcp_server_name));
321
+ return agent.mcp_servers
322
+ .filter((server) => referenced.has(server.name))
323
+ .map((server) => ({ name: server.name, url: server.url }));
324
+ };
325
+ }
326
+ export function createStoreBackedMcpCredentialResolver(opts) {
327
+ return async (workspaceId, sessionId, serverUrl, context) => {
328
+ const session = opts.sessions.retrieveAny(workspaceId, sessionId);
329
+ const vaultIds = session?.vault_ids ?? context?.vaultIds ?? [];
330
+ if (vaultIds.length === 0)
331
+ return undefined;
332
+ const resolved = opts.vaults.resolveCredential(workspaceId, vaultIds, serverUrl);
333
+ if (!resolved)
334
+ return undefined;
335
+ return createRuntimeCredentialBinding({
336
+ workspaceId,
337
+ vaultIds,
338
+ serverUrl,
339
+ initial: resolved,
340
+ vaults: opts.vaults,
341
+ ...(opts.refresh === undefined ? {} : { refresh: opts.refresh }),
342
+ now: opts.now ?? (() => new Date()),
343
+ });
344
+ };
345
+ }
346
+ function createRuntimeCredentialBinding(args) {
347
+ let current = args.initial;
348
+ let closed = false;
349
+ const retained = new Set();
350
+ const snapshot = () => {
351
+ if (closed)
352
+ throw new Error("MCP credential is no longer active");
353
+ const authorization = `Bearer ${current.token}`;
354
+ retained.add(authorization);
355
+ retained.add(current.token);
356
+ return { authorization, identity: identityOf(args.workspaceId, current) };
357
+ };
358
+ const resolveCurrent = () => {
359
+ const resolved = args.vaults.resolveCredential(args.workspaceId, args.vaultIds, args.serverUrl);
360
+ if (resolved !== undefined &&
361
+ (resolved.vaultId !== current.vaultId ||
362
+ resolved.credentialId !== current.credentialId)) {
363
+ return undefined;
364
+ }
365
+ if (resolved !== undefined)
366
+ current = resolved;
367
+ return resolved;
368
+ };
369
+ const refreshAndResolve = async (force, expectedAuthVersion) => {
370
+ if (args.refresh === undefined || current.authType !== "mcp_oauth")
371
+ return undefined;
372
+ try {
373
+ const result = await args.refresh.refreshCredential({
374
+ workspaceId: args.workspaceId,
375
+ vaultId: current.vaultId,
376
+ credentialId: current.credentialId,
377
+ ...(force ? { force: true } : {}),
378
+ ...(expectedAuthVersion === undefined ? {} : { expectedAuthVersion }),
379
+ });
380
+ return result;
381
+ }
382
+ catch {
383
+ return undefined;
384
+ }
385
+ };
386
+ return {
387
+ get fingerprint() {
388
+ return `${current.credentialId}:${current.authVersion}`;
389
+ },
390
+ get identity() {
391
+ return identityOf(args.workspaceId, current);
392
+ },
393
+ async authorize() {
394
+ if (closed)
395
+ throw new Error("MCP credential is no longer active");
396
+ let latest = args.vaults.readCredentialRuntimeMetadata(args.workspaceId, current.vaultId, current.credentialId);
397
+ if (latest === undefined) {
398
+ closed = true;
399
+ throw new Error("MCP credential is no longer active");
400
+ }
401
+ if (latest.authVersion !== current.authVersion) {
402
+ if (resolveCurrent() === undefined) {
403
+ closed = true;
404
+ throw new Error("MCP credential is no longer active");
405
+ }
406
+ latest = args.vaults.readCredentialRuntimeMetadata(args.workspaceId, current.vaultId, current.credentialId);
407
+ if (latest === undefined) {
408
+ closed = true;
409
+ throw new Error("MCP credential is no longer active");
410
+ }
411
+ }
412
+ const refreshMode = admittedRefreshMode(latest, args.now());
413
+ if (refreshMode !== undefined && args.refresh !== undefined) {
414
+ const refreshResult = await refreshAndResolve(refreshMode === "forced", refreshMode === "forced" ? latest.authVersion : undefined);
415
+ if (refreshResult?.outcome === "skipped" &&
416
+ refreshResult.reason === "forced_refresh_floor") {
417
+ return snapshot();
418
+ }
419
+ if (refreshResult === undefined)
420
+ return snapshot();
421
+ if (resolveCurrent() === undefined) {
422
+ closed = true;
423
+ throw new Error("MCP credential is no longer active");
424
+ }
425
+ }
426
+ return snapshot();
427
+ },
428
+ async forceRefresh(rejected) {
429
+ if (closed || current.authType !== "mcp_oauth" || args.refresh === undefined) {
430
+ return { status: "failed", reason: "not_refreshable" };
431
+ }
432
+ const hintAt = args.now().toISOString();
433
+ try {
434
+ args.refresh.recordAuthHint({
435
+ workspaceId: rejected.identity.workspaceId,
436
+ vaultId: rejected.identity.vaultId,
437
+ credentialId: rejected.identity.credentialId,
438
+ expectedAuthVersion: rejected.identity.authVersion,
439
+ authHintAt: hintAt,
440
+ });
441
+ }
442
+ catch {
443
+ return { status: "failed", reason: "hint_adapter_error" };
444
+ }
445
+ if (resolveCurrent() === undefined) {
446
+ closed = true;
447
+ return { status: "failed", reason: "missing" };
448
+ }
449
+ if (changedFrom(current, rejected)) {
450
+ return { status: "ready", authorization: snapshot() };
451
+ }
452
+ const result = await refreshAndResolve(true, rejected.identity.authVersion);
453
+ if (result?.outcome === "skipped" && result.reason === "forced_refresh_floor") {
454
+ if (resolveCurrent() === undefined) {
455
+ closed = true;
456
+ return { status: "failed", reason: "missing" };
457
+ }
458
+ if (changedFrom(current, rejected)) {
459
+ return { status: "ready", authorization: snapshot() };
460
+ }
461
+ return { status: "skipped_floor" };
462
+ }
463
+ if (result === undefined || resolveCurrent() === undefined) {
464
+ return { status: "failed", reason: "missing" };
465
+ }
466
+ if (changedFrom(current, rejected)) {
467
+ return { status: "ready", authorization: snapshot() };
468
+ }
469
+ return {
470
+ status: "failed",
471
+ ...(result.outcome === "ok" ? {} : { reason: result.reason }),
472
+ };
473
+ },
474
+ knownSecrets: () => [...retained],
475
+ close: () => {
476
+ closed = true;
477
+ retained.clear();
478
+ },
479
+ };
480
+ }
481
+ function identityOf(workspaceId, resolved) {
482
+ return {
483
+ workspaceId,
484
+ vaultId: resolved.vaultId,
485
+ credentialId: resolved.credentialId,
486
+ authVersion: resolved.authVersion,
487
+ authType: resolved.authType,
488
+ };
489
+ }
490
+ function changedFrom(resolved, rejected) {
491
+ return (resolved.credentialId !== rejected.identity.credentialId ||
492
+ resolved.authVersion !== rejected.identity.authVersion ||
493
+ `Bearer ${resolved.token}` !== rejected.authorization);
494
+ }
495
+ const OAUTH_RUNTIME_EXPIRY_SKEW_MS = 60_000;
496
+ function admittedRefreshMode(metadata, now) {
497
+ if (metadata.authType !== "mcp_oauth" || !metadata.hasRefresh) {
498
+ return undefined;
499
+ }
500
+ if (metadata.refreshStatus === "invalid")
501
+ return undefined;
502
+ if (metadata.authHintAt !== null)
503
+ return "forced";
504
+ if (metadata.nextRefreshAt !== null &&
505
+ new Date(metadata.nextRefreshAt).getTime() > now.getTime()) {
506
+ return undefined;
507
+ }
508
+ return metadata.expiresAt !== undefined &&
509
+ new Date(metadata.expiresAt).getTime() <=
510
+ now.getTime() + OAUTH_RUNTIME_EXPIRY_SKEW_MS
511
+ ? "lazy"
512
+ : undefined;
513
+ }
514
+ function knownSecrets(opts) {
515
+ const configured = opts.knownSecrets;
516
+ if (typeof configured === "function")
517
+ return configured();
518
+ if (configured !== undefined)
519
+ return configured;
520
+ return opts.connection.credential?.knownSecrets();
521
+ }
522
+ function isRecord(value) {
523
+ return typeof value === "object" && value !== null && !Array.isArray(value);
524
+ }
525
+ function toError(error) {
526
+ return error instanceof Error ? error : new Error(String(error));
527
+ }
@@ -0,0 +1,157 @@
1
+ // Control-plane MCP client (plan 0122 §4.2). One connection per
2
+ // (session, declared server), streamable HTTP only, dialed through the
3
+ // SSRF-guarded fetch. The sandbox never dials MCP servers and (from M2)
4
+ // never sees credentials — connections live control-plane-side by design
5
+ // (architecture.md placement decision).
6
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8
+ import { isJsonObject } from "../../../../types/json.js";
9
+ import { attachMcpAuthSnapshot, getMcpAuthSnapshot, recordRejectedMcpAuthorization, runMcpAuthOperation, } from "./credential.js";
10
+ export const DEFAULT_MCP_OPERATION_TIMEOUT_MS = 60_000;
11
+ // Discovery bounds (review 0122-M1, Codex-adv HIGH): the tool list is
12
+ // attacker-influenced input parsed in the shared control plane, so a hostile
13
+ // or broken server must not be able to dictate unbounded work. Exceeding any
14
+ // bound fails the whole connection deterministically (surfaces as the
15
+ // structured mcp_connection_failed_error), never a silent partial register.
16
+ const MAX_DISCOVERED_TOOLS = 256;
17
+ const MAX_TOOL_NAME_LENGTH = 256;
18
+ const MAX_TOOL_DESCRIPTION_LENGTH = 4_096;
19
+ const MAX_TOOL_SCHEMA_BYTES = 64 * 1024;
20
+ const MAX_TOOL_LIST_PAGES = 16;
21
+ export class McpConnection {
22
+ serverName;
23
+ tools;
24
+ client;
25
+ timeoutMs;
26
+ credential;
27
+ constructor(serverName, tools, client, timeoutMs, credential) {
28
+ this.serverName = serverName;
29
+ this.tools = tools;
30
+ this.client = client;
31
+ this.timeoutMs = timeoutMs;
32
+ this.credential = credential;
33
+ }
34
+ /** Connect + discover in one step; both share the operation timeout. */
35
+ static async connect(declaration, opts) {
36
+ const timeoutMs = opts.operationTimeoutMs ?? DEFAULT_MCP_OPERATION_TIMEOUT_MS;
37
+ for (let attempt = 0; attempt < 2; attempt += 1) {
38
+ const client = new Client({ name: "open-managed-agents", version: "0" });
39
+ const transport = new StreamableHTTPClientTransport(new URL(declaration.url), { fetch: credentialFetch(opts.fetch, opts.credential) });
40
+ try {
41
+ const tools = await runMcpAuthOperation(async () => {
42
+ try {
43
+ await client.connect(transport, { timeout: timeoutMs });
44
+ return await discoverTools(client, declaration.name, timeoutMs);
45
+ }
46
+ catch (error) {
47
+ throw attachMcpAuthSnapshot(error);
48
+ }
49
+ });
50
+ return new McpConnection(declaration.name, tools, client, timeoutMs, opts.credential);
51
+ }
52
+ catch (error) {
53
+ const annotated = attachMcpAuthSnapshot(error);
54
+ await client.close().catch(() => undefined);
55
+ const rejectedSnapshot = getMcpAuthSnapshot(annotated);
56
+ if (attempt === 0 &&
57
+ opts.credential !== undefined &&
58
+ rejectedSnapshot !== undefined &&
59
+ isMcpAuthError(annotated)) {
60
+ try {
61
+ const refreshed = await opts.credential.forceRefresh(rejectedSnapshot);
62
+ if (refreshed.status === "ready")
63
+ continue;
64
+ }
65
+ catch {
66
+ // Adapter failure does not replace the reached server's structured
67
+ // auth rejection; the caller classifies the original final error.
68
+ }
69
+ }
70
+ opts.credential?.close?.();
71
+ throw annotated;
72
+ }
73
+ }
74
+ throw new Error("MCP connection retry exhausted");
75
+ }
76
+ async callTool(name, args, signal) {
77
+ const result = await runMcpAuthOperation(async () => {
78
+ try {
79
+ return await this.client.callTool({ name, arguments: args }, undefined, {
80
+ timeout: this.timeoutMs,
81
+ ...(signal === undefined ? {} : { signal }),
82
+ });
83
+ }
84
+ catch (error) {
85
+ throw attachMcpAuthSnapshot(error);
86
+ }
87
+ });
88
+ return {
89
+ content: Array.isArray(result.content) ? result.content : [],
90
+ isError: result.isError === true,
91
+ };
92
+ }
93
+ async close() {
94
+ await this.client.close().catch(() => undefined);
95
+ this.credential?.close?.();
96
+ }
97
+ }
98
+ function credentialFetch(fetch, credential) {
99
+ if (credential === undefined)
100
+ return fetch;
101
+ return async (url, init) => {
102
+ const snapshot = await credential.authorize();
103
+ const headers = new Headers(init?.headers);
104
+ if (snapshot === undefined)
105
+ headers.delete("authorization");
106
+ else
107
+ headers.set("authorization", snapshot.authorization);
108
+ const response = await fetch(url, { ...init, headers });
109
+ if (snapshot !== undefined && (response.status === 401 || response.status === 403)) {
110
+ recordRejectedMcpAuthorization(snapshot);
111
+ }
112
+ return response;
113
+ };
114
+ }
115
+ export function isMcpAuthError(error) {
116
+ const code = error?.code;
117
+ return code === 401 || code === 403;
118
+ }
119
+ /** Paginated discovery (review: nextCursor was silently dropped) + bounds. */
120
+ async function discoverTools(client, serverName, timeoutMs) {
121
+ const tools = [];
122
+ let cursor;
123
+ for (let page = 0; page < MAX_TOOL_LIST_PAGES; page += 1) {
124
+ const listed = await client.listTools(cursor === undefined ? undefined : { cursor }, { timeout: timeoutMs });
125
+ for (const tool of listed.tools) {
126
+ if (tools.length >= MAX_DISCOVERED_TOOLS) {
127
+ throw discoveryBoundError(serverName, `more than ${MAX_DISCOVERED_TOOLS} tools`);
128
+ }
129
+ if (tool.name.length > MAX_TOOL_NAME_LENGTH) {
130
+ throw discoveryBoundError(serverName, `tool name over ${MAX_TOOL_NAME_LENGTH} chars`);
131
+ }
132
+ const description = tool.description;
133
+ if (description !== undefined &&
134
+ description.length > MAX_TOOL_DESCRIPTION_LENGTH) {
135
+ throw discoveryBoundError(serverName, `tool ${tool.name} description over ${MAX_TOOL_DESCRIPTION_LENGTH} chars`);
136
+ }
137
+ const inputSchema = isJsonObject(tool.inputSchema)
138
+ ? tool.inputSchema
139
+ : { type: "object" };
140
+ if (JSON.stringify(inputSchema).length > MAX_TOOL_SCHEMA_BYTES) {
141
+ throw discoveryBoundError(serverName, `tool ${tool.name} schema over ${MAX_TOOL_SCHEMA_BYTES} bytes`);
142
+ }
143
+ tools.push({
144
+ name: tool.name,
145
+ ...(description === undefined ? {} : { description }),
146
+ inputSchema,
147
+ });
148
+ }
149
+ cursor = listed.nextCursor ?? undefined;
150
+ if (cursor === undefined)
151
+ return tools;
152
+ }
153
+ throw discoveryBoundError(serverName, `more than ${MAX_TOOL_LIST_PAGES} tools/list pages`);
154
+ }
155
+ function discoveryBoundError(serverName, detail) {
156
+ return new Error(`MCP server ${serverName} exceeded discovery bounds: ${detail}`);
157
+ }