@opengeni/api-router 3.0.1 → 3.1.2

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 (546) hide show
  1. package/dist/access-grant-rls.d.ts +8 -1
  2. package/dist/admission-trace.d.ts +8 -0
  3. package/dist/app.d.ts +50 -3
  4. package/dist/app.js +10 -1
  5. package/dist/artifact-catalog-cursor.d.ts +1 -0
  6. package/dist/attached-browser-viewer.d.ts +11 -0
  7. package/dist/auth/managed-auth-attempt-context.d.ts +2 -0
  8. package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
  9. package/dist/auth/managed-auth-session-adapter.d.ts +9 -1
  10. package/dist/auth/managed-auth.d.ts +72 -5
  11. package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
  12. package/dist/auth/new-signups-gate.d.ts +23 -0
  13. package/dist/auth/signup-funnel-metrics.d.ts +44 -0
  14. package/dist/azure-live.d.ts +28 -0
  15. package/dist/browser-action-fences.d.ts +13 -0
  16. package/dist/browser-controller-authority.d.ts +1 -61
  17. package/dist/browser-ephemeral.d.ts +5 -0
  18. package/dist/browser-lost-runtime-end.d.ts +24 -0
  19. package/dist/browser-state-authority.d.ts +1 -27
  20. package/dist/chunk-OGQDXVXY.js +18 -0
  21. package/dist/chunk-OGQDXVXY.js.map +1 -0
  22. package/dist/{chunk-AJ62T5QN.js → chunk-OT56OY4V.js} +80503 -39869
  23. package/dist/chunk-OT56OY4V.js.map +1 -0
  24. package/dist/claude-default-models.d.ts +19 -0
  25. package/dist/claude-subscription-core.d.ts +50 -0
  26. package/dist/claude-subscription-oauth.d.ts +36 -0
  27. package/dist/claude-subscription-profile.d.ts +9 -0
  28. package/dist/claude-subscription-usage.d.ts +6 -0
  29. package/dist/claude-workspace-connection.d.ts +15 -0
  30. package/dist/codemode.d.ts +40 -5
  31. package/dist/codex-realtime.d.ts +29 -3
  32. package/dist/codex-session-pointers.d.ts +19 -0
  33. package/dist/connection-ownership.d.ts +6 -3
  34. package/dist/controller-data-plane.d.ts +16 -0
  35. package/dist/credit-promotion-snapshot.d.ts +6 -0
  36. package/dist/editable-artifact-source-session.d.ts +3 -0
  37. package/dist/editable-artifact-websocket.d.ts +5 -3
  38. package/dist/fatal-process-boundary.d.ts +9 -1
  39. package/dist/github-access.d.ts +7 -1
  40. package/dist/github-browser-flow.d.ts +1 -1
  41. package/dist/http/acting-person.d.ts +36 -0
  42. package/dist/http/api-contract-compat.d.ts +19 -0
  43. package/dist/http/api-error.d.ts +50 -1
  44. package/dist/http/auth.d.ts +8 -1
  45. package/dist/http/cors.d.ts +12 -0
  46. package/dist/http/deprecation.d.ts +42 -0
  47. package/dist/http/keyed-admission.d.ts +12 -0
  48. package/dist/http/local-browser-boundary.d.ts +72 -0
  49. package/dist/http/metrics-listener.d.ts +22 -0
  50. package/dist/http/path-identifier.d.ts +17 -0
  51. package/dist/http/registered-route-label.d.ts +16 -0
  52. package/dist/http/rejection-telemetry.d.ts +34 -0
  53. package/dist/http/request-body.d.ts +27 -0
  54. package/dist/http/request-source.d.ts +41 -3
  55. package/dist/http/sse.d.ts +12 -1
  56. package/dist/http/unmatched-route.d.ts +18 -0
  57. package/dist/http/user-content.d.ts +36 -0
  58. package/dist/http/variable-sets-removed.d.ts +41 -0
  59. package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
  60. package/dist/index.js +1279 -108
  61. package/dist/index.js.map +1 -1
  62. package/dist/integration-connect-metrics.d.ts +35 -0
  63. package/dist/integration-endpoint-test.d.ts +20 -0
  64. package/dist/integrations/api-integrations.d.ts +2 -2
  65. package/dist/integrations/atlassian.d.ts +8 -3
  66. package/dist/integrations/fiken.d.ts +3 -1
  67. package/dist/integrations/github-lens-connect.d.ts +37 -66
  68. package/dist/integrations/google-drive.d.ts +2 -0
  69. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  70. package/dist/integrations/marketplace-plugin.d.ts +43 -43
  71. package/dist/integrations/oauth-client.d.ts +171 -12
  72. package/dist/integrations/oauth-profiles.d.ts +42 -13
  73. package/dist/integrations/onepassword-vault-provider.d.ts +9 -0
  74. package/dist/integrations/slack-bot.d.ts +285 -7
  75. package/dist/integrations/slack-file-upload-flow.d.ts +62 -0
  76. package/dist/integrations/slack-interactions.d.ts +89 -10
  77. package/dist/integrations/slack-mrkdwn.d.ts +41 -0
  78. package/dist/integrations/slack-routing.d.ts +1 -1
  79. package/dist/integrations/slack-session-defaults.d.ts +58 -0
  80. package/dist/integrations/slack-task-file-upload.d.ts +36 -0
  81. package/dist/integrations/slack-tool-review.d.ts +5 -0
  82. package/dist/interaction-agent-access.d.ts +13 -8
  83. package/dist/interaction-agent-version.d.ts +21 -0
  84. package/dist/interaction-holder-heartbeat.d.ts +5 -0
  85. package/dist/interaction-metrics.d.ts +27 -2
  86. package/dist/interaction-route-stages.d.ts +25 -0
  87. package/dist/interaction-secrets-vault.d.ts +20 -0
  88. package/dist/interaction-secrets.d.ts +103 -0
  89. package/dist/mcp/action-catalog-types.d.ts +14 -0
  90. package/dist/mcp/action-catalog.gen.d.ts +2 -0
  91. package/dist/mcp/action-descriptions.d.ts +4 -0
  92. package/dist/mcp/action-schema-imports.gen.d.ts +1 -0
  93. package/dist/mcp/capability-account-readiness.d.ts +7 -0
  94. package/dist/mcp/child-read-evidence.d.ts +9 -0
  95. package/dist/mcp/company-profile-agent-admin.d.ts +12 -0
  96. package/dist/mcp/contract-input.d.ts +13 -0
  97. package/dist/mcp/documents.d.ts +2 -0
  98. package/dist/mcp/first-party-tool-permissions.d.ts +3 -606
  99. package/dist/mcp/notification-tools.d.ts +26 -0
  100. package/dist/mcp/orchestration-failure-diagnostic.d.ts +43 -0
  101. package/dist/mcp/scheduled-task-input.d.ts +1309 -0
  102. package/dist/mcp/scheduled-task-view.d.ts +217 -3
  103. package/dist/mcp/secret-tools.d.ts +14 -0
  104. package/dist/mcp/server.d.ts +108 -5
  105. package/dist/mcp/session-admin-access-tools.d.ts +33 -0
  106. package/dist/mcp/session-event-errors.d.ts +28 -0
  107. package/dist/mcp/session-event-view.d.ts +62 -0
  108. package/dist/mcp/session-view.d.ts +15 -0
  109. package/dist/mcp/session-wait.d.ts +18 -0
  110. package/dist/mcp/site-agent-clamp-tools.d.ts +38 -0
  111. package/dist/mcp/site-data-tools.d.ts +22 -0
  112. package/dist/mcp/site-publication-tools.d.ts +19 -0
  113. package/dist/mcp/site-sql-tools.d.ts +27 -0
  114. package/dist/mcp-oauth.d.ts +37 -4
  115. package/dist/model-calls/chat-completions.d.ts +87 -0
  116. package/dist/model-calls/pending-usage-settlements.d.ts +57 -0
  117. package/dist/model-calls/service.d.ts +36 -0
  118. package/dist/model-catalog.d.ts +9 -3
  119. package/dist/native-push-dispatch.d.ts +69 -0
  120. package/dist/observability.d.ts +3 -1
  121. package/dist/organization-mcp.d.ts +82 -0
  122. package/dist/prepared-mcp-actor.d.ts +77 -0
  123. package/dist/prepared-mcp-permissions.d.ts +8 -0
  124. package/dist/routes/analytics-consent.d.ts +26 -0
  125. package/dist/routes/api-integrations.d.ts +11 -0
  126. package/dist/routes/api-keys.d.ts +7 -7
  127. package/dist/routes/automations.d.ts +2 -0
  128. package/dist/routes/billing.d.ts +59 -0
  129. package/dist/routes/browser-sessions.d.ts +65 -2
  130. package/dist/routes/chat-completions.d.ts +11 -0
  131. package/dist/routes/claude-core-usage.d.ts +20 -0
  132. package/dist/routes/claude-subscription-accounts.d.ts +3 -0
  133. package/dist/routes/claude-subscription-oauth.d.ts +3 -0
  134. package/dist/routes/client-errors.d.ts +182 -0
  135. package/dist/routes/codex-core.d.ts +401 -0
  136. package/dist/routes/codex.d.ts +64 -2
  137. package/dist/routes/computer-sessions.d.ts +6 -1
  138. package/dist/routes/desktop-distribution.d.ts +32 -0
  139. package/dist/routes/github-browser-fonts.gen.d.ts +2 -0
  140. package/dist/routes/github-browser-pages.d.ts +27 -0
  141. package/dist/routes/inbox.d.ts +13 -0
  142. package/dist/routes/insights-response-cache.d.ts +10 -0
  143. package/dist/routes/insights-usage.d.ts +12 -0
  144. package/dist/routes/insights.d.ts +28 -0
  145. package/dist/routes/interaction-placement-message.d.ts +7 -0
  146. package/dist/routes/managed-sign-in-methods.d.ts +6 -0
  147. package/dist/routes/model-account-choice.d.ts +13 -0
  148. package/dist/routes/model-account-settings.d.ts +19 -0
  149. package/dist/routes/native-app-auth.d.ts +12 -0
  150. package/dist/routes/organization-integration-policy.d.ts +8 -0
  151. package/dist/routes/organization-integrations.d.ts +6 -0
  152. package/dist/routes/organization-mcp-connections.d.ts +3 -0
  153. package/dist/routes/organization-model-defaults.d.ts +9 -0
  154. package/dist/routes/organization-service-accounts.d.ts +3 -0
  155. package/dist/routes/personal-api-keys.d.ts +3 -0
  156. package/dist/routes/session-admin-access.d.ts +3 -0
  157. package/dist/routes/session-artifact-associations.d.ts +4 -0
  158. package/dist/routes/session-history-imports.d.ts +20 -0
  159. package/dist/routes/sessions.d.ts +38 -2
  160. package/dist/routes/site-data.d.ts +18 -0
  161. package/dist/routes/site-publishing.d.ts +5 -0
  162. package/dist/routes/site-runtime.d.ts +31 -0
  163. package/dist/routes/site-sql.d.ts +16 -0
  164. package/dist/routes/slack-bot-access.d.ts +3 -0
  165. package/dist/routes/subscription-account-pools.d.ts +21 -0
  166. package/dist/routes/subscription-core-http.d.ts +5 -0
  167. package/dist/routes/subscription-pool-access.d.ts +37 -0
  168. package/dist/routes/subscription-pool-core.d.ts +153 -0
  169. package/dist/routes/supergrok.d.ts +6 -6
  170. package/dist/routes/usage-allowances.d.ts +12 -0
  171. package/dist/routes/variable-sets-removed.d.ts +2 -0
  172. package/dist/routes/vault-providers.d.ts +34 -0
  173. package/dist/routes/vaults.d.ts +3 -0
  174. package/dist/routes/workspace-integrations.d.ts +35 -0
  175. package/dist/routes/workspace-model-providers.d.ts +4 -0
  176. package/dist/routes/workspaces.d.ts +11 -2
  177. package/dist/sandbox/agent-update.d.ts +64 -0
  178. package/dist/sandbox/auth-callout.d.ts +8 -0
  179. package/dist/sandbox/channel-a.d.ts +15 -1
  180. package/dist/sandbox/enrollment.d.ts +26 -4
  181. package/dist/sandbox/machine-audience.d.ts +29 -0
  182. package/dist/sandbox/machines.d.ts +15 -2
  183. package/dist/sandbox/metrics-ingestion.d.ts +6 -2
  184. package/dist/sandbox/screen-control.d.ts +85 -0
  185. package/dist/sandbox/viewer.d.ts +5 -1
  186. package/dist/sandbox-file-artifacts.d.ts +17 -1
  187. package/dist/scheduled-task-blockers.d.ts +39 -0
  188. package/dist/scheduled-task-deletion.d.ts +3 -4
  189. package/dist/scheduled-task-lifecycle.d.ts +14 -0
  190. package/dist/secret-answers.d.ts +40 -0
  191. package/dist/session-create-observability.d.ts +5 -0
  192. package/dist/session-event-view-36GO3IL7.js +528 -0
  193. package/dist/session-event-view-36GO3IL7.js.map +1 -0
  194. package/dist/signed-state-secret.d.ts +18 -0
  195. package/dist/site-agent-guard.d.ts +53 -0
  196. package/dist/site-data/operations.d.ts +103 -0
  197. package/dist/site-data/service.d.ts +203 -0
  198. package/dist/site-frame-guard.d.ts +32 -0
  199. package/dist/site-publishing/agent-data-tools.d.ts +84 -0
  200. package/dist/site-publishing/agent-model.d.ts +58 -0
  201. package/dist/site-publishing/agent-session.d.ts +16 -0
  202. package/dist/site-publishing/content-scanner.d.ts +31 -0
  203. package/dist/site-publishing/edge-sync.d.ts +48 -0
  204. package/dist/site-publishing/gateway-signature.d.ts +56 -0
  205. package/dist/site-publishing/limits.d.ts +63 -0
  206. package/dist/site-publishing/page-tool-authority.d.ts +30 -0
  207. package/dist/site-publishing/platform-usage.d.ts +113 -0
  208. package/dist/site-publishing/platform.d.ts +21 -0
  209. package/dist/site-publishing/readiness.d.ts +62 -0
  210. package/dist/site-publishing/runtime.d.ts +179 -0
  211. package/dist/site-publishing/service.d.ts +264 -0
  212. package/dist/site-publishing/session-events.d.ts +22 -0
  213. package/dist/site-publishing/session-views.d.ts +36 -0
  214. package/dist/site-publishing/viewer.d.ts +40 -0
  215. package/dist/site-session-origin.d.ts +31 -6
  216. package/dist/site-sql/format.d.ts +47 -0
  217. package/dist/site-sql/gateway.d.ts +50 -0
  218. package/dist/site-sql/service.d.ts +213 -0
  219. package/dist/temporal-schedule-sync.d.ts +26 -0
  220. package/dist/transcription/billing-refusal.d.ts +8 -0
  221. package/dist/transcription/normalize.d.ts +23 -0
  222. package/dist/transcription/providers/azure-mai.d.ts +12 -0
  223. package/dist/transcription/providers/azure-openai.d.ts +4 -0
  224. package/dist/transcription/providers/openai.d.ts +16 -0
  225. package/dist/transcription/providers/subscription-core.d.ts +39 -0
  226. package/dist/transcription/service.d.ts +10 -1
  227. package/dist/transcription/wav-duration.d.ts +8 -0
  228. package/dist/workspace-tool-gateway-attestations.d.ts +37 -0
  229. package/dist/workspace-tool-gateway-observability.d.ts +16 -0
  230. package/dist/workspace-tool-gateway-secrets.d.ts +27 -0
  231. package/dist/workspace-tool-gateway.d.ts +102 -6
  232. package/dist/workspace-tool-target-fence.d.ts +116 -0
  233. package/dist/workspace-tool-target.d.ts +225 -0
  234. package/dist/workspace-webhook-dispatch.d.ts +26 -0
  235. package/dist/xai-subscription-core.d.ts +60 -0
  236. package/package.json +25 -20
  237. package/src/access-grant-rls.ts +64 -14
  238. package/src/admission-trace.ts +14 -0
  239. package/src/app.ts +1684 -252
  240. package/src/artifact-catalog-cursor.ts +1 -0
  241. package/src/attached-browser-viewer.ts +26 -0
  242. package/src/auth/managed-auth-attempt-context.ts +9 -0
  243. package/src/auth/managed-auth-rate-limits.ts +252 -0
  244. package/src/auth/managed-auth-session-adapter.ts +64 -29
  245. package/src/auth/managed-auth.ts +303 -35
  246. package/src/auth/managed-email.ts +18 -1
  247. package/src/auth/managed-sign-in-notifications.ts +81 -0
  248. package/src/auth/new-signups-gate.ts +49 -0
  249. package/src/auth/organization-user-setup.ts +3 -3
  250. package/src/auth/signup-funnel-metrics.ts +164 -0
  251. package/src/azure-live.ts +103 -0
  252. package/src/browser-action-fences.ts +45 -0
  253. package/src/browser-controller-authority.ts +1 -186
  254. package/src/browser-ephemeral.ts +39 -0
  255. package/src/browser-lost-runtime-end.ts +71 -0
  256. package/src/browser-state-authority.ts +1 -236
  257. package/src/claude-default-models.ts +37 -0
  258. package/src/claude-subscription-core.ts +154 -0
  259. package/src/claude-subscription-oauth.ts +352 -0
  260. package/src/claude-subscription-profile.ts +73 -0
  261. package/src/claude-subscription-usage.ts +51 -0
  262. package/src/claude-workspace-connection.ts +82 -0
  263. package/src/codemode.ts +236 -39
  264. package/src/codex-realtime.ts +181 -53
  265. package/src/codex-session-pointers.ts +80 -0
  266. package/src/company-brain-okf.ts +1 -1
  267. package/src/connected-machine-computer-access.ts +2 -1
  268. package/src/connection-ownership.ts +19 -13
  269. package/src/controller-data-plane.ts +34 -0
  270. package/src/credit-promotion-snapshot.ts +36 -0
  271. package/src/editable-artifact-production.ts +10 -0
  272. package/src/editable-artifact-source-session.ts +123 -0
  273. package/src/editable-artifact-websocket.ts +16 -3
  274. package/src/fatal-process-boundary.ts +67 -3
  275. package/src/gateway-realtime.ts +12 -8
  276. package/src/github-access.ts +31 -5
  277. package/src/github-browser-flow.ts +1 -1
  278. package/src/http/acting-person.ts +105 -0
  279. package/src/http/api-contract-compat.ts +49 -0
  280. package/src/http/api-error.ts +210 -2
  281. package/src/http/auth.ts +68 -4
  282. package/src/http/cors.ts +37 -5
  283. package/src/http/deprecation.ts +118 -0
  284. package/src/http/keyed-admission.ts +29 -0
  285. package/src/http/local-browser-boundary.ts +343 -0
  286. package/src/http/metrics-listener.ts +57 -0
  287. package/src/http/path-identifier.ts +54 -0
  288. package/src/http/registered-route-label.ts +34 -0
  289. package/src/http/rejection-telemetry.ts +120 -0
  290. package/src/http/request-body.ts +127 -0
  291. package/src/http/request-source.ts +154 -13
  292. package/src/http/sse.ts +88 -28
  293. package/src/http/unmatched-route.ts +60 -0
  294. package/src/http/user-content.ts +158 -0
  295. package/src/http/variable-sets-removed.ts +122 -0
  296. package/src/http/websocket-upgrade-dispatch.ts +29 -0
  297. package/src/index.ts +107 -57
  298. package/src/integration-connect-metrics.ts +228 -0
  299. package/src/integration-endpoint-test.ts +291 -0
  300. package/src/integrations/api-integrations.ts +81 -7
  301. package/src/integrations/atlassian.ts +202 -83
  302. package/src/integrations/fiken.ts +66 -38
  303. package/src/integrations/github-app-connect.ts +64 -18
  304. package/src/integrations/github-lens-connect.ts +8 -29
  305. package/src/integrations/google-drive.ts +448 -152
  306. package/src/integrations/integration-commit-authority.ts +69 -0
  307. package/src/integrations/marketplace-plugin.ts +1 -1
  308. package/src/integrations/oauth-client.ts +733 -193
  309. package/src/integrations/oauth-profiles.ts +116 -45
  310. package/src/integrations/onepassword-vault-provider.ts +16 -0
  311. package/src/integrations/personal-github-repositories.ts +1 -1
  312. package/src/integrations/personal-github.ts +61 -35
  313. package/src/integrations/provider-oauth.ts +67 -85
  314. package/src/integrations/slack-app-home.ts +5 -5
  315. package/src/integrations/slack-bot.ts +893 -201
  316. package/src/integrations/slack-file-upload-flow.ts +147 -0
  317. package/src/integrations/slack-install.ts +7 -7
  318. package/src/integrations/slack-interactions.ts +1589 -299
  319. package/src/integrations/slack-mrkdwn.ts +193 -0
  320. package/src/integrations/slack-routing.ts +2 -2
  321. package/src/integrations/slack-session-defaults.ts +224 -0
  322. package/src/integrations/slack-task-file-upload.ts +323 -0
  323. package/src/integrations/slack-tool-review.ts +65 -0
  324. package/src/integrations/social-oauth.ts +90 -58
  325. package/src/interaction-agent-access.ts +40 -20
  326. package/src/interaction-agent-version.ts +85 -0
  327. package/src/interaction-holder-heartbeat.ts +24 -8
  328. package/src/interaction-metrics.ts +164 -2
  329. package/src/interaction-route-stages.ts +94 -0
  330. package/src/interaction-secrets-vault.ts +213 -0
  331. package/src/interaction-secrets.ts +426 -0
  332. package/src/mcp/action-catalog-types.ts +14 -0
  333. package/src/mcp/action-catalog.gen.ts +8014 -0
  334. package/src/mcp/action-descriptions.ts +299 -0
  335. package/src/mcp/action-schema-imports.gen.ts +304 -0
  336. package/src/mcp/capability-account-readiness.ts +17 -0
  337. package/src/mcp/child-read-evidence.ts +19 -0
  338. package/src/mcp/company-profile-agent-admin.ts +13 -0
  339. package/src/mcp/contract-input.ts +165 -0
  340. package/src/mcp/documents.ts +9 -2
  341. package/src/mcp/editable-artifacts.ts +29 -11
  342. package/src/mcp/files.ts +5 -1
  343. package/src/mcp/first-party-tool-permissions.ts +7 -285
  344. package/src/mcp/knowledge-entries.ts +108 -34
  345. package/src/mcp/notification-tools.ts +480 -0
  346. package/src/mcp/orchestration-failure-diagnostic.ts +43 -0
  347. package/src/mcp/scheduled-task-input.ts +89 -0
  348. package/src/mcp/scheduled-task-view.ts +63 -3
  349. package/src/mcp/secret-tools.ts +185 -0
  350. package/src/mcp/server.ts +2213 -1116
  351. package/src/mcp/session-admin-access-tools.ts +163 -0
  352. package/src/mcp/session-event-errors.ts +123 -0
  353. package/src/mcp/session-event-view.ts +395 -13
  354. package/src/mcp/session-view.ts +3 -0
  355. package/src/mcp/session-wait.ts +67 -7
  356. package/src/mcp/site-agent-clamp-tools.ts +140 -0
  357. package/src/mcp/site-data-tools.ts +339 -0
  358. package/src/mcp/site-publication-tools.ts +201 -0
  359. package/src/mcp/site-sql-tools.ts +427 -0
  360. package/src/mcp-oauth.ts +635 -45
  361. package/src/model-calls/chat-completions.ts +352 -0
  362. package/src/model-calls/pending-usage-settlements.ts +125 -0
  363. package/src/model-calls/service.ts +694 -0
  364. package/src/model-catalog.ts +68 -17
  365. package/src/native-push-dispatch.ts +457 -0
  366. package/src/observability.ts +15 -1
  367. package/src/organization-mcp.ts +485 -0
  368. package/src/prepared-mcp-actor.ts +154 -0
  369. package/src/prepared-mcp-permissions.ts +38 -0
  370. package/src/routes/analytics-consent.ts +123 -0
  371. package/src/routes/api-integrations.ts +268 -9
  372. package/src/routes/api-keys.ts +164 -25
  373. package/src/routes/artifact-catalog.ts +111 -0
  374. package/src/routes/automations.ts +163 -90
  375. package/src/routes/billing.ts +535 -59
  376. package/src/routes/browser-sessions.ts +966 -342
  377. package/src/routes/canonical-human-identities.ts +61 -0
  378. package/src/routes/capabilities.ts +112 -5
  379. package/src/routes/channels.ts +4 -3
  380. package/src/routes/chat-completions.ts +235 -0
  381. package/src/routes/claude-core-usage.ts +147 -0
  382. package/src/routes/claude-subscription-accounts.ts +233 -0
  383. package/src/routes/claude-subscription-oauth.ts +175 -0
  384. package/src/routes/client-errors.ts +394 -0
  385. package/src/routes/codex-core.ts +687 -0
  386. package/src/routes/codex.ts +1277 -1119
  387. package/src/routes/company-brain.ts +4 -0
  388. package/src/routes/computer-sessions.ts +319 -85
  389. package/src/routes/connect.ts +277 -175
  390. package/src/routes/connections.ts +690 -363
  391. package/src/routes/desktop-distribution.ts +217 -0
  392. package/src/routes/documents.ts +13 -7
  393. package/src/routes/editable-artifacts.ts +74 -4
  394. package/src/routes/enrollments.ts +110 -16
  395. package/src/routes/external-identity-links.ts +13 -12
  396. package/src/routes/files.ts +41 -16
  397. package/src/routes/github-browser-fonts.gen.ts +12 -0
  398. package/src/routes/github-browser-pages.ts +258 -0
  399. package/src/routes/github.ts +200 -75
  400. package/src/routes/inbox.ts +353 -0
  401. package/src/routes/insights-response-cache.ts +83 -0
  402. package/src/routes/insights-usage.ts +345 -0
  403. package/src/routes/insights.ts +103 -8
  404. package/src/routes/install.ts +51 -10
  405. package/src/routes/integration-facets.ts +70 -28
  406. package/src/routes/interaction-placement-message.ts +12 -0
  407. package/src/routes/knowledge.ts +136 -27
  408. package/src/routes/machines.ts +189 -135
  409. package/src/routes/managed-auth-session-sets.ts +43 -10
  410. package/src/routes/managed-onboarding.ts +39 -19
  411. package/src/routes/managed-sign-in-methods.ts +433 -0
  412. package/src/routes/memory-slack-publications.ts +3 -2
  413. package/src/routes/model-account-choice.ts +194 -0
  414. package/src/routes/model-account-settings.ts +235 -0
  415. package/src/routes/model-connection-access.ts +210 -48
  416. package/src/routes/native-app-auth.ts +296 -0
  417. package/src/routes/organization-integration-policy.ts +116 -0
  418. package/src/routes/organization-integrations.ts +326 -0
  419. package/src/routes/organization-mcp-connections.ts +183 -0
  420. package/src/routes/organization-memberships.ts +205 -19
  421. package/src/routes/organization-model-defaults.ts +110 -0
  422. package/src/routes/organization-model-providers.ts +74 -3
  423. package/src/routes/organization-service-accounts.ts +128 -0
  424. package/src/routes/organization-sessions.ts +21 -5
  425. package/src/routes/personal-api-keys.ts +184 -0
  426. package/src/routes/personal-github-git-broker.ts +2 -2
  427. package/src/routes/personal-github.ts +9 -4
  428. package/src/routes/plugins.ts +46 -7
  429. package/src/routes/pr-review-github.ts +105 -118
  430. package/src/routes/pr-review.ts +88 -82
  431. package/src/routes/preference-registry.ts +18 -0
  432. package/src/routes/rigs.ts +48 -13
  433. package/src/routes/scheduled-tasks.ts +347 -27
  434. package/src/routes/session-admin-access.ts +271 -0
  435. package/src/routes/session-artifact-associations.ts +164 -0
  436. package/src/routes/session-history-imports.ts +305 -0
  437. package/src/routes/sessions.ts +1682 -363
  438. package/src/routes/site-data.ts +340 -0
  439. package/src/routes/site-publishing.ts +584 -0
  440. package/src/routes/site-runtime.ts +1944 -0
  441. package/src/routes/site-sql.ts +263 -0
  442. package/src/routes/skill-content.ts +72 -7
  443. package/src/routes/skills.ts +5 -4
  444. package/src/routes/slack-bot-access.ts +108 -0
  445. package/src/routes/social.ts +27 -15
  446. package/src/routes/subscription-account-pools.ts +209 -0
  447. package/src/routes/subscription-core-http.ts +18 -0
  448. package/src/routes/subscription-pool-access.ts +180 -0
  449. package/src/routes/subscription-pool-core.ts +552 -0
  450. package/src/routes/supergrok.ts +174 -655
  451. package/src/routes/transcription-recordings.ts +71 -4
  452. package/src/routes/transcriptions.ts +26 -2
  453. package/src/routes/usage-allowances.ts +381 -0
  454. package/src/routes/variable-sets-removed.ts +19 -0
  455. package/src/routes/vault-providers.ts +782 -0
  456. package/src/routes/vaults.ts +421 -0
  457. package/src/routes/video-generation.ts +30 -12
  458. package/src/routes/workspace-artifacts.ts +165 -53
  459. package/src/routes/workspace-integrations.ts +576 -0
  460. package/src/routes/workspace-model-providers.ts +148 -0
  461. package/src/routes/workspace-state.ts +4 -0
  462. package/src/routes/workspaces.ts +625 -117
  463. package/src/sandbox/agent-update.ts +315 -0
  464. package/src/sandbox/auth-callout.ts +104 -21
  465. package/src/sandbox/channel-a.ts +165 -55
  466. package/src/sandbox/enrollment.ts +131 -22
  467. package/src/sandbox/machine-audience.ts +192 -0
  468. package/src/sandbox/machines.ts +75 -31
  469. package/src/sandbox/metrics-ingestion.ts +133 -2
  470. package/src/sandbox/rematerialize.ts +51 -21
  471. package/src/sandbox/screen-control.ts +289 -0
  472. package/src/sandbox/viewer.ts +33 -58
  473. package/src/sandbox-file-artifacts.ts +53 -27
  474. package/src/scheduled-task-blockers.ts +293 -0
  475. package/src/scheduled-task-deletion.ts +13 -3
  476. package/src/scheduled-task-lifecycle.ts +84 -0
  477. package/src/secret-answers.ts +119 -0
  478. package/src/session-create-observability.ts +46 -0
  479. package/src/session-realtime-context.ts +26 -4
  480. package/src/signed-state-secret.ts +58 -0
  481. package/src/site-agent-guard.ts +261 -0
  482. package/src/site-data/operations.ts +777 -0
  483. package/src/site-data/service.ts +1043 -0
  484. package/src/site-frame-guard.ts +107 -0
  485. package/src/site-publishing/agent-data-tools.ts +250 -0
  486. package/src/site-publishing/agent-model.ts +127 -0
  487. package/src/site-publishing/agent-session.ts +148 -0
  488. package/src/site-publishing/content-scanner.ts +93 -0
  489. package/src/site-publishing/edge-sync.ts +148 -0
  490. package/src/site-publishing/gateway-signature.ts +198 -0
  491. package/src/site-publishing/limits.ts +315 -0
  492. package/src/site-publishing/page-tool-authority.ts +111 -0
  493. package/src/site-publishing/platform-usage.ts +479 -0
  494. package/src/site-publishing/platform.ts +98 -0
  495. package/src/site-publishing/readiness.ts +330 -0
  496. package/src/site-publishing/runtime.ts +831 -0
  497. package/src/site-publishing/service.ts +1282 -0
  498. package/src/site-publishing/session-events.ts +262 -0
  499. package/src/site-publishing/session-views.ts +262 -0
  500. package/src/site-publishing/viewer.ts +75 -0
  501. package/src/site-session-origin.ts +91 -6
  502. package/src/site-sql/format.ts +134 -0
  503. package/src/site-sql/gateway.ts +168 -0
  504. package/src/site-sql/service.ts +1636 -0
  505. package/src/site-uploads.ts +27 -5
  506. package/src/temporal-schedule-sync.ts +74 -0
  507. package/src/transcription/billing-refusal.ts +14 -0
  508. package/src/transcription/normalize.ts +77 -0
  509. package/src/transcription/providers/azure-mai.ts +98 -0
  510. package/src/transcription/providers/azure-openai.ts +16 -7
  511. package/src/transcription/providers/codex-subscription.ts +190 -67
  512. package/src/transcription/providers/openai.ts +79 -5
  513. package/src/transcription/providers/subscription-core.ts +125 -0
  514. package/src/transcription/providers/xai-subscription.ts +81 -83
  515. package/src/transcription/segmenter.ts +7 -1
  516. package/src/transcription/service.ts +239 -10
  517. package/src/transcription/wav-duration.ts +39 -0
  518. package/src/workspace-deletion.ts +25 -1
  519. package/src/workspace-tool-gateway-attestations.ts +133 -0
  520. package/src/workspace-tool-gateway-observability.ts +64 -0
  521. package/src/workspace-tool-gateway-secrets.ts +104 -0
  522. package/src/workspace-tool-gateway.ts +921 -103
  523. package/src/workspace-tool-target-fence.ts +380 -0
  524. package/src/workspace-tool-target.ts +554 -0
  525. package/src/workspace-webhook-dispatch.ts +225 -0
  526. package/src/xai-realtime.ts +85 -81
  527. package/src/xai-subscription-core.ts +172 -0
  528. package/dist/browser-auth-broker.d.ts +0 -20
  529. package/dist/chunk-AJ62T5QN.js.map +0 -1
  530. package/dist/connection-authority-owner.d.ts +0 -59
  531. package/dist/routes/connection-authorities.d.ts +0 -3
  532. package/dist/routes/environments.d.ts +0 -5
  533. package/dist/routes/host-mcp-bindings.d.ts +0 -5
  534. package/dist/routes/host-mcp-resolvers.d.ts +0 -3
  535. package/dist/routes/packs.d.ts +0 -3
  536. package/dist/session-event-view-MU3HMNV3.js +0 -264
  537. package/dist/session-event-view-MU3HMNV3.js.map +0 -1
  538. package/dist/xai-subscription-auth.d.ts +0 -23
  539. package/src/browser-auth-broker.ts +0 -155
  540. package/src/connection-authority-owner.ts +0 -61
  541. package/src/routes/connection-authorities.ts +0 -139
  542. package/src/routes/environments.ts +0 -388
  543. package/src/routes/host-mcp-bindings.ts +0 -122
  544. package/src/routes/host-mcp-resolvers.ts +0 -41
  545. package/src/routes/packs.ts +0 -784
  546. package/src/xai-subscription-auth.ts +0 -139
package/src/mcp/server.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import {
2
+ getSessionSystemUpdateById,
3
+ getSessionMessageOutcomeEvent,
4
+ setSessionTargetContext,
2
5
  getAttemptToolCatalog,
3
6
  createChannel,
4
7
  listChannels,
@@ -9,20 +12,32 @@ import {
9
12
  setSessionChannel,
10
13
  } from "@opengeni/db";
11
14
  import { createHash, randomUUID } from "node:crypto";
15
+ import { assertGoalResumeAllowed } from "@opengeni/core";
16
+ import { mintEnrollToken } from "../sandbox/enrollment";
17
+ import { enableMachineScreenControl, enrollmentAccess } from "../sandbox/screen-control";
18
+ import { registerNotificationTools } from "./notification-tools";
19
+ import { capabilityAccountReadiness } from "./capability-account-readiness";
12
20
  import {
13
21
  prepareWorkspaceArtifactUpload,
14
22
  prepareWorkspaceArtifactPublication,
15
23
  workspaceArtifactDownloads,
16
24
  } from "../site-uploads";
17
25
  import {
26
+ sessionTargetContext,
27
+ SetSessionTargetRequest,
18
28
  CreateScheduledTaskRequest,
29
+ ScheduledTaskAgentConfigInput,
30
+ ResourceRef,
31
+ ToolRef,
32
+ SessionMcpServerInput,
19
33
  CreateSessionRequest,
20
34
  GoalSpec,
35
+ SessionGoalReportRequirements,
36
+ SessionGoalReportDeliveries,
21
37
  boundSessionMcpText as capSessionDiscoveryText,
22
38
  compactSessionMcpListRow,
23
39
  sessionMcpIncludesRelatedWork,
24
40
  FIRST_PARTY_MCP_TOOL_NAMES,
25
- defaultRepositoryMountPath,
26
41
  SESSION_EVENT_RAW_DELTA_TYPES,
27
42
  SessionEventLatestClass,
28
43
  SessionEventPayloadMode,
@@ -33,17 +48,16 @@ import {
33
48
  SessionEventType,
34
49
  stableJson,
35
50
  compactSessionEventResult,
51
+ turnCompletedReply,
36
52
  sessionEventLatestClassToSemanticClass,
37
53
  SessionMcpCredentialUpdateInput,
38
54
  ToolAuthNeededPayload,
39
- VariableSetVariableName,
40
55
  capabilityCatalogItemIsTrustedForExposure,
41
56
  type AccessGrant,
42
57
  type CapabilityCatalogItem,
43
58
  type GitHubRepository,
44
59
  type FirstPartyMcpToolName,
45
- type Permission,
46
- type ResourceRef,
60
+ Permission,
47
61
  type SessionAuthorizationOperation,
48
62
  type SessionAuthorizationActor,
49
63
  type SessionAuthorizationSurface,
@@ -61,8 +75,8 @@ import {
61
75
  SESSION_GOAL_SUCCESS_CRITERIA_MAX_BYTES,
62
76
  SESSION_GOAL_TEXT_MAX_BYTES,
63
77
  SESSION_INSTRUCTIONS_MAX_CHARACTERS,
78
+ AGENT_IDENTITY_MAX_CHARACTERS,
64
79
  SESSION_TITLE_MAX_CHARACTERS,
65
- MAX_SELECTED_VARIABLE_SETS,
66
80
  sessionGoalUtf8Bytes,
67
81
  TASK_NOTE_LIST_DEFAULT_LIMIT,
68
82
  TASK_NOTE_LIST_MAX_LIMIT,
@@ -80,27 +94,27 @@ import {
80
94
  type WorkClaimSubjectFilter,
81
95
  type SessionStatus,
82
96
  SubmitHumanInputResponseRequest,
97
+ FIRST_PARTY_MCP_CALLER_META_KEY,
83
98
  } from "@opengeni/contracts";
84
99
  import {
85
- countVariableSets,
100
+ SessionCreateConnectionSelectionUnavailableError,
86
101
  beginRigChangeVerificationAttempt,
87
- createVariableSet,
88
- decryptVariableSetValue,
89
- encryptVariableSetValue,
90
102
  getSession,
103
+ getScheduledTaskRevisionAuthoritySubject,
91
104
  getSessionGoal,
92
105
  getSessionMcpMonitoringSummary,
93
106
  getSessionQueueSnapshot,
94
107
  getSessionTurn,
108
+ getSessionTurnMcpAccountBindings,
95
109
  getOrCreatePreferenceRegistrySnapshot,
96
110
  getPreferenceRegistryFullContent,
97
- getVariableSet,
98
- getVariableSetByName,
99
111
  listScheduledTaskRuns,
100
112
  listScheduledTasks,
101
113
  listSessionEventPage,
102
114
  listOutstandingSessionSystemUpdates,
103
115
  listSessionDiscoverySummaries,
116
+ getSandbox,
117
+ authorizePersonalMachineForAttempt,
104
118
  listEnrollments,
105
119
  projectEffectiveControlForRelatedAccess,
106
120
  projectSessionForRelatedAccess,
@@ -112,10 +126,8 @@ import {
112
126
  listSocialPosts,
113
127
  recordAuditEvent,
114
128
  removeEnrollment,
115
- readVariableSetSecretAtomically,
116
129
  recordSyncedSocialPosts,
117
- listVariableSets,
118
- requireScheduledTask,
130
+ getScheduledTaskIncludingDeleted,
119
131
  requireSession,
120
132
  type MemoryAgentScope,
121
133
  serializeEffectiveSessionControl,
@@ -124,7 +136,6 @@ import {
124
136
  waitForSessionInputWithEvent,
125
137
  SESSION_INPUT_WAIT_MAX_SECONDS,
126
138
  SESSION_INPUT_WAIT_MIN_SECONDS,
127
- setVariableSetVariable,
128
139
  updateSessionGoalWithEvent,
129
140
  upsertSessionGoalWithEvent,
130
141
  RigChangeTransitionError,
@@ -160,32 +171,64 @@ import type { AnySchema, ZodRawShapeCompat } from "@modelcontextprotocol/sdk/ser
160
171
  import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
161
172
  import { HTTPException } from "hono/http-exception";
162
173
  import * as z4 from "zod/v4";
174
+ import { normalizeObjectSchema } from "@modelcontextprotocol/sdk/server/zod-compat.js";
175
+ import { ApiHttpError, scheduledTaskTargetAccessHttpError } from "../http/api-error";
176
+ import { assertDescribedToolInput, contractToolInput } from "./contract-input";
177
+ import { registerSiteDataTools } from "./site-data-tools";
178
+ import { registerSitePublicationTools } from "./site-publication-tools";
179
+ import { registerSiteSqlTools } from "./site-sql-tools";
180
+ import { siteSqlGatewayConfig } from "../site-sql/gateway";
181
+ import { resolveSiteAgentDataContext } from "../site-publishing/agent-data-tools";
182
+ import {
183
+ resolveScheduledTaskCreateInput,
184
+ scheduledTaskCreateToolInput as buildScheduledTaskCreateToolInput,
185
+ scheduledTaskCreateToolValidation,
186
+ } from "./scheduled-task-input";
187
+ import { editableArtifactActorForGrant } from "../routes/editable-artifacts";
188
+ import { readOnlySessionRefusal } from "../routes/session-history-imports";
189
+ import { vaultProviderRegistryFor } from "../routes/vault-providers";
163
190
  import { registerKnowledgeEntryTools } from "./knowledge-entries";
191
+ import { registerSecretTools } from "./secret-tools";
192
+ import {
193
+ attemptSecretWriteContext,
194
+ prepareSecretAnswers,
195
+ SecretAnswerRejectedError,
196
+ splitSecretAnswers,
197
+ } from "../secret-answers";
198
+ import { isVaultError, vaultErrorFromDatabase } from "@opengeni/db";
164
199
  import {
165
200
  FIRST_PARTY_TOOL_AUTHORIZATION,
166
201
  type FirstPartyToolAuthorization,
167
202
  } from "./first-party-tool-permissions";
168
203
  import {
169
- hasLiteralPermission,
170
204
  hasPermission,
171
205
  authorizedSocialConnectionsForGrant,
172
- authorizedAtlassianConnectionsForGrant,
173
206
  buildCapabilityCatalog,
207
+ CONNECTED_MACHINE_CAPABILITY_ID,
174
208
  nativeConnectionCapabilityRecommendations,
209
+ OPENGENI_BROWSER_EXTENSION_URL,
175
210
  requireLiveAgentAttemptAuthorization,
176
211
  requireSessionAuthorization,
177
212
  requireSessionAuthorizationListScope,
178
213
  resolveWorkspaceCatalogSettings,
179
214
  SessionAuthorizationDeniedError,
180
215
  SessionAuthorizationUnavailableError,
216
+ dispatchWithSessionAuthorizationReadReuse,
181
217
  searchCapabilityCatalogItems,
218
+ suggestCapabilityCatalogItems,
182
219
  type ResolvedSessionAuthorization,
183
220
  } from "@opengeni/core";
184
- import { recordWorkspaceUsage, requireLimit, workflowIdForSession } from "@opengeni/core";
221
+ import {
222
+ recordWorkspaceUsage,
223
+ requireLimit,
224
+ resolveScheduledTaskPreflightModel,
225
+ workflowIdForSession,
226
+ } from "@opengeni/core";
185
227
  import type { ApiRouteDeps } from "@opengeni/core";
186
228
  import {
187
229
  githubBindingStatus,
188
230
  listWorkspaceGitHubInstallationBindings,
231
+ githubRepositoryResourceRef,
189
232
  listWorkspaceGitHubRepositories,
190
233
  } from "../github-access";
191
234
  import { githubBrowserBaseUrl, githubBrowserGrantClaims } from "../github-browser-flow";
@@ -209,18 +252,22 @@ import {
209
252
  proposeRigChangeForApi,
210
253
  requireRigChangeForApi,
211
254
  requireRigForApi,
212
- assertAllowedVariableSetVariableName,
213
- MAX_ENVIRONMENTS_PER_WORKSPACE,
214
- MAX_VARIABLES_PER_ENVIRONMENT,
215
- recordVariableSetAuditEvent,
216
- requireVariableSetEncryption,
217
255
  } from "@opengeni/core";
218
256
  import {
257
+ assertNotSiteClampedAgent,
219
258
  captureScheduledTaskRestoreState,
220
259
  createValidatedScheduledTask,
221
260
  manualScheduledTaskTriggerUsageKey,
222
261
  manualScheduledTaskTriggerWorkflowId,
223
262
  scheduledTaskForGrant,
263
+ pauseScheduledTaskAsAdmin,
264
+ pausePrivateScheduledTaskAsAdmin,
265
+ requirePausableScheduledTask,
266
+ requireVisibleScheduledTask,
267
+ scheduledTaskAccess,
268
+ scheduledTaskViewerForGrant,
269
+ siteViewerForGrant,
270
+ resolveNewSiteAudience,
224
271
  scheduledTaskRunForGrant,
225
272
  scheduledTaskToolsProvided,
226
273
  scheduledTaskTriggerToken,
@@ -230,6 +277,7 @@ import {
230
277
  validateScheduledTaskMachineTarget,
231
278
  validateScheduledTaskTarget,
232
279
  updateScheduledTaskForApi,
280
+ triggerScheduledTaskForGrant,
233
281
  validatedScheduledTaskUpdate,
234
282
  } from "@opengeni/core";
235
283
  import {
@@ -242,7 +290,9 @@ import {
242
290
  sendAgentSessionMessage,
243
291
  steerAgentSession,
244
292
  updateSessionTitle,
293
+ setSessionModel,
245
294
  sessionWithEffectiveToolPolicy,
295
+ workspaceSessionEffectiveToolsContext,
246
296
  workspaceSessionToolPolicyDefaultServerIds,
247
297
  workspaceSessionToolPolicyServerIds,
248
298
  type AgentSessionCommandContext,
@@ -252,6 +302,7 @@ import {
252
302
  listFleet,
253
303
  provisionSandbox,
254
304
  runOnSandbox,
305
+ createRunOnSecretResolver,
255
306
  swapActiveSandbox,
256
307
  type FleetContext,
257
308
  type FleetServices,
@@ -274,6 +325,17 @@ import {
274
325
  boundRigDetailMcp,
275
326
  SESSION_EVENT_MCP_MAX_BYTES,
276
327
  } from "./session-view";
328
+ import { completeChildReadSequences } from "./child-read-evidence";
329
+ import {
330
+ sessionEventAuditSelectorMessage,
331
+ addUnknownSessionEventTypeIssues,
332
+ unknownSessionEventTypeMessage,
333
+ } from "./session-event-errors";
334
+ import {
335
+ acknowledgeConsumedChildEvents,
336
+ listOutstandingSessionSystemUpdatesForAttempt,
337
+ recordConsumedChildAnswers,
338
+ } from "@opengeni/db";
277
339
  import {
278
340
  SESSION_WAIT_COMPLETION_EVENT_TYPES,
279
341
  SESSION_WAIT_DEFAULT_SECONDS,
@@ -281,8 +343,10 @@ import {
281
343
  SESSION_WAIT_EVENTS_PER_TARGET,
282
344
  SESSION_WAIT_MAX_SECONDS,
283
345
  SESSION_WAIT_MAX_TARGETS,
346
+ sessionWaitChangeEventMatches,
284
347
  sessionWaitCompletionEventMatches,
285
348
  waitForSessionChanges,
349
+ withOwnPendingUpdateKinds,
286
350
  } from "./session-wait";
287
351
  import {
288
352
  mcpMutationReceipt,
@@ -293,24 +357,45 @@ import {
293
357
  boundScheduledTaskDetailMcp,
294
358
  boundScheduledTaskMcpPage,
295
359
  scheduledTaskMcpSummary,
360
+ scheduledTaskPromptPage,
296
361
  } from "./scheduled-task-view";
297
362
  import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
298
363
  import {
299
364
  createOpenGeniSlackBotClient,
365
+ prepareSlackBotPost,
300
366
  resolveSlackBotConnectionForTool,
367
+ sendSlackBotPost,
368
+ type OpenGeniSlackBotClient,
301
369
  } from "../integrations/slack-bot";
370
+ import { uploadSlackTaskFile } from "../integrations/slack-task-file-upload";
302
371
  import { createFikenClient, resolveFikenConnectionForTool } from "../integrations/fiken";
303
372
  import {
304
- browseAtlassianSources,
305
- getAtlassianLiveItem,
306
- searchAtlassianLive,
307
- } from "../integrations/atlassian";
308
- import { AtlassianConnectionMetadata } from "@opengeni/contracts/atlassian";
373
+ allowanceExhaustedMessage,
374
+ parseAllowanceExhaustedRefusal,
375
+ } from "@opengeni/contracts/allowance-refusal";
309
376
  import { registerEditableArtifactAgentTools } from "./editable-artifacts";
310
377
  import { registerCompanyProfileAgentAdminTools } from "./company-profile-agent-admin";
311
378
  import { mintSandboxCodemodeToken } from "@opengeni/runtime/sandbox";
312
379
  import { deleteScheduledTaskWithDurableCleanup } from "../scheduled-task-deletion";
313
380
  import { observeWorkDiscovery, summarizeWorkDiscoveryRows } from "../work-discovery-observability";
381
+ import { orchestrationFailureDiagnostic } from "./orchestration-failure-diagnostic";
382
+ import { loadCodexSessionPointerProjection } from "../codex-session-pointers";
383
+ import {
384
+ afterSiteArtifactMutation,
385
+ requireSiteVersionPublishOpen,
386
+ SITE_AGENT_OTHER_SITE_MESSAGE,
387
+ siteAgentMayChangeArtifact,
388
+ } from "../site-publishing/service";
389
+ import { sitePlatformBrakeToolRefusal } from "../site-publishing/platform-usage";
390
+ import {
391
+ siteAgentClampRefusal,
392
+ siteAgentClampedToolResult,
393
+ siteAgentComputeRefusal,
394
+ } from "./site-agent-clamp-tools";
395
+ import {
396
+ registerSessionAdminAccessTools,
397
+ type SessionAdminAccessDispatch,
398
+ } from "./session-admin-access-tools";
314
399
 
315
400
  export type McpServerOptions = {
316
401
  // Origin of the HTTP request that reached the MCP route. Browser-oriented
@@ -325,6 +410,13 @@ export type McpServerOptions = {
325
410
  * save into that private layer. Omitted/null keeps the workspace layer.
326
411
  */
327
412
  sessionMemory?: MemoryAgentScope | null | undefined;
413
+ /**
414
+ * Set by the route only when the bound session has admin access right now
415
+ * (allowed by the organization, given by an owner or admin who still is
416
+ * one). It adds the admin tools whatever the session's tool selection;
417
+ * each call checks the access again.
418
+ */
419
+ sessionAdminAccess?: SessionAdminAccessDispatch | null | undefined;
328
420
  };
329
421
 
330
422
  const ORCHESTRATION_FAILURE_CODE_MAX_LENGTH = 128;
@@ -336,11 +428,11 @@ const ORCHESTRATION_FAILURE_MESSAGE_MAX_UTF8_BYTES = 1_024;
336
428
  // API-wide request-body ceiling for this 200-code-point field.
337
429
  const MCP_DISCOVERY_QUERY_MAX_UTF16_CODE_UNITS = WORK_DISCOVERY_QUERY_MAX_CHARS * 8;
338
430
 
339
- type OrchestrationToolName = "session_create" | "session_send_message";
431
+ type OrchestrationToolName = "session_create" | "session_send_message" | "session_steer";
340
432
 
341
433
  function boundedOrchestrationFailureMessage(value: string): string {
342
434
  const normalized = value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
343
- if (!normalized) return "OpenGeni could not complete the request.";
435
+ if (!normalized) return "Opengeni could not complete the request.";
344
436
  const encoded = new TextEncoder().encode(normalized);
345
437
  if (encoded.byteLength <= ORCHESTRATION_FAILURE_MESSAGE_MAX_UTF8_BYTES) return normalized;
346
438
  let end = ORCHESTRATION_FAILURE_MESSAGE_MAX_UTF8_BYTES;
@@ -405,7 +497,40 @@ function sessionCreateValidationFailureResult(error: z4.ZodError) {
405
497
  };
406
498
  }
407
499
 
408
- function orchestrationFailureEnvelope(tool: OrchestrationToolName, error: unknown) {
500
+ export function orchestrationFailureEnvelope(tool: OrchestrationToolName, error: unknown) {
501
+ if (
502
+ tool === "session_create" &&
503
+ error instanceof SessionCreateConnectionSelectionUnavailableError
504
+ ) {
505
+ return {
506
+ error: {
507
+ code: "session_create_connection_selection_unavailable",
508
+ message: error.message,
509
+ retryable: false,
510
+ },
511
+ };
512
+ }
513
+ // An archived or imported session is read-only: say so, so the calling agent
514
+ // starts a new session instead of retrying.
515
+ const readOnly = readOnlySessionRefusal(error);
516
+ if (readOnly) {
517
+ return {
518
+ error: {
519
+ code: `${tool}_session_read_only`,
520
+ message: readOnly.message,
521
+ reason: readOnly.code,
522
+ retryable: false,
523
+ },
524
+ };
525
+ }
526
+ const allowance =
527
+ (error instanceof HTTPException ? parseAllowanceExhaustedRefusal(error.cause) : null) ??
528
+ parseAllowanceExhaustedRefusal(error);
529
+ if (allowance) {
530
+ return {
531
+ error: { ...allowance, message: allowanceExhaustedMessage(allowance), retryable: false },
532
+ };
533
+ }
409
534
  if (error instanceof SessionSpawnDeniedError) {
410
535
  const denial = sessionSpawnDenialEnvelope(error);
411
536
  return {
@@ -421,7 +546,7 @@ function orchestrationFailureEnvelope(tool: OrchestrationToolName, error: unknow
421
546
  code: orchestrationFailureCode(tool, error),
422
547
  message:
423
548
  error.status >= 500
424
- ? "OpenGeni is temporarily unavailable — retry."
549
+ ? "Opengeni is temporarily unavailable — retry."
425
550
  : boundedOrchestrationFailureMessage(error.message),
426
551
  },
427
552
  };
@@ -474,21 +599,229 @@ function orchestrationFailureEnvelope(tool: OrchestrationToolName, error: unknow
474
599
  return {
475
600
  error: {
476
601
  code: `${tool}_failed`,
477
- message: "OpenGeni could not complete the request.",
602
+ message: "Opengeni could not complete the request.",
478
603
  },
479
604
  };
480
605
  }
481
606
 
482
- function orchestrationFailureResult(tool: OrchestrationToolName, error: unknown) {
607
+ function orchestrationFailureResult(
608
+ tool: OrchestrationToolName,
609
+ error: unknown,
610
+ deps: ApiRouteDeps,
611
+ grant: AccessGrant,
612
+ ) {
483
613
  const envelope = orchestrationFailureEnvelope(tool, error);
614
+ let result: Record<string, unknown> = envelope;
615
+ if (envelope.error.code === `${tool}_failed` || envelope.error.code === `${tool}_unavailable`) {
616
+ try {
617
+ result = {
618
+ error: {
619
+ ...envelope.error,
620
+ ...orchestrationFailureDiagnostic(deps, tool, error, exactAgentAttemptClaims(grant)),
621
+ },
622
+ };
623
+ } catch {
624
+ // Diagnostic construction must never replace the original tool outcome.
625
+ }
626
+ }
484
627
  return {
485
- content: [{ type: "text" as const, text: JSON.stringify(envelope, null, 2) }],
486
- structuredContent: envelope,
628
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
629
+ structuredContent: result,
487
630
  isError: true as const,
488
631
  };
489
632
  }
490
633
 
491
634
  const FIRST_PARTY_MCP_TOOL_NAME_SET = new Set<string>(FIRST_PARTY_MCP_TOOL_NAMES);
635
+ // Tool names whose input passed `assertDescribedToolInput` in this process.
636
+ // The check is about code structure, which cannot change while the process
637
+ // runs, so converting every input to JSON Schema per request only re-proves it.
638
+ const DESCRIBED_TOOL_INPUTS = new Set<string>();
639
+
640
+ /**
641
+ * Build a caller-independent tool input once per process. Zod construction and
642
+ * JSON Schema conversion are a large part of building the per-request server.
643
+ */
644
+ function staticToolInput<T>(build: () => T): () => T {
645
+ let value: T | undefined;
646
+ return () => (value ??= build());
647
+ }
648
+
649
+ const scheduledTaskCreateToolProjection = staticToolInput(buildScheduledTaskCreateToolInput);
650
+ const scheduledTaskUpdateToolInput = staticToolInput(() =>
651
+ contractToolInput(
652
+ z4
653
+ .object({
654
+ ...UpdateScheduledTaskRequest.out.shape,
655
+ id: z4.string().uuid(),
656
+ action: CreateScheduledTaskRequest.options[1].out.shape.action.optional(),
657
+ agentConfig: z4
658
+ .object(ScheduledTaskAgentConfigInput.shape)
659
+ .omit({ slackBotChannelId: true, toolPolicy: true })
660
+ .strict()
661
+ .optional(),
662
+ })
663
+ // Only a person pins connector accounts; agents never do.
664
+ .omit({
665
+ agentLearning: true,
666
+ connectionAuthorities: true,
667
+ pinnedAccounts: true,
668
+ pinnedAccountsRequired: true,
669
+ variableSetId: true,
670
+ environmentId: true,
671
+ })
672
+ .strict(),
673
+ UpdateScheduledTaskRequest,
674
+ ),
675
+ );
676
+
677
+ const sessionCreateToolInputs = new Map<string, ReturnType<typeof buildSessionCreateToolInput>>();
678
+
679
+ /** Distinct selections are few; cache each built contract for reuse. */
680
+ function sessionCreateToolInput(allowedTools: readonly FirstPartyMcpToolName[]) {
681
+ const key = allowedTools.join(",");
682
+ let input = sessionCreateToolInputs.get(key);
683
+ if (!input) {
684
+ if (sessionCreateToolInputs.size >= 256) sessionCreateToolInputs.clear();
685
+ input = buildSessionCreateToolInput(allowedTools);
686
+ sessionCreateToolInputs.set(key, input);
687
+ }
688
+ return input;
689
+ }
690
+
691
+ function buildSessionCreateToolInput(allowedTools: readonly FirstPartyMcpToolName[]) {
692
+ const capabilityToggle = z4.boolean().optional();
693
+ const sessionCreateAgentInput = z4
694
+ .object({
695
+ capabilities: z4
696
+ .union([
697
+ z4.enum(["all", "none"]),
698
+ z4
699
+ .object({
700
+ from: z4.enum(["all", "none"]),
701
+ webSearch: capabilityToggle,
702
+ humanInput: capabilityToggle,
703
+ skills: z4
704
+ .union([z4.literal("read"), z4.literal("manage"), z4.literal(false)])
705
+ .optional(),
706
+ goals: capabilityToggle,
707
+ subagents: capabilityToggle,
708
+ knowledge: capabilityToggle,
709
+ schedules: capabilityToggle,
710
+ artifacts: capabilityToggle,
711
+ browser: capabilityToggle,
712
+ media: capabilityToggle,
713
+ workspaceFiles: capabilityToggle,
714
+ workspaceConnectors: capabilityToggle,
715
+ workspaceAdmin: capabilityToggle,
716
+ })
717
+ .strict(),
718
+ ])
719
+ .optional(),
720
+ identity: z4.string().min(1).max(AGENT_IDENTITY_MAX_CHARACTERS).nullable().optional(),
721
+ instructions: z4.string().min(1).max(SESSION_INSTRUCTIONS_MAX_CHARACTERS).optional(),
722
+ renderer: z4.enum(["opengeni", "markdown"]).optional(),
723
+ })
724
+ .strict()
725
+ .optional()
726
+ .describe(
727
+ "Optional agent configuration for the child. Omit to inherit this session's. capabilities 'all' means everything this session has; 'none' keeps only essentials; an object starts from one and switches capabilities off (or back on within this session's own set). A child may only narrow: enabling a capability this session lacks is rejected. For a worker that must not start, message, or follow other sessions, use agent: { capabilities: { from: 'all', subagents: false } } and omit firstPartyMcpTools. This keeps the parent's other capabilities and tool selections; the worker's final answer still reaches its parent automatically.",
728
+ );
729
+ const sessionCreateInput = z4
730
+ .object({
731
+ initialMessage: z4.string().min(1),
732
+ projectId: z4
733
+ .string()
734
+ .uuid()
735
+ .nullable()
736
+ .optional()
737
+ .describe(
738
+ "Workspace project to file the new session into. Omit for existing default behavior; does not change runtime or visibility.",
739
+ ),
740
+ title: z4
741
+ .string()
742
+ .min(1)
743
+ .max(SESSION_TITLE_MAX_CHARACTERS)
744
+ .optional()
745
+ .describe(
746
+ "Concise semantic title for the child session. Omit only when the delegated goal or initial message already provides a suitable title; Opengeni derives a sensitive-safe bounded fallback from that text.",
747
+ ),
748
+ instructions: z4.string().min(1).max(SESSION_INSTRUCTIONS_MAX_CHARACTERS).optional(),
749
+ goal: GoalSpec.optional(),
750
+ resources: z4
751
+ .array(ResourceRef)
752
+ .optional()
753
+ .describe(
754
+ "Omit to inherit parent repositories only. Files are never inherited automatically; explicitly include file resources to attach them. An empty array inherits no resources.",
755
+ ),
756
+ tools: z4.array(ToolRef).optional(),
757
+ mcpServers: z4.array(SessionMcpServerInput).optional(),
758
+ rigId: z4.string().uuid().optional(),
759
+ model: z4
760
+ .string()
761
+ .min(1)
762
+ .optional()
763
+ .describe(
764
+ "Model for the worker. Omit to inherit the exact calling turn's model, including its Codex subscription billing path.",
765
+ ),
766
+ reasoningEffort: CreateSessionRequest.out.shape.reasoningEffort.describe(
767
+ "Omit to inherit the exact calling turn's reasoning effort.",
768
+ ),
769
+ latencyMode: z4
770
+ .enum(["standard", "priority", "fast"])
771
+ .optional()
772
+ .describe(
773
+ "Omit for standard latency, even when model and reasoning are inherited. Fast or priority must be selected explicitly and supported by the model.",
774
+ ),
775
+ sandboxBackend: CreateSessionRequest.out.shape.sandboxBackend,
776
+ // Model-only structural coupling: workingDir cannot exist without a
777
+ // targetSandboxId because both live inside one optional object. The
778
+ // handler maps this back to the stable public REST/SDK request fields.
779
+ machineTarget: z4
780
+ .object({
781
+ targetSandboxId: z4.string().uuid(),
782
+ workingDir: z4.string().optional(),
783
+ })
784
+ .strict()
785
+ .optional(),
786
+ metadata: z4.record(z4.string(), z4.unknown()).optional(),
787
+ idempotencyKey: z4.string().min(1).max(200).optional(),
788
+ firstPartyMcpPermissions: z4
789
+ .array(Permission)
790
+ .optional()
791
+ .describe(
792
+ "Optional first-party capability set for the child. Omit to inherit this session's effective permissions. An explicit set may only narrow capabilities held by this session. A goal-bearing child requires goals:manage in the resulting set; creation fails rather than adding it implicitly.",
793
+ ),
794
+ firstPartyMcpTools: z4
795
+ .array(
796
+ allowedTools.length
797
+ ? z4.enum(allowedTools as [FirstPartyMcpToolName, ...FirstPartyMcpToolName[]])
798
+ : z4.never(),
799
+ )
800
+ .optional()
801
+ .describe(
802
+ "Exact model-visible first-party tool selection for the child. Omit it in almost every case: omission inherits this session's effective selection, which is what workers normally need. The allowed values are exactly the tools this session holds that a child may keep. An explicit array replaces the entire inherited selection, including browser, computer, and scheduling tools; it is not an additions list. Do not construct a partial list merely because a worker has a specialist role or bounded task. For a worker that must not coordinate other sessions, prefer agent.capabilities with from: 'all' and subagents: false. Use an explicit array only for deliberate per-tool restrictions. A tool outside the allowed values is rejected, and [] selects no tools from this server. This does not grant permissions.",
803
+ ),
804
+ // The child's agent-access scope and end-user label are never model
805
+ // choices: it inherits this session's exactly. Only the Memory
806
+ // selector may be narrowed here (workspace > user > off).
807
+ memoryScope: z4
808
+ .enum(["workspace", "user", "off"])
809
+ .optional()
810
+ .describe(
811
+ "Optional Memory selector for the child. Omit to inherit this session's selector. An explicit value may only narrow it (workspace > user > off); off gives the child no Memory tools. Use task notes for task-local findings.",
812
+ ),
813
+ // Omission is the ordinary safe sharing path. Literal "shared" remains
814
+ // available to advanced REST/SDK callers but is intentionally absent
815
+ // from the model surface because it turns compatibility drift into a
816
+ // deterministic failure instead of the omission path's safe own-box fallback.
817
+ sandbox: z4.union([z4.literal("new"), z4.object({ groupId: z4.string().uuid() })]).optional(),
818
+ agent: sessionCreateAgentInput,
819
+ })
820
+ .strict();
821
+ return contractToolInput(sessionCreateInput, sessionCreateInput, {
822
+ jsonObjectFields: ["agent", "goal", "machineTarget"],
823
+ });
824
+ }
492
825
 
493
826
  class PolicyMcpServer extends McpServer {
494
827
  private registeredToolCount = 0;
@@ -497,10 +830,27 @@ class PolicyMcpServer extends McpServer {
497
830
  private readonly grant: AccessGrant,
498
831
  private readonly sessionId: string | null,
499
832
  private readonly selectedTools: ReadonlySet<FirstPartyMcpToolName> | null,
833
+ private readonly sessionAdminAccess: boolean = false,
500
834
  ) {
501
835
  super({ name: "opengeni", version: "1.0.0" });
502
836
  }
503
837
 
838
+ /**
839
+ * Whether a selection-governed tool is both selected and authorized for this
840
+ * grant. Admin-access tools are never selection-governed, so a child
841
+ * selection can never carry them.
842
+ */
843
+ holdsSelectableTool(toolName: FirstPartyMcpToolName): boolean {
844
+ const policy: FirstPartyToolAuthorization = FIRST_PARTY_TOOL_AUTHORIZATION[toolName];
845
+ if (policy.adminAccessRequired) return false;
846
+ const authorized =
847
+ (policy.allOf?.every((permission) => hasPermission(this.grant.permissions, permission)) ??
848
+ true) &&
849
+ (policy.anyOf?.some((permission) => hasPermission(this.grant.permissions, permission)) ??
850
+ true);
851
+ return authorized && (this.selectedTools === null || this.selectedTools.has(toolName));
852
+ }
853
+
504
854
  override registerTool<
505
855
  OutputArgs extends ZodRawShapeCompat | AnySchema,
506
856
  InputArgs extends undefined | ZodRawShapeCompat | AnySchema = undefined,
@@ -521,14 +871,15 @@ class PolicyMcpServer extends McpServer {
521
871
  if (catalogued) {
522
872
  const toolName = name as FirstPartyMcpToolName;
523
873
  const policy: FirstPartyToolAuthorization = FIRST_PARTY_TOOL_AUTHORIZATION[toolName];
524
- const authorized =
525
- (!policy.sessionRequired || this.sessionId !== null) &&
526
- (policy.allOf?.every((permission) => hasPermission(this.grant.permissions, permission)) ??
527
- true) &&
528
- (policy.anyOf?.some((permission) => hasPermission(this.grant.permissions, permission)) ??
529
- true);
530
- const selected = this.selectedTools === null || this.selectedTools.has(toolName);
531
- admitted = authorized && selected;
874
+ if (policy.adminAccessRequired) {
875
+ // Admin access, not the tool selection or the agent's own
876
+ // permissions, decides: the tools act as the person who gave it.
877
+ admitted = this.sessionId !== null && this.sessionAdminAccess;
878
+ } else {
879
+ admitted =
880
+ (!policy.sessionRequired || this.sessionId !== null) &&
881
+ this.holdsSelectableTool(toolName);
882
+ }
532
883
  }
533
884
  if (!admitted) {
534
885
  return {
@@ -544,8 +895,23 @@ class PolicyMcpServer extends McpServer {
544
895
  remove() {},
545
896
  };
546
897
  }
898
+ if (config.inputSchema && !DESCRIBED_TOOL_INPUTS.has(name)) {
899
+ const schema =
900
+ normalizeObjectSchema(config.inputSchema) ??
901
+ (Object.keys(config.inputSchema).length === 0 ? z4.object({}) : undefined);
902
+ if (!schema) throw new Error(`${name}: MCP input must be an object schema`);
903
+ assertDescribedToolInput(
904
+ z4.toJSONSchema(schema as z4.ZodType, { io: "input", target: "draft-7" }),
905
+ name,
906
+ );
907
+ DESCRIBED_TOOL_INPUTS.add(name);
908
+ }
547
909
  this.registeredToolCount += 1;
548
- return super.registerTool(name, config, cb);
910
+ const handler = cb as (...args: unknown[]) => unknown;
911
+ return super.registerTool(name, config, ((...args: unknown[]) =>
912
+ dispatchWithSessionAuthorizationReadReuse(() =>
913
+ handler(...args),
914
+ )) as ToolCallback<InputArgs>);
549
915
  }
550
916
 
551
917
  ensureToolsListHandler(): void {
@@ -565,6 +931,51 @@ class PolicyMcpServer extends McpServer {
565
931
  }
566
932
  }
567
933
 
934
+ export function slackBotFileContentResult(
935
+ result: Awaited<ReturnType<OpenGeniSlackBotClient["fileContent"]>>,
936
+ ) {
937
+ if (!("image" in result)) {
938
+ return {
939
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
940
+ structuredContent: {
941
+ kind: "text" as const,
942
+ fileId: result.file.id,
943
+ contentType: result.contentType,
944
+ content: result.content,
945
+ sizeBytes: null,
946
+ nextOffset: result.nextOffset,
947
+ },
948
+ };
949
+ }
950
+ return {
951
+ structuredContent: {
952
+ kind: "image" as const,
953
+ fileId: result.file.id,
954
+ contentType: result.image.contentType,
955
+ content: null,
956
+ sizeBytes: result.image.bytes.byteLength,
957
+ nextOffset: null,
958
+ },
959
+ content: [
960
+ {
961
+ type: "text" as const,
962
+ text: JSON.stringify({
963
+ channel: result.channel,
964
+ file: result.file,
965
+ contentType: result.image.contentType,
966
+ sizeBytes: result.image.bytes.byteLength,
967
+ receipt: result.receipt,
968
+ }),
969
+ },
970
+ {
971
+ type: "image" as const,
972
+ mimeType: result.image.contentType,
973
+ data: Buffer.from(result.image.bytes).toString("base64"),
974
+ },
975
+ ],
976
+ };
977
+ }
978
+
568
979
  export function buildOpenGeniMcpServer(
569
980
  deps: ApiRouteDeps,
570
981
  grant: AccessGrant,
@@ -628,7 +1039,23 @@ export function buildOpenGeniMcpServer(
628
1039
  typeof nestedAgentDepth !== "number" ||
629
1040
  typeof effectiveMaxNestedAgentDepth !== "number" ||
630
1041
  nestedAgentDepth < effectiveMaxNestedAgentDepth;
631
- const server = new PolicyMcpServer(grant, sessionId, selectedTools);
1042
+ const sessionAdminAccess =
1043
+ sessionId !== null && exactAgentAttemptClaims(grant) !== null
1044
+ ? (options.sessionAdminAccess ?? null)
1045
+ : null;
1046
+ const server = new PolicyMcpServer(grant, sessionId, selectedTools, sessionAdminAccess !== null);
1047
+ if (sessionId !== null && sessionAdminAccess !== null) {
1048
+ registerSessionAdminAccessTools({
1049
+ server,
1050
+ db: deps.db,
1051
+ grant,
1052
+ sessionId,
1053
+ access: sessionAdminAccess,
1054
+ authorize: async () => {
1055
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
1056
+ },
1057
+ });
1058
+ }
632
1059
  // set_session_title names the agent's OWN session — pure session metadata,
633
1060
  // not a goal operation — so it is available on every session, gated only on
634
1061
  // the signed sessionId (NOT goals:manage, and NOT on a goal existing).
@@ -651,6 +1078,21 @@ export function buildOpenGeniMcpServer(
651
1078
  },
652
1079
  );
653
1080
  }
1081
+ // Notifications reach the person who started this session: their inbox and,
1082
+ // when new, their phones (see notification-tools.ts).
1083
+ if (sessionId !== null) {
1084
+ registerNotificationTools({
1085
+ server,
1086
+ deps,
1087
+ grant,
1088
+ sessionId,
1089
+ authorize: async () => {
1090
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
1091
+ },
1092
+ attempt: () => exactAgentCommandContext(grant, sessionId),
1093
+ json,
1094
+ });
1095
+ }
654
1096
  // PolicyMcpServer applies each tool's own permission contract. Register this
655
1097
  // mixed group for every session so session-level wait_for_input remains
656
1098
  // available with sessions:control even when goals:manage is intentionally
@@ -662,12 +1104,15 @@ export function buildOpenGeniMcpServer(
662
1104
  // Reading existing Knowledge and Skills remains available when authoring is Off.
663
1105
  if (sessionId !== null) {
664
1106
  registerKnowledgeEntryTools(server, deps, grant, sessionId);
1107
+ // secret_* (capability `secrets`): gated by permission and the signed
1108
+ // tool selection like every catalogued tool; agent attempts only.
1109
+ registerSecretTools(server, deps, grant, sessionId);
665
1110
  }
666
1111
  server.registerTool(
667
1112
  "artifacts_list",
668
1113
  {
669
1114
  description:
670
- "List the generic published artifacts in this workspace and their current versions.",
1115
+ "List the published Sites you can see in this workspace (shared with the workspace, or Only me Sites that belong to the person you act for) and their current versions and audience.",
671
1116
  inputSchema: {},
672
1117
  },
673
1118
  async () => {
@@ -676,7 +1121,11 @@ export function buildOpenGeniMcpServer(
676
1121
  }
677
1122
  return json(
678
1123
  redactWorkspaceArtifactListProvenance(
679
- await listWorkspaceArtifacts(deps.db, grant.workspaceId),
1124
+ await listWorkspaceArtifacts(
1125
+ deps.db,
1126
+ grant.workspaceId,
1127
+ await siteViewerForGrant(deps.db, grant),
1128
+ ),
680
1129
  ),
681
1130
  );
682
1131
  },
@@ -701,11 +1150,55 @@ export function buildOpenGeniMcpServer(
701
1150
  authorize: async () => {
702
1151
  await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
703
1152
  },
1153
+ // A Site-started agent does not propose or confirm the organization profile.
1154
+ refuseSiteAgent: async () =>
1155
+ await siteAgentClampRefusal(deps, grant, "the organization profile"),
704
1156
  json,
705
1157
  });
706
1158
  }
707
1159
  if (sessionId !== null && exactAgentAttemptClaims(grant) !== null) {
708
1160
  registerWorkspaceArtifactTools(server, deps, grant, sessionId, json);
1161
+ // A published Site's own agent: its Site Data and Site SQL tools are
1162
+ // pinned to that Site and act as the conversation's viewer. Resolve on
1163
+ // every call: a long-lived MCP server must observe audience revocations.
1164
+ const siteAgent = () =>
1165
+ resolveSiteAgentDataContext(
1166
+ deps,
1167
+ { accountId: grant.accountId, workspaceId: grant.workspaceId },
1168
+ sessionId,
1169
+ );
1170
+ registerSiteDataTools(
1171
+ server,
1172
+ deps,
1173
+ grant,
1174
+ async () => {
1175
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
1176
+ },
1177
+ json,
1178
+ siteAgent,
1179
+ );
1180
+ // Site SQL tools need a configured gateway (they also reach databases
1181
+ // that already exist after the deployment stops offering Site SQL).
1182
+ if (siteSqlGatewayConfig(deps.settings) !== null)
1183
+ registerSiteSqlTools(
1184
+ server,
1185
+ deps,
1186
+ grant,
1187
+ async () => {
1188
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
1189
+ },
1190
+ json,
1191
+ siteAgent,
1192
+ );
1193
+ registerSitePublicationTools(
1194
+ server,
1195
+ deps,
1196
+ grant,
1197
+ async () => {
1198
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
1199
+ },
1200
+ json,
1201
+ );
709
1202
  registerSandboxFileArtifactTool(server, deps, grant, sessionId, json);
710
1203
  registerEditableArtifactAgentTools({
711
1204
  server,
@@ -733,14 +1226,13 @@ export function buildOpenGeniMcpServer(
733
1226
  registerRigTools(server, deps, grant, can, sessionId, json);
734
1227
  registerSlackBotTools(server, deps, grant, sessionId, json);
735
1228
  registerFikenTools(server, deps, grant, sessionId, json);
736
- registerAtlassianTools(server, deps, grant, json);
737
1229
 
738
- // Orchestration, variableSet, and GitHub status tools are permission-gated
1230
+ // Orchestration, secret, and GitHub status tools are permission-gated
739
1231
  // at registration: a grant without the permission does not see the tool.
740
1232
  // Sandboxed workers reach this server with the first-party delegated
741
1233
  // permission set (firstPartyMcpPermissions in @opengeni/runtime), which is
742
- // POWERFUL BY DEFAULT — it carries sessions:*, variable sets:*, and github:use,
743
- // so agents can spawn/read sessions, manage variable set variables, inspect
1234
+ // POWERFUL BY DEFAULT — it carries sessions:*, secrets:*, and github:use,
1235
+ // so agents can spawn/read sessions, use and save vault secrets, inspect
744
1236
  // GitHub connection availability, and use already-bound repository resources
745
1237
  // out of the box. GitHub installation credentials are refreshed host-side and
746
1238
  // never returned through a model-visible MCP tool. A user DEMOTES a specific
@@ -753,10 +1245,14 @@ export function buildOpenGeniMcpServer(
753
1245
  can,
754
1246
  sessionId,
755
1247
  sessionCreateVisible,
1248
+ // A child selection may only narrow this session's: advertise exactly the
1249
+ // deployment-allowed tools this session holds, never the whole catalog.
1250
+ allowedFirstPartyMcpToolsForSession(deps.settings, FIRST_PARTY_MCP_TOOL_NAMES).filter((tool) =>
1251
+ server.holdsSelectableTool(tool),
1252
+ ),
756
1253
  json,
757
1254
  );
758
1255
  registerProjectTools(server, deps, grant, can, json);
759
- registerVariableSetTools(server, deps, grant, can, sessionId, json);
760
1256
  if (sessionId !== null && can("workspace:read")) {
761
1257
  registerCapabilityDiscoveryTools(server, deps, grant, sessionId, json);
762
1258
  }
@@ -797,8 +1293,7 @@ export function buildOpenGeniMcpServer(
797
1293
  server.registerTool(
798
1294
  "social_connections_list",
799
1295
  {
800
- description:
801
- "List connected social media accounts available to social media analysis packs.",
1296
+ description: "List connected social media accounts available for analysis.",
802
1297
  inputSchema: { limit: z4.number().int().positive().optional() },
803
1298
  },
804
1299
  async ({ limit }) =>
@@ -812,7 +1307,7 @@ export function buildOpenGeniMcpServer(
812
1307
  server.registerTool(
813
1308
  "social_posts_recent",
814
1309
  {
815
- description: "List recent social media posts imported or synced into OpenGeni.",
1310
+ description: "List recent social media posts imported or synced into Opengeni.",
816
1311
  inputSchema: {
817
1312
  connectionIds: z4.array(z4.string().uuid()).optional(),
818
1313
  since: z4.string().optional(),
@@ -1001,7 +1496,7 @@ export function buildOpenGeniMcpServer(
1001
1496
 
1002
1497
  // Provider-scoped aliases are the canonical tools advertised by the X and
1003
1498
  // Reddit Integration cards. The legacy social_* names remain available to
1004
- // existing Packs/sessions, but these names bind the provider in the tool
1499
+
1005
1500
  // identity and reject a near-identical Connection from the other adapter.
1006
1501
  for (const provider of ["x", "reddit"] as const) {
1007
1502
  const providerName = provider === "x" ? "X" : "Reddit";
@@ -1127,7 +1622,7 @@ export function buildOpenGeniMcpServer(
1127
1622
  "social_posts_sync",
1128
1623
  {
1129
1624
  description:
1130
- "Sync the connected account's own recent posts from the provider into OpenGeni's social_posts store (idempotent), so social_posts_recent and daily analysis see fresh data.",
1625
+ "Sync the connected account's own recent posts from the provider into Opengeni's social_posts store (idempotent), so social_posts_recent and daily analysis see fresh data.",
1131
1626
  inputSchema: {
1132
1627
  connectionId: z4.string().uuid(),
1133
1628
  limit: z4.number().int().positive().optional(),
@@ -1224,7 +1719,7 @@ export function buildOpenGeniMcpServer(
1224
1719
  server.registerTool(
1225
1720
  `${provider}_posts_sync`,
1226
1721
  {
1227
- description: `Sync one exact connected ${providerName} account's recent posts into OpenGeni (idempotent).`,
1722
+ description: `Sync one exact connected ${providerName} account's recent posts into Opengeni (idempotent).`,
1228
1723
  inputSchema: {
1229
1724
  connectionId: z4.string().uuid(),
1230
1725
  limit: z4.number().int().positive().optional(),
@@ -1318,6 +1813,16 @@ export function buildOpenGeniMcpServer(
1318
1813
  }
1319
1814
 
1320
1815
  if (can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
1816
+ // Someone else's Only me schedule is indistinguishable from a missing one.
1817
+ const requireVisibleTask = async (id: string) => {
1818
+ try {
1819
+ return await requireVisibleScheduledTask(deps.db, grant, id);
1820
+ } catch (error) {
1821
+ if (error instanceof HTTPException && error.status === 404)
1822
+ throw new Error(`Scheduled task not found: ${id}`, { cause: error });
1823
+ throw error;
1824
+ }
1825
+ };
1321
1826
  server.registerTool(
1322
1827
  "scheduled_tasks_list",
1323
1828
  {
@@ -1331,7 +1836,15 @@ export function buildOpenGeniMcpServer(
1331
1836
  async ({ limit: requestedLimit, offset: requestedOffset }) => {
1332
1837
  const limit = requestedLimit ?? 25;
1333
1838
  const offset = requestedOffset ?? 0;
1334
- const rows = await listScheduledTasks(deps.db, grant.workspaceId, limit + 1, offset);
1839
+ const viewer = await scheduledTaskViewerForGrant(deps.db, grant, deps.settings);
1840
+ const rows = await listScheduledTasks(
1841
+ deps.db,
1842
+ grant.workspaceId,
1843
+ limit + 1,
1844
+ offset,
1845
+ undefined,
1846
+ viewer.subjectId,
1847
+ );
1335
1848
  return json(
1336
1849
  boundScheduledTaskMcpPage({
1337
1850
  tasks: rows.slice(0, limit).map((task) => scheduledTaskForGrant(task, grant)),
@@ -1347,17 +1860,18 @@ export function buildOpenGeniMcpServer(
1347
1860
  "scheduled_tasks_get",
1348
1861
  {
1349
1862
  description:
1350
- "Get one scheduled task. The default is the same compact summary used by scheduled_tasks_list; pass includeEntity=true for a bounded projection with an 8 KiB prompt preview, bounded goal fields, resource/tool identity previews, and metadata keys without values.",
1863
+ "Get one scheduled task. The default is the same compact summary used by scheduled_tasks_list; pass includeEntity=true for a bounded projection with an 8 KiB prompt preview, bounded goal fields, resource/tool identity previews, and metadata keys without values. For exact complete message text, pass promptOffset=0, then follow prompt.nextOffset with expectedExecutionDigest from the first page. This returns only the prompt page and preserves every character.",
1351
1864
  inputSchema: {
1352
1865
  id: z4.string().uuid(),
1353
1866
  includeEntity: z4.boolean().optional(),
1867
+ promptOffset: z4.number().int().nonnegative().max(2_147_483_647).optional(),
1868
+ expectedExecutionDigest: z4.string().min(1).max(128).optional(),
1354
1869
  },
1355
1870
  },
1356
- async ({ id, includeEntity }) => {
1357
- const task = scheduledTaskForGrant(
1358
- await requireScheduledTask(deps.db, grant.workspaceId, id),
1359
- grant,
1360
- );
1871
+ async ({ id, includeEntity, promptOffset, expectedExecutionDigest }) => {
1872
+ const task = scheduledTaskForGrant(await requireVisibleTask(id), grant);
1873
+ if (promptOffset !== undefined)
1874
+ return json(scheduledTaskPromptPage(task, promptOffset, expectedExecutionDigest));
1361
1875
  return json(
1362
1876
  includeEntity ? boundScheduledTaskDetailMcp(task) : scheduledTaskMcpSummary(task),
1363
1877
  );
@@ -1368,30 +1882,14 @@ export function buildOpenGeniMcpServer(
1368
1882
  "scheduled_tasks_create",
1369
1883
  {
1370
1884
  description:
1371
- "Create a scheduled task. Sessions generated for a task created from this session inherit this session's effective first-party tool selection and permission set; they never receive the deployment default catalog.",
1372
- inputSchema: {
1373
- name: z4.string(),
1374
- schedule: z4.unknown(),
1375
- runMode: z4.string().optional(),
1376
- targetSessionId: z4.string().uuid().nullable().optional(),
1377
- overlapPolicy: z4.string().optional(),
1378
- agentConfig: z4.unknown(),
1379
- status: z4.string().optional(),
1380
- // Explicit credential-free connection authority selections; declared
1381
- // so MCP validation doesn't strip them before the contract parse.
1382
- connectionAuthorities: z4.array(z4.unknown()).optional(),
1383
- variableSetId: z4.string().uuid().optional(),
1384
- // Deprecated alias of variableSetId; declared so MCP validation doesn't
1385
- // strip it before the contract parse maps it (rename back-compat).
1386
- environmentId: z4.string().uuid().optional(),
1387
- // Bind the task to a rig; declared so MCP validation doesn't strip it.
1388
- rigId: z4.string().uuid().nullable().optional(),
1389
- metadata: z4.record(z4.string(), z4.unknown()).optional(),
1390
- },
1885
+ "Schedule a message in this chat by supplying name, schedule and prompt. Omitted destination uses the calling chat, with its model, tools, machine and attachments at run time. Choose reusable_session or new_session_per_run and agentConfig only when the user explicitly wants a separate agent; omit agentConfig.tools so each run gets the workspace's default connectors at that time, like a new chat, or list tools to give it exactly those. Each run uses the schedule owner’s current connected accounts; it never uses another chat owner’s credentials.",
1886
+ inputSchema: contractToolInput(
1887
+ scheduledTaskCreateToolProjection(),
1888
+ scheduledTaskCreateToolValidation(sessionId),
1889
+ ),
1391
1890
  },
1392
1891
  async (args) => {
1393
- const payload = CreateScheduledTaskRequest.parse(args);
1394
- requireVariableSetsUseForMcpAttachment(grant, payload.variableSetId);
1892
+ const payload = resolveScheduledTaskCreateInput(args, sessionId);
1395
1893
  await requireLimit(deps, {
1396
1894
  accountId: grant.accountId,
1397
1895
  workspaceId: grant.workspaceId,
@@ -1434,34 +1932,23 @@ export function buildOpenGeniMcpServer(
1434
1932
  server.registerTool(
1435
1933
  "scheduled_tasks_update",
1436
1934
  {
1437
- description: "Update a scheduled task.",
1438
- inputSchema: {
1439
- id: z4.string().uuid(),
1440
- name: z4.string().optional(),
1441
- schedule: z4.unknown().optional(),
1442
- runMode: z4.string().optional(),
1443
- targetSessionId: z4.string().uuid().nullable().optional(),
1444
- overlapPolicy: z4.string().optional(),
1445
- agentConfig: z4.unknown().optional(),
1446
- status: z4.string().optional(),
1447
- // Omitted preserves the frozen selections, [] clears them, and an
1448
- // array replaces them; declared so MCP validation doesn't strip it.
1449
- connectionAuthorities: z4.array(z4.unknown()).optional(),
1450
- variableSetId: z4.string().uuid().nullable().optional(),
1451
- // Deprecated alias of variableSetId (rename back-compat); declared so MCP
1452
- // validation doesn't strip it before the contract parse maps it.
1453
- environmentId: z4.string().uuid().nullable().optional(),
1454
- // Bind the task to a rig; declared so MCP validation doesn't strip it.
1455
- rigId: z4.string().uuid().nullable().optional(),
1456
- metadata: z4.record(z4.string(), z4.unknown()).optional(),
1457
- },
1935
+ description:
1936
+ "Update a schedule without reconstructing its configuration: use prompt for message edits or targetSessionId to move it to an existing chat. Omitted fields are preserved. Send expectedExecutionDigest from get to reject stale edits. A move inherits the destination chat’s execution settings; if it reports an attached-access change, review it before retrying with adoptSessionSettings=true. For separate-agent model defaults use agentConfigPatch. agentConfig is full replacement; bounded get output is not replacement input. A schedule whose configuration.connectors is workspace_default keeps following the workspace's default connectors; its agentConfig.tools lists only connectors added on top.",
1937
+ inputSchema: scheduledTaskUpdateToolInput(),
1458
1938
  },
1459
1939
  async ({ id, ...raw }) => {
1460
- const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
1940
+ const existing = await requireVisibleTask(id);
1461
1941
  const previous = await captureScheduledTaskRestoreState(deps.db, existing);
1462
1942
  const payload = UpdateScheduledTaskRequest.parse(raw);
1463
- requireVariableSetsUseForMcpAttachment(grant, payload.variableSetId);
1464
- const update = await validatedScheduledTaskUpdate({
1943
+ const patchWarnings = (task: ScheduledTask) =>
1944
+ (payload.agentConfigPatch?.model !== undefined ||
1945
+ payload.agentConfigPatch?.reasoningEffort !== undefined) &&
1946
+ (task.runMode === "existing_session" || task.reusableSessionId)
1947
+ ? [
1948
+ "The task uses an existing session, whose model and reasoning are unchanged. Change that session separately if intended.",
1949
+ ]
1950
+ : [];
1951
+ const updateOrError = await validatedScheduledTaskUpdate({
1465
1952
  settings: deps.settings,
1466
1953
  db: deps.db,
1467
1954
  objectStorage: deps.objectStorage,
@@ -1471,18 +1958,43 @@ export function buildOpenGeniMcpServer(
1471
1958
  toolsProvided: scheduledTaskToolsProvided(raw),
1472
1959
  sessionAuthorization: deps.sessionAuthorization,
1473
1960
  authorizationSurface: "first_party_mcp",
1961
+ }).catch((error: unknown) => {
1962
+ const conflict = scheduledTaskTargetAccessHttpError(error);
1963
+ if (conflict) return conflict;
1964
+ throw error;
1474
1965
  });
1966
+ if (updateOrError instanceof ApiHttpError) {
1967
+ return {
1968
+ isError: true,
1969
+ ...json({
1970
+ error: {
1971
+ code: updateOrError.code,
1972
+ message: updateOrError.message,
1973
+ details: updateOrError.details,
1974
+ },
1975
+ }),
1976
+ };
1977
+ }
1978
+ const update = updateOrError;
1475
1979
  if (!scheduledTaskUpdateChangesState(existing, update)) {
1476
- return json(scheduledTaskReceipt("scheduled_tasks_update", existing, "unchanged", false));
1980
+ return json(
1981
+ scheduledTaskReceipt("scheduled_tasks_update", existing, "unchanged", false, {
1982
+ warnings: patchWarnings(existing),
1983
+ }),
1984
+ );
1477
1985
  }
1478
- const task = await updateScheduledTaskForApi(deps.db, grant.workspaceId, id, update);
1986
+ const task = await updateScheduledTaskForApi(deps.db, grant, id, update);
1479
1987
  await syncUpdatedScheduledTask({
1480
1988
  db: deps.db,
1481
1989
  workflowClient: deps.workflowClient,
1482
1990
  previous,
1483
1991
  task,
1484
1992
  });
1485
- return json(scheduledTaskReceipt("scheduled_tasks_update", task, "updated", true));
1993
+ return json(
1994
+ scheduledTaskReceipt("scheduled_tasks_update", task, "updated", true, {
1995
+ warnings: patchWarnings(task),
1996
+ }),
1997
+ );
1486
1998
  },
1487
1999
  );
1488
2000
 
@@ -1493,14 +2005,34 @@ export function buildOpenGeniMcpServer(
1493
2005
  inputSchema: { id: z4.string().uuid() },
1494
2006
  },
1495
2007
  async ({ id }) => {
1496
- const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
2008
+ // Admins can pause any schedule, including someone else's Only me one,
2009
+ // which they only ever see as a summary.
2010
+ const viewer = await scheduledTaskViewerForGrant(deps.db, grant, deps.settings);
2011
+ let pausable: Awaited<ReturnType<typeof requirePausableScheduledTask>>;
2012
+ try {
2013
+ pausable = await requirePausableScheduledTask(deps.db, grant, id, viewer);
2014
+ } catch (error) {
2015
+ if (error instanceof HTTPException && error.status === 404)
2016
+ throw new Error(`Scheduled task not found: ${id}`, { cause: error });
2017
+ throw error;
2018
+ }
2019
+ if (pausable.access === "summary") {
2020
+ const changed = pausable.summary.status !== "paused";
2021
+ if (changed) await pausePrivateScheduledTaskAsAdmin(deps.db, grant, id, viewer);
2022
+ return json({ operation: "scheduled_tasks_pause", id, status: "paused", changed });
2023
+ }
2024
+ const existing = pausable.task;
1497
2025
  if (existing.status === "paused") {
1498
2026
  return json(scheduledTaskReceipt("scheduled_tasks_pause", existing, "unchanged", false));
1499
2027
  }
1500
2028
  const previous = await captureScheduledTaskRestoreState(deps.db, existing);
1501
- const task = await updateScheduledTaskForApi(deps.db, grant.workspaceId, id, {
1502
- status: "paused",
1503
- });
2029
+ const adminPause =
2030
+ viewer.admin &&
2031
+ (viewer.subjectId === null || existing.ownerSubjectId !== viewer.subjectId);
2032
+ if (adminPause) await assertNotSiteClampedAgent(deps.db, grant, "schedules");
2033
+ const task = adminPause
2034
+ ? await pauseScheduledTaskAsAdmin(deps.db, grant.workspaceId, id)
2035
+ : await updateScheduledTaskForApi(deps.db, grant, id, { status: "paused" });
1504
2036
  await syncUpdatedScheduledTask({
1505
2037
  db: deps.db,
1506
2038
  workflowClient: deps.workflowClient,
@@ -1518,7 +2050,7 @@ export function buildOpenGeniMcpServer(
1518
2050
  inputSchema: { id: z4.string().uuid() },
1519
2051
  },
1520
2052
  async ({ id }) => {
1521
- const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
2053
+ const existing = await requireVisibleTask(id);
1522
2054
  if (existing.status === "active") {
1523
2055
  return json(scheduledTaskReceipt("scheduled_tasks_resume", existing, "unchanged", false));
1524
2056
  }
@@ -1533,7 +2065,7 @@ export function buildOpenGeniMcpServer(
1533
2065
  sessionAuthorization: deps.sessionAuthorization,
1534
2066
  authorizationSurface: "first_party_mcp",
1535
2067
  });
1536
- const task = await updateScheduledTaskForApi(deps.db, grant.workspaceId, id, update);
2068
+ const task = await updateScheduledTaskForApi(deps.db, grant, id, update);
1537
2069
  await syncUpdatedScheduledTask({
1538
2070
  db: deps.db,
1539
2071
  workflowClient: deps.workflowClient,
@@ -1555,7 +2087,7 @@ export function buildOpenGeniMcpServer(
1555
2087
  },
1556
2088
  },
1557
2089
  async ({ id, triggerId }) => {
1558
- const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
2090
+ const task = await requireVisibleTask(id);
1559
2091
  if (task.action.kind === "agent_turn") {
1560
2092
  const targetSession = await validateScheduledTaskTarget({
1561
2093
  db: deps.db,
@@ -1583,15 +2115,27 @@ export function buildOpenGeniMcpServer(
1583
2115
  grant,
1584
2116
  runMode: task.runMode,
1585
2117
  agentConfig: task.agentConfig,
2118
+ audience: task.visibility === "private" ? "user_private" : "workspace_shared",
1586
2119
  requireOnline: true,
1587
2120
  });
1588
- await requireLimit(deps, {
1589
- accountId: grant.accountId,
1590
- workspaceId: grant.workspaceId,
1591
- action: "agent_run:create",
1592
- quantity: 1,
1593
- model: task.agentConfig.model ?? deps.settings.openaiModel,
2121
+ const taskAuthoritySubjectId = await getScheduledTaskRevisionAuthoritySubject(deps.db, {
2122
+ accountId: task.accountId,
2123
+ workspaceId: task.workspaceId,
2124
+ taskId: task.id,
2125
+ taskAuthorityRevision: task.authorityRevision,
1594
2126
  });
2127
+ await requireLimit(
2128
+ { ...deps, settings: catalogSettings },
2129
+ {
2130
+ accountId: grant.accountId,
2131
+ workspaceId: grant.workspaceId,
2132
+ initiatingHumanSubjectId:
2133
+ taskAuthoritySubjectId === task.ownerSubjectId ? taskAuthoritySubjectId : null,
2134
+ action: "agent_run:create",
2135
+ quantity: 1,
2136
+ model: await resolveScheduledTaskPreflightModel(deps.db, catalogSettings, task),
2137
+ },
2138
+ );
1595
2139
  }
1596
2140
  const triggerToken = scheduledTaskTriggerToken(triggerId);
1597
2141
  const agentRunUsageIdempotencyKey =
@@ -1599,7 +2143,7 @@ export function buildOpenGeniMcpServer(
1599
2143
  ? manualScheduledTaskTriggerUsageKey(grant.workspaceId, task.id, triggerToken)
1600
2144
  : `knowledge-source-sync:manual:${grant.workspaceId}:${task.id}:${triggerToken}`;
1601
2145
  const triggerWorkflowId = manualScheduledTaskTriggerWorkflowId(task.id, triggerToken);
1602
- await deps.workflowClient.triggerScheduledTask({
2146
+ await triggerScheduledTaskForGrant(deps.db, grant, deps.workflowClient, {
1603
2147
  task,
1604
2148
  agentRunUsageIdempotencyKey,
1605
2149
  triggerWorkflowId,
@@ -1653,10 +2197,18 @@ export function buildOpenGeniMcpServer(
1653
2197
  inputSchema: { id: z4.string().uuid() },
1654
2198
  },
1655
2199
  async ({ id }) => {
2200
+ const target = await getScheduledTaskIncludingDeleted(deps.db, grant.workspaceId, id);
2201
+ if (
2202
+ target &&
2203
+ scheduledTaskAccess(
2204
+ await scheduledTaskViewerForGrant(deps.db, grant, deps.settings),
2205
+ target,
2206
+ ) !== "full"
2207
+ )
2208
+ throw new Error(`Scheduled task not found: ${id}`);
1656
2209
  const { task, changed } = await deleteScheduledTaskWithDurableCleanup(deps, {
1657
- workspaceId: grant.workspaceId,
2210
+ grant,
1658
2211
  taskId: id,
1659
- subjectId: grant.subjectId,
1660
2212
  });
1661
2213
  return json(
1662
2214
  mcpMutationReceipt({
@@ -1680,12 +2232,14 @@ export function buildOpenGeniMcpServer(
1680
2232
  limit: z4.number().int().positive().optional(),
1681
2233
  },
1682
2234
  },
1683
- async ({ taskId, limit }) =>
1684
- json({
2235
+ async ({ taskId, limit }) => {
2236
+ await requireVisibleTask(taskId);
2237
+ return json({
1685
2238
  runs: (await listScheduledTaskRuns(deps.db, grant.workspaceId, taskId, limit ?? 100)).map(
1686
2239
  (run) => scheduledTaskRunForGrant(run, grant),
1687
2240
  ),
1688
- }),
2241
+ });
2242
+ },
1689
2243
  );
1690
2244
  }
1691
2245
 
@@ -1705,7 +2259,7 @@ function registerSandboxFileArtifactTool(
1705
2259
  "sandbox_file_publish",
1706
2260
  {
1707
2261
  description:
1708
- "Publish one exact file from this session's /workspace into durable workspace storage. Use this before presenting a ZIP, CSV, JSON, Markdown, HTML, PDF, Office file, or other sandbox output as downloadable. Returns a permanent, authenticated artifact receipt; never invent or expose a sandbox: URL as the durable result.",
2262
+ "Publish one exact file from this session's /workspace into durable workspace storage. Use this before presenting a video, ZIP, CSV, JSON, Markdown, HTML, PDF, Office file, or other sandbox output as downloadable. Present the returned artifact.artifactId as [Open file](artifact:<artifactId>), or ![Preview](artifact:<artifactId>) for inline image/video/audio/PDF previews. Replace <artifactId> with the exact returned ID and use a descriptive label. Never expose a sandbox: URL as the durable result. The artifactId is also the file's workspace File ID, so tools that take workspace file IDs, such as a browser upload action, accept it.",
1709
2263
  inputSchema: {
1710
2264
  path: z4.string().min(1).max(4_096),
1711
2265
  },
@@ -1738,14 +2292,29 @@ function registerSlackBotTools(
1738
2292
  sessionId,
1739
2293
  ...(connectionId ? { requestedConnectionId: connectionId } : {}),
1740
2294
  });
1741
- return createOpenGeniSlackBotClient(deps, resolved);
2295
+ return createOpenGeniSlackBotClient(
2296
+ {
2297
+ ...deps,
2298
+ authorizeProviderRequest: async () => {
2299
+ if (sessionId !== null)
2300
+ await authorizeFirstPartySession(
2301
+ deps,
2302
+ grant,
2303
+ sessionId,
2304
+ "session.first_party_mcp.call",
2305
+ );
2306
+ return true;
2307
+ },
2308
+ },
2309
+ resolved,
2310
+ );
1742
2311
  };
1743
2312
 
1744
2313
  server.registerTool(
1745
2314
  "slack_bot_list_channels",
1746
2315
  {
1747
2316
  description:
1748
- "List public and bot-visible private Slack channels through the workspace-shared OpenGeni bot. isMember identifies channels the bot may read/post in; the bot never joins channels automatically.",
2317
+ "List public and bot-visible private Slack channels through the workspace-shared Opengeni bot. isMember identifies channels the bot may read/post in; the bot never joins channels automatically.",
1749
2318
  inputSchema: {
1750
2319
  connectionId: z4.string().uuid().optional(),
1751
2320
  cursor: z4.string().max(1024).optional(),
@@ -1763,62 +2332,14 @@ function registerSlackBotTools(
1763
2332
  ),
1764
2333
  );
1765
2334
 
1766
- server.registerTool(
1767
- "slack_bot_search",
1768
- {
1769
- description:
1770
- "Search public Slack channels workspace-wide as the workspace-shared OpenGeni bot: messages by default, plus files and channels via contentTypes. Public content only; private channels, DMs, and group DMs are searchable only through a member's personal Slack connection. Requires a bot install with the search scopes; older installs must be reinstalled by an admin. Continue with cursor for more results.",
1771
- inputSchema: {
1772
- connectionId: z4.string().uuid().optional(),
1773
- query: z4.string().min(1).max(500),
1774
- contentTypes: z4
1775
- .array(z4.enum(["messages", "files", "channels"]))
1776
- .min(1)
1777
- .max(3)
1778
- .optional(),
1779
- includeBots: z4.boolean().optional(),
1780
- before: z4.number().int().min(0).optional(),
1781
- after: z4.number().int().min(0).optional(),
1782
- sort: z4.enum(["score", "timestamp"]).optional(),
1783
- sortDir: z4.enum(["asc", "desc"]).optional(),
1784
- cursor: z4.string().max(1024).optional(),
1785
- limit: z4.number().int().min(1).max(20).optional(),
1786
- },
1787
- },
1788
- async ({
1789
- connectionId,
1790
- query,
1791
- contentTypes,
1792
- includeBots,
1793
- before,
1794
- after,
1795
- sort,
1796
- sortDir,
1797
- cursor,
1798
- limit,
1799
- }) =>
1800
- json(
1801
- await (
1802
- await clientFor(connectionId)
1803
- ).searchContext({
1804
- query,
1805
- ...(contentTypes ? { contentTypes } : {}),
1806
- ...(includeBots !== undefined ? { includeBots } : {}),
1807
- ...(before !== undefined ? { before } : {}),
1808
- ...(after !== undefined ? { after } : {}),
1809
- ...(sort ? { sort } : {}),
1810
- ...(sortDir ? { sortDir } : {}),
1811
- ...(cursor ? { cursor } : {}),
1812
- ...(limit !== undefined ? { limit } : {}),
1813
- }),
1814
- ),
1815
- );
2335
+ // Real-time Search requires an approved app and a trusted Slack interaction
2336
+ // action token. Generic agent calls cannot provide that interaction proof.
1816
2337
 
1817
2338
  server.registerTool(
1818
2339
  "slack_bot_channel_history",
1819
2340
  {
1820
2341
  description:
1821
- "Read Slack channel history as the workspace-shared OpenGeni bot. Public and private channels both require bot membership; invite the bot to private channels first.",
2342
+ "Read Slack channel history as the workspace-shared Opengeni bot. Public and private channels both require bot membership; invite the bot to private channels first.",
1822
2343
  inputSchema: {
1823
2344
  connectionId: z4.string().uuid().optional(),
1824
2345
  channelId: z4.string().min(1).max(64),
@@ -1842,7 +2363,7 @@ function registerSlackBotTools(
1842
2363
  "slack_bot_thread_replies",
1843
2364
  {
1844
2365
  description:
1845
- "Read a Slack thread as the workspace-shared OpenGeni bot. Pass the channel ID and the parent message timestamp returned by channel history. The result includes the parent followed by its replies.",
2366
+ "Read a Slack thread as the workspace-shared Opengeni bot. Pass the channel ID and the parent message timestamp returned by channel history. The result includes the parent followed by its replies.",
1846
2367
  inputSchema: {
1847
2368
  connectionId: z4.string().uuid().optional(),
1848
2369
  channelId: z4.string().min(1).max(64),
@@ -1867,7 +2388,7 @@ function registerSlackBotTools(
1867
2388
  server.registerTool(
1868
2389
  "slack_bot_list_users",
1869
2390
  {
1870
- description: "List Slack workspace users through the workspace-shared OpenGeni bot.",
2391
+ description: "List Slack workspace users through the workspace-shared Opengeni bot.",
1871
2392
  inputSchema: {
1872
2393
  connectionId: z4.string().uuid().optional(),
1873
2394
  cursor: z4.string().max(1024).optional(),
@@ -1889,7 +2410,7 @@ function registerSlackBotTools(
1889
2410
  "slack_bot_list_files",
1890
2411
  {
1891
2412
  description:
1892
- "List Slack files and canvases shared with a channel where the workspace-shared OpenGeni bot is already a member.",
2413
+ "List Slack files and canvases shared with a channel where the workspace-shared Opengeni bot is already a member.",
1893
2414
  inputSchema: {
1894
2415
  connectionId: z4.string().uuid().optional(),
1895
2416
  channelId: z4.string().min(1).max(64),
@@ -1913,7 +2434,7 @@ function registerSlackBotTools(
1913
2434
  "slack_bot_file_info",
1914
2435
  {
1915
2436
  description:
1916
- "Read safe metadata for a Slack file or canvas shared with a channel where the workspace-shared OpenGeni bot is already a member. For an embedded huddle transcript, also pass the shared canvas file ID as parentFileId so OpenGeni can verify the indirect share.",
2437
+ "Read safe metadata for a Slack file or canvas shared with a channel where the workspace-shared Opengeni bot is already a member. For an embedded huddle transcript, also pass the shared canvas file ID as parentFileId so Opengeni can verify the indirect share.",
1917
2438
  inputSchema: {
1918
2439
  connectionId: z4.string().uuid().optional(),
1919
2440
  channelId: z4.string().min(1).max(64),
@@ -1937,7 +2458,7 @@ function registerSlackBotTools(
1937
2458
  "slack_bot_file_content",
1938
2459
  {
1939
2460
  description:
1940
- "Read a bounded page of UTF-8 text from a Slack file or canvas shared with a channel where the workspace-shared OpenGeni bot is already a member. For an embedded huddle transcript, also pass the shared canvas file ID as parentFileId so OpenGeni can verify the channel-to-canvas-to-transcript chain. Slack may still restrict a huddle transcript body to participants; that returns huddle_transcript_requires_participant_access. Private Slack URLs and credentials are never returned. Continue with nextOffset when truncated is true.",
2461
+ "Read a bounded page of text or view a PNG, JPEG, or WebP image from a Slack file shared with a channel where the workspace-shared Opengeni bot is already a member. Use the file ID from thread replies to view images in earlier thread messages. Images are returned as viewable content, only when directly shared to a non-shared channel, up to 640 KiB; offset must be 0. For an embedded huddle transcript, also pass the shared canvas file ID as parentFileId so Opengeni can verify the channel-to-canvas-to-transcript chain. Slack may still restrict a huddle transcript body to participants; that returns huddle_transcript_requires_participant_access. Private Slack URLs and credentials are never returned. Continue with nextOffset for truncated text.",
1941
2462
  inputSchema: {
1942
2463
  connectionId: z4.string().uuid().optional(),
1943
2464
  channelId: z4.string().min(1).max(64),
@@ -1945,33 +2466,134 @@ function registerSlackBotTools(
1945
2466
  parentFileId: z4.string().min(1).max(64).optional(),
1946
2467
  offset: z4.number().int().min(0).max(4_000_000).optional(),
1947
2468
  },
2469
+ outputSchema: {
2470
+ kind: z4.enum(["text", "image"]),
2471
+ fileId: z4.string(),
2472
+ contentType: z4.string(),
2473
+ content: z4.string().nullable(),
2474
+ sizeBytes: z4.number().int().nullable(),
2475
+ nextOffset: z4.number().int().nullable(),
2476
+ },
1948
2477
  },
1949
- async ({ connectionId, channelId, fileId, parentFileId, offset }) =>
1950
- json(
1951
- await (
1952
- await clientFor(connectionId)
1953
- ).fileContent({
1954
- channelId,
2478
+ async ({ connectionId, channelId, fileId, parentFileId, offset }) => {
2479
+ const result = await (
2480
+ await clientFor(connectionId)
2481
+ ).fileContent({
2482
+ channelId,
2483
+ fileId,
2484
+ ...(parentFileId ? { parentFileId } : {}),
2485
+ ...(offset !== undefined ? { offset } : {}),
2486
+ });
2487
+ return slackBotFileContentResult(result);
2488
+ },
2489
+ );
2490
+
2491
+ server.registerTool(
2492
+ "slack_bot_upload_file",
2493
+ {
2494
+ description:
2495
+ "Upload one explicitly selected retained workspace file (including generated images) into this session's existing Slack task thread as the Opengeni bot. Use the file/artifact UUID returned by sandbox_file_publish or image generation. No channel or URL is accepted. Generate one operationId UUID per intended delivery and reuse the same operationId on every retry, including unknown outcomes; never start a replacement delivery to retry. Requires the bot's optional files:write scope: a Slack administrator must apply the bot manifest and reinstall an older bot, not connect a personal Slack account. Nonempty files up to 25 MiB; personal files stay in private task threads. Does not automatically upload files merely because they appear in a message.",
2496
+ inputSchema: { fileId: z4.string().uuid(), operationId: z4.string().uuid() },
2497
+ },
2498
+ async ({ fileId, operationId }) => {
2499
+ if (!sessionId) throw new Error("File upload requires an existing Slack task session");
2500
+ return json(
2501
+ await uploadSlackTaskFile(deps, {
2502
+ grant,
2503
+ sessionId,
1955
2504
  fileId,
1956
- ...(parentFileId ? { parentFileId } : {}),
1957
- ...(offset !== undefined ? { offset } : {}),
2505
+ operationId,
2506
+ authorize: async () => {
2507
+ await authorizeFirstPartySession(
2508
+ deps,
2509
+ grant,
2510
+ sessionId,
2511
+ "session.first_party_mcp.call",
2512
+ );
2513
+ },
1958
2514
  }),
1959
- ),
2515
+ );
2516
+ },
1960
2517
  );
1961
2518
 
2519
+ // Scheduled runs retain their person-chosen channel; ordinary chats supply
2520
+ // an explicit destination. A server-owned prepared message id is the durable
2521
+ // Slack delivery identity in both cases.
2522
+ const authorizeBotPost = async () => {
2523
+ if (sessionId === null) {
2524
+ throw new Error("Bot posting requires a chat");
2525
+ }
2526
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
2527
+ };
1962
2528
  server.registerTool(
1963
- "slack_bot_delete_message",
2529
+ "slack_bot_prepare_message",
1964
2530
  {
1965
2531
  description:
1966
- "Delete a message authored by the workspace-shared OpenGeni bot. Pass the channel ID and exact message timestamp returned by a prior post or channel/thread read. Generate one operationId UUID per intended deletion and reuse it on every retry, including after an unknown outcome. Slack refuses deletion of messages not authored by this bot.",
2532
+ "Prepare a message as the Opengeni bot, never as a personal Slack account. In an ordinary chat supply channelId and, if multiple bots are available, connectionId. Scheduled runs omit both and use only the channel chosen by a person in Post to Slack. Nothing is sent until slack_bot_send_prepared_message is called with the returned messageId. Reuse that messageId for retries. Bot membership and organization sharing are checked when sending.",
1967
2533
  inputSchema: {
1968
2534
  connectionId: z4.string().uuid().optional(),
1969
- operationId: z4.string().uuid(),
1970
- channelId: z4.string().min(1).max(64),
1971
- timestamp: z4.string().min(1).max(64),
2535
+ channelId: z4
2536
+ .string()
2537
+ .regex(/^[CG][A-Z0-9]{2,63}$/)
2538
+ .optional(),
2539
+ text: z4.string().min(1).max(40_000),
2540
+ threadTimestamp: z4
2541
+ .string()
2542
+ .regex(/^\d{1,20}\.\d{1,12}$/)
2543
+ .optional(),
1972
2544
  },
1973
2545
  },
1974
- async ({ connectionId, operationId, channelId, timestamp }) =>
2546
+ async ({ text, threadTimestamp, channelId, connectionId }) => {
2547
+ await authorizeBotPost();
2548
+ return json(
2549
+ await prepareSlackBotPost({
2550
+ db: deps.db,
2551
+ grant,
2552
+ sessionId,
2553
+ text,
2554
+ ...(channelId ? { channelId } : {}),
2555
+ ...(connectionId ? { connectionId } : {}),
2556
+ ...(threadTimestamp ? { threadTimestamp } : {}),
2557
+ }),
2558
+ );
2559
+ },
2560
+ );
2561
+ server.registerTool(
2562
+ "slack_bot_send_prepared_message",
2563
+ {
2564
+ description:
2565
+ "Send a message prepared by slack_bot_prepare_message in this chat, exactly as saved, as the Opengeni bot. If a send is interrupted or its outcome is unclear, retry with the same messageId: Opengeni checks Slack and never posts the same message twice. Do not prepare a new message just to retry.",
2566
+ inputSchema: { messageId: z4.string().uuid() },
2567
+ },
2568
+ async ({ messageId }) => {
2569
+ await authorizeBotPost();
2570
+ return json(
2571
+ await sendSlackBotPost({
2572
+ db: deps.db,
2573
+ settings: deps.settings,
2574
+ grant,
2575
+ sessionId,
2576
+ messageId,
2577
+ ...(deps.slackFetch ? { slackFetch: deps.slackFetch } : {}),
2578
+ authorizeProviderRequest: authorizeBotPost,
2579
+ }),
2580
+ );
2581
+ },
2582
+ );
2583
+
2584
+ server.registerTool(
2585
+ "slack_bot_delete_message",
2586
+ {
2587
+ description:
2588
+ "Delete a message authored by the workspace-shared Opengeni bot. Pass the channel ID and exact message timestamp returned by a prior post or channel/thread read. Generate one operationId UUID per intended deletion and reuse it on every retry, including after an unknown outcome. Slack refuses deletion of messages not authored by this bot.",
2589
+ inputSchema: {
2590
+ connectionId: z4.string().uuid().optional(),
2591
+ operationId: z4.string().uuid(),
2592
+ channelId: z4.string().min(1).max(64),
2593
+ timestamp: z4.string().min(1).max(64),
2594
+ },
2595
+ },
2596
+ async ({ connectionId, operationId, channelId, timestamp }) =>
1975
2597
  json(
1976
2598
  await (await clientFor(connectionId)).deleteMessage({ operationId, channelId, timestamp }),
1977
2599
  ),
@@ -2225,122 +2847,6 @@ function registerFikenTools(
2225
2847
  );
2226
2848
  }
2227
2849
 
2228
- function registerAtlassianTools(
2229
- server: McpServer,
2230
- deps: ApiRouteDeps,
2231
- grant: AccessGrant,
2232
- json: JsonResult,
2233
- ): void {
2234
- const connectionFor = async (connectionId?: string) => {
2235
- const authorized = await authorizedAtlassianConnectionsForGrant({
2236
- db: deps.db,
2237
- grant,
2238
- });
2239
- const candidates = authorized.filter(({ connection }) =>
2240
- connectionId ? connection.id === connectionId : true,
2241
- );
2242
- if (candidates.length === 0) {
2243
- throw new Error(
2244
- connectionId
2245
- ? "the requested Atlassian connection is unavailable for this turn"
2246
- : "no Atlassian connection is available for this turn",
2247
- );
2248
- }
2249
- if (!connectionId && candidates.length > 1) {
2250
- throw new Error(
2251
- "connectionId is required because multiple Atlassian connections are available",
2252
- );
2253
- }
2254
- const authority = candidates[0]!;
2255
- const metadata = AtlassianConnectionMetadata.safeParse(authority.connection.metadata);
2256
- if (!metadata.success) throw new Error("Atlassian connection metadata is invalid");
2257
- return {
2258
- connection: authority.connection,
2259
- metadata: metadata.data,
2260
- subjectId: authority.subjectId ?? grant.subjectId,
2261
- };
2262
- };
2263
-
2264
- server.registerTool(
2265
- "atlassian_sources_list",
2266
- {
2267
- description:
2268
- "List the Jira projects and Confluence spaces available through the authorized Atlassian connection, including which sources are selected for OpenGeni. Use this before search when the site or boundary is unclear.",
2269
- inputSchema: { connectionId: z4.string().uuid().optional() },
2270
- },
2271
- async ({ connectionId }) => {
2272
- const authority = await connectionFor(connectionId);
2273
- const response = await browseAtlassianSources(deps, {
2274
- workspaceId: grant.workspaceId,
2275
- subjectId: authority.subjectId,
2276
- connectionId: authority.connection.id,
2277
- });
2278
- const selected = new Set(authority.metadata.selectedSources.map((source) => source.id));
2279
- return json({
2280
- connectionId: authority.connection.id,
2281
- account: authority.metadata.displayName,
2282
- items: response.items.map((item) => ({
2283
- ...item,
2284
- selected: selected.has(item.id),
2285
- })),
2286
- });
2287
- },
2288
- );
2289
-
2290
- server.registerTool(
2291
- "atlassian_search",
2292
- {
2293
- description:
2294
- "Search Jira issues and Confluence pages live within the projects and spaces selected for OpenGeni. Results reflect current Atlassian data and permissions, independent of the knowledge sync index.",
2295
- inputSchema: {
2296
- connectionId: z4.string().uuid().optional(),
2297
- query: z4.string().min(1).max(500),
2298
- product: z4.enum(["jira", "confluence"]).optional(),
2299
- limit: z4.number().int().min(1).max(50).optional(),
2300
- },
2301
- },
2302
- async ({ connectionId, query, product, limit }) => {
2303
- const authority = await connectionFor(connectionId);
2304
- return json({
2305
- connectionId: authority.connection.id,
2306
- results: await searchAtlassianLive(deps, {
2307
- workspaceId: grant.workspaceId,
2308
- subjectId: authority.subjectId,
2309
- connectionId: authority.connection.id,
2310
- query,
2311
- ...(product ? { product } : {}),
2312
- limit: limit ?? 20,
2313
- }),
2314
- });
2315
- },
2316
- );
2317
-
2318
- server.registerTool(
2319
- "atlassian_get",
2320
- {
2321
- description:
2322
- "Open one current Jira issue or Confluence page, including description or page content and comments. The item must belong to a project or space selected for OpenGeni.",
2323
- inputSchema: {
2324
- connectionId: z4.string().uuid().optional(),
2325
- kind: z4.enum(["jira_issue", "confluence_page"]),
2326
- id: z4.string().min(1).max(256),
2327
- },
2328
- },
2329
- async ({ connectionId, kind, id }) => {
2330
- const authority = await connectionFor(connectionId);
2331
- return json(
2332
- await getAtlassianLiveItem(deps, {
2333
- workspaceId: grant.workspaceId,
2334
- subjectId: authority.subjectId,
2335
- connectionId: authority.connection.id,
2336
- kind,
2337
- id,
2338
- }),
2339
- );
2340
- },
2341
- );
2342
- }
2343
-
2344
2850
  /** Only a prompt explicitly supplied through the human/API channel may redirect a user-paused goal. */
2345
2851
  export function isHumanDirectedTurn(turn: { source: string }): boolean {
2346
2852
  return turn.source === "user" || turn.source === "api";
@@ -2393,34 +2899,49 @@ function registerGoalTools(
2393
2899
  sessionId: string,
2394
2900
  json: (value: unknown) => { content: Array<{ type: "text"; text: string }> },
2395
2901
  ): void {
2396
- const boundedGoalToolString = (maxBytes: number, field: string) =>
2902
+ const boundedGoalToolString = (maxBytes: number, field: string, purpose: string) =>
2397
2903
  z4
2398
2904
  .string()
2399
2905
  .min(1)
2906
+ .max(maxBytes)
2907
+ .describe(
2908
+ `${purpose} At most ${maxBytes} UTF-8 bytes (ASCII characters count as one byte). Use normal spaces and sentences; summarize if needed, never squeeze words together. This is a ledger field, not the final answer.`,
2909
+ )
2400
2910
  .refine((value) => sessionGoalUtf8Bytes(value) <= maxBytes, {
2401
2911
  message: `${field} exceeds ${maxBytes} UTF-8 bytes`,
2402
2912
  });
2403
- const goalText = boundedGoalToolString(SESSION_GOAL_TEXT_MAX_BYTES, "goal text");
2913
+ const goalText = boundedGoalToolString(
2914
+ SESSION_GOAL_TEXT_MAX_BYTES,
2915
+ "goal text",
2916
+ "The human-readable standing objective, not a progress report.",
2917
+ );
2404
2918
  const successCriteriaSchema = boundedGoalToolString(
2405
2919
  SESSION_GOAL_SUCCESS_CRITERIA_MAX_BYTES,
2406
2920
  "goal success criteria",
2921
+ "Human-readable conditions that prove the full objective is achieved.",
2922
+ );
2923
+ const goalRationale = boundedGoalToolString(
2924
+ SESSION_GOAL_RATIONALE_MAX_BYTES,
2925
+ "goal rationale",
2926
+ "A short human-readable explanation of the goal change or blocker and what must happen next.",
2407
2927
  );
2408
- const goalRationale = boundedGoalToolString(SESSION_GOAL_RATIONALE_MAX_BYTES, "goal rationale");
2409
2928
  const progressNoteSchema = boundedGoalToolString(
2410
2929
  SESSION_GOAL_PROGRESS_MAX_BYTES,
2411
2930
  "goal progress note",
2931
+ "A short human-readable status of concrete progress toward the unchanged goal. Keep only useful milestones; do not pack a deliverable, command transcript, or continuation instructions here.",
2412
2932
  );
2413
- const inputWaitReasonSchema = boundedGoalToolString(2 * 1024, "session input wait reason").refine(
2414
- (value) => value.trim().length > 0,
2415
- {
2416
- message: "session input wait reason must not be blank",
2417
- },
2418
- );
2933
+ const inputWaitReasonSchema = boundedGoalToolString(
2934
+ 2 * 1024,
2935
+ "session input wait reason",
2936
+ "One short human-readable sentence explaining the dependency being awaited.",
2937
+ ).refine((value) => value.trim().length > 0, {
2938
+ message: "session input wait reason must not be blank",
2939
+ });
2419
2940
  server.registerTool(
2420
2941
  "goal_set",
2421
2942
  {
2422
2943
  description:
2423
- "Create a goal when this session has none, or replace a completed goal with a new one. While active, idle moments synthesize continuation turns until goal_complete or goal_pause. To change an active or paused goal, use goal_update with its objective revision, a change kind, and rationale.",
2944
+ "Create a goal when this session has none, or replace a completed goal with a new one. text is the human-readable objective and successCriteria states its completion conditions; each allows 8192 UTF-8 bytes. Keep normal spaces; summarize, never compress words or put the deliverable in ledger text. Declare user-facing native document reports with reportRequirements before producing them. While active, idle moments synthesize continuation turns until goal_complete or goal_pause. To change an active or paused goal, use goal_update with its objective revision, a change kind, and rationale.",
2424
2945
  // `maxAutoContinuations` is deliberately not agent-facing: the ceiling is
2425
2946
  // API/scheduled-task pacing configuration, and an agent that set its own
2426
2947
  // cap used to silence its orchestration for hours. Continuation pacing is
@@ -2428,9 +2949,10 @@ function registerGoalTools(
2428
2949
  inputSchema: {
2429
2950
  text: goalText,
2430
2951
  successCriteria: successCriteriaSchema.optional(),
2952
+ reportRequirements: SessionGoalReportRequirements.optional(),
2431
2953
  },
2432
2954
  },
2433
- async ({ text, successCriteria }) => {
2955
+ async ({ text, successCriteria, reportRequirements }) => {
2434
2956
  await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
2435
2957
  await requireSession(deps.db, grant.workspaceId, sessionId);
2436
2958
  const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
@@ -2444,15 +2966,24 @@ function registerGoalTools(
2444
2966
  ? (grant.metadata["turnId"] as string)
2445
2967
  : null;
2446
2968
  await assertGoalReactivationAllowed(deps, grant.workspaceId, sessionId, callerTurnId);
2969
+ const context = exactAgentCommandContext(grant, sessionId);
2447
2970
  const { goal, replaced, events } = await upsertSessionGoalWithEvent(deps.db, {
2448
2971
  accountId: grant.accountId,
2449
2972
  workspaceId: grant.workspaceId,
2450
2973
  sessionId,
2451
2974
  text,
2452
2975
  successCriteria: successCriteria ?? null,
2976
+ ...(reportRequirements !== undefined ? { reportRequirements } : {}),
2453
2977
  maxAutoContinuations: null,
2454
2978
  createdBy: "agent",
2455
2979
  actor: "agent",
2980
+ commandActor: {
2981
+ type: "agent_attempt",
2982
+ sessionId: context.callerSessionId,
2983
+ turnId: context.callerTurnId,
2984
+ attemptId: context.callerAttemptId,
2985
+ executionGeneration: context.callerExecutionGeneration,
2986
+ },
2456
2987
  });
2457
2988
  if (events.length > 0) {
2458
2989
  await deps.bus.publish(grant.workspaceId, sessionId, events);
@@ -2482,7 +3013,7 @@ function registerGoalTools(
2482
3013
  "goal_update",
2483
3014
  {
2484
3015
  description:
2485
- "Propose or apply a semantic goal revision under the session's mutation policy. Retain the standing goal unless explicit user direction or meaningful new evidence justifies the declared refinement, adaptation, or replacement. Every rewrite must use the exact expected objective revision and a concise rationale. Root constraints cannot be changed by an agent. A rewrite is not an execution-progress audit fact; use the optional goal_progress tool when such a fact should be recorded.",
3016
+ "Maintain your operational goal as user direction or meaningful new evidence clarifies the intended outcome. text and successCriteria each allow 8192 UTF-8 bytes; rationale allows 2048 UTF-8 bytes for a short human-readable explanation. Use normal spaces and summarize instead of squeezing words. Changes apply directly unless the user explicitly configured review_changes; refinement, adaptation, and replacement are audit classifications, not approval gates under the default policy. Use the exact expected objective revision. Updating a goal grants no additional authority and cannot change root constraints. Progress notes belong in goal_progress (8192 UTF-8 bytes), not a semantic goal rewrite; deliver the answer in your final reply.",
2486
3017
  inputSchema: {
2487
3018
  text: goalText.optional(),
2488
3019
  successCriteria: successCriteriaSchema.nullable().optional(),
@@ -2540,13 +3071,14 @@ function registerGoalTools(
2540
3071
  "goal_progress",
2541
3072
  {
2542
3073
  description:
2543
- "Record concrete progress toward the unchanged active goal. This does not change goal text, success criteria, mutation policy, or objective revision. Do not use it merely to keep the continuation loop alive.",
3074
+ "Record concrete progress toward the unchanged active goal. progressNote is a short human-readable status with useful milestones, at most 8192 UTF-8 bytes. Use normal spaces and sentences; summarize instead of compressing words. It is not the deliverable, a raw command transcript, or instructions for the next turn; the final answer still belongs in chat. Optionally append reportRequirements for secondary user-facing reports discovered during other work; existing requirement IDs and titles cannot be changed or removed. This does not change goal text, success criteria, mutation policy, or objective revision. Do not use it merely to keep the continuation loop alive.",
2544
3075
  inputSchema: {
2545
3076
  progressNote: progressNoteSchema,
2546
3077
  idempotencyKey: z4.string().uuid(),
3078
+ reportRequirements: SessionGoalReportRequirements.optional(),
2547
3079
  },
2548
3080
  },
2549
- async ({ progressNote, idempotencyKey }) => {
3081
+ async ({ progressNote, idempotencyKey, reportRequirements }) => {
2550
3082
  await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
2551
3083
  const context = exactAgentCommandContext(grant, sessionId);
2552
3084
  const { goal, events, operationId, replay } = await recordSessionGoalProgressWithEvent(
@@ -2555,6 +3087,7 @@ function registerGoalTools(
2555
3087
  sessionId,
2556
3088
  {
2557
3089
  progressNote,
3090
+ ...(reportRequirements !== undefined ? { reportRequirements } : {}),
2558
3091
  command: {
2559
3092
  accountId: grant.accountId,
2560
3093
  actor: {
@@ -2577,10 +3110,10 @@ function registerGoalTools(
2577
3110
  "wait_for_input",
2578
3111
  {
2579
3112
  description:
2580
- "End the current turn and wait out of turn for relevant session input. This is self-only and does not require a goal. After success, the production runtime ends the turn at the tool-batch boundary without another model step or final message. Use it for long or uncertain waits instead of sleeping or repeatedly calling session_wait/command_wait. timeoutSeconds is a relative safety-wake duration; OpenGeni persists the first absolute deadline for the turn, and repeated calls do not extend it. Timeout never cancels a background command. A human/API prompt, agent message or Steer, child terminal result, scheduled input, terminal background-command result, or the deadline wakes the session. Use goal_pause instead when the active goal itself should stop pending a human decision.",
3113
+ "End the current turn and wait out of turn for relevant session input. This is self-only and does not require a goal. After success, the production runtime ends the turn at the tool-batch boundary without another model step or final message. Use it for long or uncertain waits, including right after spawning a child that needs minutes, instead of sleeping or repeatedly calling session_wait, session_get, or command_wait. No preliminary short wait or status recheck is required. timeoutSeconds is a relative safety-wake duration, not a blocking execution wait; choose it for the dependency or a meaningful user/task/Skill monitoring cadence, potentially hours or days within the schema limits. Do not schedule wakeups merely for unchanged reassurance unless an explicit update cadence requires it. Opengeni persists the first absolute deadline for the turn, and repeated calls do not extend it. After answering a question during a wait, preserve the existing deadline by passing the time remaining, not a fresh full timeout. If less than the schema minimum remains or the deadline has passed, a question-only human/API turn that consumed no immediate machine input may finish without replacing the retained wait; its deadline machinery remains authoritative. Do not send an invalid timeout or silently extend the deadline. Otherwise do not assume the old wait remains armed; register a valid wait if needed and make any unavoidable deadline adjustment explicit. Timeout never cancels a background command. A human/API prompt, agent message or Steer, child terminal result (it carries the child's final answer in payload.finalAnswer), scheduled input, terminal background-command result, or the deadline wakes the session. Use goal_pause instead when the active goal itself should stop pending a human decision. Pending Codemode calls require the same live attempt: observe them with command_wait/command_read rather than ending the turn.",
2581
3114
  inputSchema: {
2582
3115
  reason: inputWaitReasonSchema.describe(
2583
- "Shown directly to the user. Write one short, natural sentence explaining what you are waiting for, with normal spacing. Exclude internal IDs, cursors, commit hashes, paths, and continuation instructions. Example: Waiting for the build and database checks to finish.",
3116
+ "Shown directly to the user; at most 2048 UTF-8 bytes. Write one short, natural sentence explaining what you are waiting for, with normal spacing. Exclude internal IDs, cursors, commit hashes, paths, and continuation instructions. Example: Waiting for the build and database checks to finish.",
2584
3117
  ),
2585
3118
  timeoutSeconds: z4
2586
3119
  .number()
@@ -2635,16 +3168,29 @@ function registerGoalTools(
2635
3168
  "goal_complete",
2636
3169
  {
2637
3170
  description:
2638
- "Mark the session goal as completed. Requires concrete evidence (what was done and how it satisfies the success criteria). Completion prevents further continuation turns.",
2639
- inputSchema: { evidence: z4.string().min(1) },
3171
+ "Mark the session goal as completed with a short concrete proof for the goal ledger (evidence: at most 8192 characters). Evidence is not the deliverable and is not shown as your final chat reply; do not squeeze a report into it or remove spaces to fit. After this tool succeeds, reply to the user with the requested deliverable, or its summary and retained artifact link. Completion stops automatic goal continuations, not the current turn or its final reply. Every persisted report requirement must have a matching reportDeliveries entry containing a native document artifactId and its server-issued inspectionReceiptId from a post-edit body inspection. Missing, stale, inaccessible or summary-only proof fails; inspect again after an edit. Omit reportDeliveries only when no reports were declared.",
3172
+ inputSchema: {
3173
+ evidence: z4
3174
+ .string()
3175
+ .min(1)
3176
+ .max(8192)
3177
+ .describe(
3178
+ "Short ledger proof, at most 8192 characters; not the final answer. Keep normal spaces. Deliver the answer in your final user-facing reply.",
3179
+ ),
3180
+ reportDeliveries: SessionGoalReportDeliveries.optional(),
3181
+ },
2640
3182
  },
2641
- async ({ evidence }) => {
3183
+ async ({ evidence, reportDeliveries }) => {
2642
3184
  await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
2643
3185
  await requireSession(deps.db, grant.workspaceId, sessionId);
2644
3186
  const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
2645
3187
  if (!existing) {
2646
3188
  throw new Error("this session has no goal; use goal_set first");
2647
3189
  }
3190
+ const context = exactAgentCommandContext(grant, sessionId);
3191
+ const reportArtifactActor = editableArtifactActorForGrant(grant, "0000000000000001");
3192
+ if (reportArtifactActor.kind !== "agent")
3193
+ throw new Error("Goal completion requires exact agent authority");
2648
3194
  const { goal, events } = await setSessionGoalStatusWithEvent(
2649
3195
  deps.db,
2650
3196
  grant.workspaceId,
@@ -2652,6 +3198,15 @@ function registerGoalTools(
2652
3198
  {
2653
3199
  status: "completed",
2654
3200
  evidence,
3201
+ ...(reportDeliveries !== undefined ? { reportDeliveries } : {}),
3202
+ reportArtifactActor,
3203
+ commandActor: {
3204
+ type: "agent_attempt",
3205
+ sessionId: context.callerSessionId,
3206
+ turnId: context.callerTurnId,
3207
+ attemptId: context.callerAttemptId,
3208
+ executionGeneration: context.callerExecutionGeneration,
3209
+ },
2655
3210
  event: { type: "goal.completed", evidence },
2656
3211
  },
2657
3212
  );
@@ -2659,8 +3214,8 @@ function registerGoalTools(
2659
3214
  if (events.length > 0) {
2660
3215
  await deps.bus.publish(grant.workspaceId, sessionId, events);
2661
3216
  }
2662
- return json(
2663
- mcpMutationReceipt({
3217
+ return json({
3218
+ ...mcpMutationReceipt({
2664
3219
  operation: "goal_complete",
2665
3220
  committed: true,
2666
3221
  outcome: changed ? "updated" : "unchanged",
@@ -2675,7 +3230,15 @@ function registerGoalTools(
2675
3230
  idempotency: { status: "not_supported" },
2676
3231
  nextAction: { tool: "session_get", arguments: { sessionId } },
2677
3232
  }),
2678
- );
3233
+ reportDeliveries: SessionGoalReportDeliveries.parse(
3234
+ goal.metadata.reportDeliveriesV1 ?? [],
3235
+ ).map((delivery) => ({
3236
+ ...delivery,
3237
+ artifactReference: `[Open report](/workspaces/${grant.workspaceId}/artifacts/editable/${delivery.artifactId})`,
3238
+ })),
3239
+ handoff:
3240
+ "Now reply to the user with the requested deliverable, or its summary and retained artifact link. The evidence is only ledger proof; this receipt does not deliver your final answer.",
3241
+ });
2679
3242
  },
2680
3243
  );
2681
3244
 
@@ -2683,7 +3246,7 @@ function registerGoalTools(
2683
3246
  "goal_pause",
2684
3247
  {
2685
3248
  description:
2686
- "Pause the session goal with a rationale (blocked, not productive, needs human input). No further continuation turns are synthesized until the goal is resumed or replaced.",
3249
+ "Pause the session goal with an evidence-based rationale when no meaningful authorized progress remains. rationale is a short human-readable blocker explanation, at most 2048 UTF-8 bytes; keep normal spaces and summarize instead of compressing words. Investigate recoverable failures and try plausible safe alternatives that could materially help; no fixed turn or retry count is required, and a definitive missing permission or required human decision can justify pausing immediately. State the blocker and what must change to resume. Work already in flight or a meaningful timed recheck uses the available waiting mechanism instead. Tool approvals remain human-only. No further continuation turns are synthesized until the goal is resumed or replaced.",
2687
3250
  inputSchema: { rationale: goalRationale },
2688
3251
  },
2689
3252
  async ({ rationale }) => {
@@ -2736,7 +3299,7 @@ function registerGoalTools(
2736
3299
  "goal_resume",
2737
3300
  {
2738
3301
  description:
2739
- "Resume this session's paused goal regardless of who paused it or why. Already active is a successful no-op. Preserves the objective and resets continuation counters.",
3302
+ "Resume this session's paused goal when the user asks you to continue (whoever paused it), or when the blocker you paused for has cleared. A user's question alone is not a reason to resume: answer it and leave the goal paused. Already active is a successful no-op. Preserves the objective and resets continuation counters.",
2740
3303
  inputSchema: {},
2741
3304
  },
2742
3305
  async () => {
@@ -2752,6 +3315,8 @@ function registerGoalTools(
2752
3315
  sessionId,
2753
3316
  {
2754
3317
  status: "active",
3318
+ beforeResume: (tx, session, causalTurn) =>
3319
+ assertGoalResumeAllowed({ ...deps, db: tx }, session, causalTurn),
2755
3320
  event: { type: "goal.resumed", actor: "agent" },
2756
3321
  },
2757
3322
  );
@@ -2830,9 +3395,22 @@ function registerWorkspaceArtifactTools(
2830
3395
  });
2831
3396
  const mutation = async (response: WorkspaceArtifactMutationResponse) =>
2832
3397
  await projectWorkspaceArtifactMutationProvenance(response, canReadProvenanceSession);
3398
+ // A published Site follows its Site here exactly as through the API routes:
3399
+ // the same hook resyncs the edge and scans a version that became current.
3400
+ const siteMutation = async (response: WorkspaceArtifactMutationResponse, scan: boolean) =>
3401
+ await mutation(await afterSiteArtifactMutation(deps, response, { scan }));
3402
+ // An agent started from a Site page changes only the Sites it was started
3403
+ // from and Sites its own session tree created (the HTTP routes check the same).
3404
+ const otherSiteRefusal = async (artifactId: string) =>
3405
+ (await siteAgentMayChangeArtifact(deps, grant, artifactId))
3406
+ ? null
3407
+ : siteAgentClampedToolResult(SITE_AGENT_OTHER_SITE_MESSAGE);
2833
3408
  const prepare = (
2834
3409
  html: string | undefined,
2835
- source?: { entrypoint: string; files: Array<{ path: string; content: string }> },
3410
+ source?: {
3411
+ entrypoint: string;
3412
+ files: Array<{ path: string; content: string }>;
3413
+ },
2836
3414
  requestedTools?: Array<{ serverId: string; toolName: string }>,
2837
3415
  uploadId?: string,
2838
3416
  ) => {
@@ -2895,9 +3473,10 @@ function registerWorkspaceArtifactTools(
2895
3473
  async ({ artifactId, versionId }) => {
2896
3474
  await authorize();
2897
3475
  if (!deps.objectStorage) throw new Error("Object storage is not configured");
3476
+ const viewer = await siteViewerForGrant(deps.db, grant);
2898
3477
  const [rawDetail, ref] = await Promise.all([
2899
- getWorkspaceArtifact(deps.db, grant.workspaceId, artifactId),
2900
- getWorkspaceArtifactContentRef(deps.db, grant.workspaceId, artifactId, versionId),
3478
+ getWorkspaceArtifact(deps.db, grant.workspaceId, viewer, artifactId),
3479
+ getWorkspaceArtifactContentRef(deps.db, grant.workspaceId, viewer, artifactId, versionId),
2901
3480
  ]);
2902
3481
  const sourceAuthorizations = new Map<string, Promise<boolean>>();
2903
3482
  const canReadSourceSession = (sourceSessionId: string): Promise<boolean> => {
@@ -2924,7 +3503,7 @@ function registerWorkspaceArtifactTools(
2924
3503
  "artifacts_create",
2925
3504
  {
2926
3505
  description:
2927
- "Create and publish a generic workspace artifact from a complete HTML document. The exact HTML runs in an opaque-origin sandboxed iframe with JavaScript, external resources, forms, popups, and downloads enabled, but without parent-origin authority or top-level navigation.",
3506
+ 'Create and publish a Site from a complete HTML document. Its audience follows this chat unless you set visibility: in an Only me chat it is Only me (just the person you act for can see it; they can share it with the workspace from the Site page), otherwise everyone in the workspace can open it. Set visibility to "workspace" or "private" only when the person asks for that audience. The exact HTML runs in an opaque-origin sandboxed iframe with JavaScript, external resources, forms, popups, and downloads enabled, but without parent-origin authority or top-level navigation.',
2928
3507
  inputSchema: {
2929
3508
  title: z4.string().min(1).max(120),
2930
3509
  description: z4.string().max(2000).nullable().optional(),
@@ -2958,6 +3537,7 @@ function registerWorkspaceArtifactTools(
2958
3537
  )
2959
3538
  .max(WORKSPACE_ARTIFACT_REQUESTED_TOOLS_MAX)
2960
3539
  .optional(),
3540
+ visibility: z4.enum(["private", "workspace"]).optional(),
2961
3541
  idempotencyKey: z4.string().min(1).max(200),
2962
3542
  },
2963
3543
  },
@@ -2968,10 +3548,19 @@ function registerWorkspaceArtifactTools(
2968
3548
  html,
2969
3549
  source,
2970
3550
  requestedTools,
3551
+ visibility,
2971
3552
  idempotencyKey,
2972
3553
  uploadId,
2973
3554
  }) => {
2974
3555
  await authorize();
3556
+ const audience = await resolveNewSiteAudience({
3557
+ db: deps.db,
3558
+ settings: deps.settings,
3559
+ grant,
3560
+ viewer: await siteViewerForGrant(deps.db, grant),
3561
+ requested: visibility,
3562
+ sourceSessionId: attempt().sessionId,
3563
+ });
2975
3564
  const artifactId = crypto.randomUUID();
2976
3565
  const slugBase = slug ?? (normalizeWorkspaceArtifactSlug(title) || "artifact");
2977
3566
  const resolvedSlug = slug ?? `${slugBase.slice(0, 87)}-${artifactId.slice(0, 8)}`;
@@ -2983,6 +3572,7 @@ function registerWorkspaceArtifactTools(
2983
3572
  requestedSlug: slug ?? null,
2984
3573
  title,
2985
3574
  description: description ?? null,
3575
+ ...audience,
2986
3576
  ...(await prepare(html, source, requestedTools, uploadId)),
2987
3577
  ...provenance(idempotencyKey, "artifacts_create"),
2988
3578
  }),
@@ -3041,16 +3631,28 @@ function registerWorkspaceArtifactTools(
3041
3631
  idempotencyKey,
3042
3632
  }) => {
3043
3633
  await authorize();
3634
+ const refused = await otherSiteRefusal(artifactId);
3635
+ if (refused) return refused;
3636
+ const braked = await sitePlatformBrakeToolRefusal(() =>
3637
+ requireSiteVersionPublishOpen(
3638
+ deps,
3639
+ { accountId: grant.accountId, workspaceId: grant.workspaceId },
3640
+ artifactId,
3641
+ ),
3642
+ );
3643
+ if (braked) return braked;
3044
3644
  return json(
3045
- await mutation(
3645
+ await siteMutation(
3046
3646
  await publishWorkspaceArtifactVersion(deps.db, {
3047
3647
  artifactId,
3648
+ viewer: await siteViewerForGrant(deps.db, grant),
3048
3649
  expectedCurrentVersionId,
3049
3650
  ...(title !== undefined ? { title } : {}),
3050
3651
  ...(description !== undefined ? { description } : {}),
3051
3652
  ...(await prepare(html, source, requestedTools, uploadId)),
3052
3653
  ...provenance(idempotencyKey, "artifacts_publish"),
3053
3654
  }),
3655
+ true,
3054
3656
  ),
3055
3657
  );
3056
3658
  },
@@ -3071,15 +3673,19 @@ function registerWorkspaceArtifactTools(
3071
3673
  },
3072
3674
  async ({ artifactId, versionId, expectedCurrentVersionId, reason, idempotencyKey }) => {
3073
3675
  await authorize();
3676
+ const refused = await otherSiteRefusal(artifactId);
3677
+ if (refused) return refused;
3074
3678
  return json(
3075
- await mutation(
3679
+ await siteMutation(
3076
3680
  await rollbackWorkspaceArtifact(deps.db, {
3077
3681
  artifactId,
3682
+ viewer: await siteViewerForGrant(deps.db, grant),
3078
3683
  versionId,
3079
3684
  expectedCurrentVersionId,
3080
3685
  reason,
3081
3686
  ...provenance(idempotencyKey, "artifacts_rollback"),
3082
3687
  }),
3688
+ true,
3083
3689
  ),
3084
3690
  );
3085
3691
  },
@@ -3099,15 +3705,19 @@ function registerWorkspaceArtifactTools(
3099
3705
  },
3100
3706
  async ({ artifactId, expectedCurrentVersionId, reason, idempotencyKey }) => {
3101
3707
  await authorize();
3708
+ const refused = await otherSiteRefusal(artifactId);
3709
+ if (refused) return refused;
3102
3710
  return json(
3103
- await mutation(
3711
+ await siteMutation(
3104
3712
  await setWorkspaceArtifactStatus(deps.db, {
3105
3713
  artifactId,
3714
+ viewer: await siteViewerForGrant(deps.db, grant),
3106
3715
  status: "archived",
3107
3716
  expectedCurrentVersionId,
3108
3717
  reason,
3109
3718
  ...provenance(idempotencyKey, "artifacts_archive"),
3110
3719
  }),
3720
+ false,
3111
3721
  ),
3112
3722
  );
3113
3723
  },
@@ -3127,15 +3737,19 @@ function registerWorkspaceArtifactTools(
3127
3737
  },
3128
3738
  async ({ artifactId, expectedCurrentVersionId, reason, idempotencyKey }) => {
3129
3739
  await authorize();
3740
+ const refused = await otherSiteRefusal(artifactId);
3741
+ if (refused) return refused;
3130
3742
  return json(
3131
- await mutation(
3743
+ await siteMutation(
3132
3744
  await setWorkspaceArtifactStatus(deps.db, {
3133
3745
  artifactId,
3746
+ viewer: await siteViewerForGrant(deps.db, grant),
3134
3747
  status: "active",
3135
3748
  expectedCurrentVersionId,
3136
3749
  reason,
3137
3750
  ...provenance(idempotencyKey, "artifacts_restore"),
3138
3751
  }),
3752
+ false,
3139
3753
  ),
3140
3754
  );
3141
3755
  },
@@ -3559,10 +4173,6 @@ function scheduledTaskUpdateChangesState(
3559
4173
  if (update.metadata !== undefined && stableJson(update.metadata) !== stableJson(task.metadata)) {
3560
4174
  return true;
3561
4175
  }
3562
- // Personal-connection delegations are recomputed with agentConfig and can
3563
- // change even when the visible config is byte-identical (for example after a
3564
- // connection rotation), so preserve that refresh as a real mutation.
3565
- if (update.personalConnectionDelegations !== undefined) return true;
3566
4176
  return false;
3567
4177
  }
3568
4178
 
@@ -3671,20 +4281,24 @@ function registerFleetTools(
3671
4281
  "sandbox_attach",
3672
4282
  {
3673
4283
  description:
3674
- 'Attach this session to a sandbox for subsequent sandbox operations. The target must be owned and verified ready. A managed-home session currently running on a Connected Machine crosses a safe attempt boundary when attached back to home: OpenGeni checkpoints completed work and continues the same logical turn on home without another user message. A same-target attach is a repair request: it revalidates readiness and advances the route epoch rather than returning unchanged success. Recovery-in-progress/degraded/unrecoverable outcomes are typed. Use a sandboxes_list `id`, or "session"/"default" for home.',
4284
+ 'Attach this session to a sandbox for subsequent sandbox operations. The target must be owned and verified ready. A managed-home session currently running on a Connected Machine crosses a safe attempt boundary when attached back to home: Opengeni checkpoints completed work and continues the same logical turn on home without another user message. A same-target attach is a repair request: it revalidates readiness and advances the route epoch rather than returning unchanged success. Recovery-in-progress/degraded/unrecoverable outcomes are typed. Use a sandboxes_list `id`, or "session"/"default" for home.',
3675
4285
  inputSchema: { target: z4.string().min(1) },
3676
4286
  },
3677
- async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
4287
+ async ({ target }) =>
4288
+ (await siteAgentComputeRefusal(deps, grant)) ??
4289
+ json(await swapActiveSandbox(services, await fleetContext(), target)),
3678
4290
  );
3679
4291
 
3680
4292
  server.registerTool(
3681
4293
  "sandbox_swap",
3682
4294
  {
3683
4295
  description:
3684
- 'Swap the active sandbox for this session mid-conversation. Validates ownership and verified readiness, then advances the route epoch. A managed-home session currently running on a Connected Machine crosses a safe attempt boundary when swapped back to home: OpenGeni checkpoints completed work and continues the same logical turn on home without another user message. Same-target swaps also revalidate and fence stale route caches. An operation that encountered provider disappearance is not replayed; retry only after a typed recovery-ready result. Use a sandboxes_list `id`, or "session"/"default" for home.',
4296
+ 'Swap the active sandbox for this session mid-conversation. Validates ownership and verified readiness, then advances the route epoch. A managed-home session currently running on a Connected Machine crosses a safe attempt boundary when swapped back to home: Opengeni checkpoints completed work and continues the same logical turn on home without another user message. Same-target swaps also revalidate and fence stale route caches. An operation that encountered provider disappearance is not replayed; retry only after a typed recovery-ready result. Use a sandboxes_list `id`, or "session"/"default" for home.',
3685
4297
  inputSchema: { target: z4.string().min(1) },
3686
4298
  },
3687
- async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
4299
+ async ({ target }) =>
4300
+ (await siteAgentComputeRefusal(deps, grant)) ??
4301
+ json(await swapActiveSandbox(services, await fleetContext(), target)),
3688
4302
  );
3689
4303
 
3690
4304
  server.registerTool(
@@ -3710,11 +4324,25 @@ function registerFleetTools(
3710
4324
  },
3711
4325
  },
3712
4326
  async ({ target, op }) => {
4327
+ const refused = await siteAgentComputeRefusal(deps, grant);
4328
+ if (refused) return refused;
3713
4329
  const typedOp = op as RunOnOp;
3714
4330
  const transientExecEnvironment = await oneOffCodemodeEnvironment(typedOp);
4331
+ // W1 (DL-9): an exact agent attempt resolves `{{secret:…}}` references.
4332
+ const claims = exactAgentAttemptClaims(grant);
4333
+ const secretResolver =
4334
+ claims && claims.sessionId === sessionId && typedOp.kind !== "read"
4335
+ ? await createRunOnSecretResolver(
4336
+ deps,
4337
+ { accountId: grant.accountId, workspaceId: grant.workspaceId, ...claims },
4338
+ (subjectId) =>
4339
+ vaultProviderRegistryFor(deps, { workspaceId: grant.workspaceId, subjectId }),
4340
+ )
4341
+ : null;
3715
4342
  return json(
3716
4343
  await runOnSandbox(services, await fleetContext(), target, typedOp, {
3717
4344
  ...(transientExecEnvironment ? { transientExecEnvironment } : {}),
4345
+ ...(secretResolver ? { secretResolver } : {}),
3718
4346
  }),
3719
4347
  );
3720
4348
  },
@@ -3724,27 +4352,67 @@ function registerFleetTools(
3724
4352
  "sandbox_provision",
3725
4353
  {
3726
4354
  description:
3727
- "Provision a new sandbox for the fleet. kind=selfhosted returns device-flow enrollment instructions to share with a HUMAN (install the agent + enroll their machine with loud whole-machine consent — the agent cannot self-consent). kind=modal creates a named Modal sandbox record, but it is NOT yet attachable as a swap target: routing a session onto a second Modal box is not supported yet, so sandbox_swap to its id is rejected. Use the session's own box (the default) or attach a Connected Machine instead.",
4355
+ "Provision a new sandbox for the fleet. kind=selfhosted posts the Connected Machine card in this chat: the person copies a one-line connect command, runs it on their own computer, and picks Use in this chat; the card also offers the Opengeni Browser Chrome extension once the machine is connected. When you can already run commands on the target machine, install it yourself with connected_machine_enroll_token instead. kind=modal creates a named Modal sandbox record, but it is NOT yet attachable as a swap target: routing a session onto a second Modal box is not supported yet, so sandbox_swap to its id is rejected. Use the session's own box (the default) or attach a Connected Machine instead.",
3728
4356
  inputSchema: {
3729
4357
  kind: z4.enum(["selfhosted", "modal"]),
3730
4358
  name: z4.string().min(1).max(120).optional(),
3731
4359
  },
3732
4360
  },
3733
- async ({ kind, name }) =>
3734
- json(
4361
+ async ({ kind, name }) => {
4362
+ const refused = await siteAgentComputeRefusal(deps, grant);
4363
+ if (refused) return refused;
4364
+ if (kind === "selfhosted" && exactAgentAttemptClaims(grant)) {
4365
+ // Same live-attempt authority the other fleet tools resolve, without
4366
+ // requiring a session box: a chat with no sandbox can still connect one.
4367
+ await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
4368
+ const item = nativeConnectionCapabilityRecommendations({ connectedMachines: true }).find(
4369
+ (candidate) => candidate.id === CONNECTED_MACHINE_CAPABILITY_ID,
4370
+ )!;
4371
+ const setup = await capabilitySetupProjection(
4372
+ deps,
4373
+ grant.workspaceId,
4374
+ item,
4375
+ new Set(),
4376
+ new Set(),
4377
+ false,
4378
+ new Set(),
4379
+ new Set(),
4380
+ );
4381
+ if (setup.status === "unavailable") {
4382
+ return json({ kind: "selfhosted", status: "unavailable", message: setup.detail });
4383
+ }
4384
+ const eventId = await appendCapabilityCardEvent(deps, grant, sessionId, {
4385
+ item,
4386
+ action: "connect",
4387
+ rationale: "Connect your computer so this chat can run on it.",
4388
+ toolName: "sandbox_provision",
4389
+ });
4390
+ return json({
4391
+ kind: "selfhosted",
4392
+ status: "card_posted",
4393
+ eventId,
4394
+ message: `${
4395
+ setup.status === "ready"
4396
+ ? `${setup.detail} The Connected Machine card lists them with Use in this chat and can connect another.`
4397
+ : "The Connected Machine card was posted. The person copies its one-line connect command, runs it on their machine, and picks Use in this chat; nothing is connected yet."
4398
+ } Check sandboxes_list after they confirm. Chrome needs the Opengeni Browser extension (${OPENGENI_BROWSER_EXTENSION_URL}) on the connected machine.`,
4399
+ });
4400
+ }
4401
+ return json(
3735
4402
  await provisionSandbox(services, await fleetContext(), {
3736
4403
  kind,
3737
4404
  ...(name ? { name } : {}),
3738
4405
  }),
3739
- ),
4406
+ );
4407
+ },
3740
4408
  );
3741
4409
  }
3742
4410
 
3743
- // Workspace-admin/operator surface for removing a connected machine. This is
4411
+ // Enrollment-management surface for creating tokens and removing machines. This is
3744
4412
  // deliberately separate from the session-scoped fleet tools: a worker can list,
3745
4413
  // attach, or run on a machine only with session authority, while removal requires
3746
- // the explicit high-trust enrollments:manage permission and never accepts a
3747
- // sandbox id. A Modal record therefore cannot be removed through this operation.
4414
+ // the explicit enrollments:manage permission. Removal accepts enrollment ids,
4415
+ // never sandbox ids; a Modal record cannot be removed through this operation.
3748
4416
  function registerConnectedMachineTools(
3749
4417
  server: McpServer,
3750
4418
  deps: ApiRouteDeps,
@@ -3752,11 +4420,119 @@ function registerConnectedMachineTools(
3752
4420
  sessionId: string | null,
3753
4421
  json: JsonResult,
3754
4422
  ): void {
4423
+ server.registerTool(
4424
+ "connected_machine_enroll_token",
4425
+ {
4426
+ description:
4427
+ "Create a single-use Connected Machine enrollment token for this workspace (expires in one hour) using existing enrollments:manage authority. Returns the token, expiry and Unix/PowerShell install commands. Use it when you can run commands on the target machine yourself (run_on, SSH, a VM you control): run the matching command there, then verify the machine with sandboxes_list. One token connects one machine; mint another for each additional machine (the same machine may rerun its command). When the person must run it on their own computer, post the Connected Machine card instead (capability_authorization_request with api:connected-machine), which mints the command in their browser; do not paste a token into the chat. Screen control is optional and defaults off; to turn it on for a machine that is already connected, use connected_machine_enable_screen_control instead of reconnecting. The token grants whole-machine access on enrollment; do not put it in public code or unrelated logs. Existing machine connections are preserved. For Chrome, the person installs the Opengeni Browser extension (" +
4428
+ OPENGENI_BROWSER_EXTENSION_URL +
4429
+ ") after the machine is connected.",
4430
+ inputSchema: { allowScreenControl: z4.boolean().optional() },
4431
+ },
4432
+ async ({ allowScreenControl }) => {
4433
+ if (sessionId) {
4434
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
4435
+ }
4436
+ const refused = await siteAgentClampRefusal(deps, grant, "Connected Machines");
4437
+ if (refused) return refused;
4438
+ const minted = await mintEnrollToken(deps, {
4439
+ accountId: grant.accountId,
4440
+ workspaceId: grant.workspaceId,
4441
+ allowScreenControl: allowScreenControl ?? false,
4442
+ });
4443
+ if (!minted) throw new Error("enrollment credential plane is not configured");
4444
+ const base = (deps.settings.publicBaseUrl ?? "https://app.opengeni.ai").replace(/\/+$/, "");
4445
+ const unixQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
4446
+ const windowsQuote = (value: string) => `'${value.replaceAll("'", "''")}'`;
4447
+ return json({
4448
+ ...minted,
4449
+ workspaceId: grant.workspaceId,
4450
+ allowScreenControl: allowScreenControl ?? false,
4451
+ installCommandUnix: `curl -fsSL ${unixQuote(`${base}/install.sh`)} | OPENGENI_API_URL=${unixQuote(base)} OPENGENI_ENROLL_TOKEN=${unixQuote(minted.token)} sh`,
4452
+ installCommandWindows: `$env:OPENGENI_API_URL=${windowsQuote(base)}; $env:OPENGENI_ENROLL_TOKEN=${windowsQuote(minted.token)}; irm ${windowsQuote(`${base}/install.ps1`)} | iex`,
4453
+ });
4454
+ },
4455
+ );
4456
+ server.registerTool(
4457
+ "connected_machine_enable_screen_control",
4458
+ {
4459
+ description:
4460
+ "Turn screen control on for a machine that is already connected, in place, so agents can see and use its screen (computer and attached-Chrome tools). `target` is a sandboxes_list id or an enrollment id. Needs no human click, token, terminal or reconnect: Opengeni records the consent on that machine and its agent refreshes its own credentials over the live connection. status=active means it is on now. status=pending means it is allowed and turns on by itself: reason=offline when the machine next connects; reason=agent_update_required after its agent is updated (update it with the machine update action, POST /v1/workspaces/{workspaceId}/machines/{enrollmentId}/update); reason=renewal_failed: call again shortly; reason=reconnect_required: the machine's connection predates in-place changes, so rerun the connect command on it (connected_machine_enroll_token with allowScreenControl=true). On a Mac the OS must also allow Screen Recording, Accessibility and Input Monitoring for Opengeni: open each pane on the Mac with run_on exec `open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'` (Privacy_Accessibility, Privacy_ListenEvent; also the privacy-settings machine action) and ask the person to switch Opengeni on there, choosing Quit & Reopen if macOS asks; the machine list reports runtime.macPermissions. Chrome additionally needs the Opengeni Browser extension (" +
4461
+ OPENGENI_BROWSER_EXTENSION_URL +
4462
+ ") on that machine.",
4463
+ inputSchema: { target: z4.string().min(1) },
4464
+ },
4465
+ async ({ target }) => {
4466
+ if (sessionId) {
4467
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
4468
+ }
4469
+ const refused = await siteAgentClampRefusal(deps, grant, "Connected Machines");
4470
+ if (refused) return refused;
4471
+ // Personal machines belong to the person the attempt acts for.
4472
+ const attempt = sessionId ? exactAgentAttemptClaims(grant) : null;
4473
+ const resourceGrant =
4474
+ sessionId && attempt
4475
+ ? {
4476
+ ...grant,
4477
+ subjectId:
4478
+ (await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId))
4479
+ .initiatingHumanSubjectId ?? grant.subjectId,
4480
+ }
4481
+ : grant;
4482
+ const enrollments = await listEnrollments(deps.db, resourceGrant, { status: "active" });
4483
+ let enrollment = enrollments.find((candidate) => candidate.id === target);
4484
+ if (!enrollment) {
4485
+ const sandbox = await getSandbox(deps.db, resourceGrant, target).catch(() => null);
4486
+ enrollment = enrollments.find((candidate) => candidate.id === sandbox?.enrollmentId);
4487
+ }
4488
+ if (!enrollment) {
4489
+ throw new Error("machine not found in this access scope; use a sandboxes_list id");
4490
+ }
4491
+ if (enrollment.scope === "organization" && !grant.permissions.includes("account:admin")) {
4492
+ throw new Error("missing permission: account:admin");
4493
+ }
4494
+ if (enrollment.scope === "user" && sessionId && attempt) {
4495
+ // The same per-attempt admission run_on requires for personal machines.
4496
+ const admitted = await authorizePersonalMachineForAttempt(deps.db, {
4497
+ accountId: grant.accountId,
4498
+ workspaceId: grant.workspaceId,
4499
+ subjectId: resourceGrant.subjectId,
4500
+ sessionId,
4501
+ turnId: attempt.turnId,
4502
+ attemptId: attempt.attemptId,
4503
+ executionGeneration: attempt.executionGeneration,
4504
+ enrollmentId: enrollment.id,
4505
+ requireActiveSandbox: false,
4506
+ }).catch(() => false);
4507
+ if (!admitted) {
4508
+ throw new Error("personal Connected Machine authority was not admitted for this attempt");
4509
+ }
4510
+ }
4511
+ const result = await enableMachineScreenControl(
4512
+ {
4513
+ db: deps.db,
4514
+ settings: deps.settings,
4515
+ ...(deps.bus ? { bus: deps.bus } : {}),
4516
+ observability: deps.observability,
4517
+ },
4518
+ {
4519
+ enrollment,
4520
+ access: enrollmentAccess(enrollment, resourceGrant),
4521
+ actor: {
4522
+ subjectId: grant.subjectId,
4523
+ sessionId: sessionId ?? null,
4524
+ attemptId: attempt?.attemptId ?? null,
4525
+ },
4526
+ },
4527
+ );
4528
+ return json({ ...result, machine: enrollment.id });
4529
+ },
4530
+ );
3755
4531
  server.registerTool(
3756
4532
  "connected_machine_remove",
3757
4533
  {
3758
4534
  description:
3759
- "Remove one enrolled self-hosted machine while it is offline. Access is revoked, future heartbeat/reconnect credentials are rejected, and session, route, lease, archive, and audit history is retained. Idle dependent sessions are detached atomically; machine-home sessions become compute-less (backend none) until another sandbox is selected. Active turns, live leases, and recovery work remain fail-closed blockers whose typed outcome explains what must settle before retrying. Pass the enrollmentId from the Machines surface, never a Modal sandbox id. Reconnecting later requires a fresh human-approved device-flow enrollment.",
4535
+ "Remove one enrolled self-hosted machine while it is offline. Access is revoked, future heartbeat/reconnect credentials are rejected, and session, route, lease, archive, and audit history is retained. Sessions that point at the machine are detached atomically, including ones whose turn is queued, paused or recovering (their next attempt uses no machine); machine-home sessions become compute-less (backend none) until another sandbox is selected. Only a live machine lease or pending lease recovery blocks removal, with a typed outcome that explains what must settle before retrying. Pass the enrollmentId from the Machines surface, never a Modal sandbox id. Reconnecting later requires fresh enrollment through device approval or an authorized enrollment token.",
3760
4536
  inputSchema: {
3761
4537
  enrollmentId: z4.string().uuid(),
3762
4538
  expectedUpdatedAt: z4.string().datetime({ offset: true }).optional(),
@@ -3764,6 +4540,8 @@ function registerConnectedMachineTools(
3764
4540
  },
3765
4541
  },
3766
4542
  async ({ enrollmentId, expectedUpdatedAt, idempotencyKey }) => {
4543
+ const refused = await siteAgentClampRefusal(deps, grant, "Connected Machines");
4544
+ if (refused) return refused;
3767
4545
  const resourceGrant =
3768
4546
  sessionId && exactAgentAttemptClaims(grant)
3769
4547
  ? {
@@ -3843,7 +4621,7 @@ function registerRigTools(
3843
4621
  const rig = await requireRigForApi(deps.db, await resourceGrant(), rigId);
3844
4622
  if (rig.scope === "organization") {
3845
4623
  throw new Error(
3846
- "Organization rig mutation requires account-admin authority through the authenticated REST surface.",
4624
+ "Organization sandbox environment mutation requires account-admin authority through the authenticated REST surface.",
3847
4625
  );
3848
4626
  }
3849
4627
  return rig;
@@ -3852,7 +4630,7 @@ function registerRigTools(
3852
4630
  server.registerTool(
3853
4631
  "rig_list",
3854
4632
  {
3855
- description: "List workspace rigs and their active versions.",
4633
+ description: "List workspace sandbox environments and their active versions.",
3856
4634
  inputSchema: {},
3857
4635
  },
3858
4636
  async () => json({ rigs: await listRigs(deps.db, await resourceGrant()) }),
@@ -3862,7 +4640,7 @@ function registerRigTools(
3862
4640
  "rig_get",
3863
4641
  {
3864
4642
  description:
3865
- "Get one rig's bounded active definition plus compact historical version/change summaries. Historical setup scripts, checks, payloads, and verification logs are represented by counts/byte facts rather than copied into model context; use the access-controlled REST detail endpoints for exact retained definitions.",
4643
+ "Get one sandbox environment's bounded active definition plus compact historical version/change summaries. Historical setup scripts, checks, payloads, and verification logs are represented by counts/byte facts rather than copied into model context; use the access-controlled REST detail endpoints for exact retained definitions.",
3866
4644
  inputSchema: {
3867
4645
  rigId: z4.string().uuid(),
3868
4646
  versionLimit: z4.number().int().positive().optional(),
@@ -3893,7 +4671,7 @@ function registerRigTools(
3893
4671
  "rig_propose_change",
3894
4672
  {
3895
4673
  description:
3896
- "Propose an additive rig setup command for clean verification. Use the exact command that already worked in this sandbox.",
4674
+ "Propose an additive sandbox environment setup command for clean verification. Use the exact command that already worked in this sandbox.",
3897
4675
  inputSchema: {
3898
4676
  rigId: z4.string().uuid(),
3899
4677
  command: z4.string().min(1).max(8192),
@@ -3901,6 +4679,9 @@ function registerRigTools(
3901
4679
  },
3902
4680
  },
3903
4681
  async ({ rigId, command, note }) => {
4682
+ // A Site-started agent does not change sandbox environments.
4683
+ const refused = await siteAgentClampRefusal(deps, grant, "sandbox environments");
4684
+ if (refused) return refused;
3904
4685
  const rig = await requireMutableRig(rigId);
3905
4686
  const originGrant = { ...(await resourceGrant()), workspaceId: rig.workspaceId };
3906
4687
  const change = await proposeRigChangeForApi(
@@ -3942,7 +4723,7 @@ function registerRigTools(
3942
4723
  retryable: true,
3943
4724
  },
3944
4725
  warnings: [
3945
- "The rig change and verifying transition committed, but verification workflow start failed.",
4726
+ "The sandbox environment change and verifying transition committed, but verification workflow start failed.",
3946
4727
  ],
3947
4728
  facts: { verificationAttempt: attempt },
3948
4729
  nextAction: { tool: "rig_get", arguments: { rigId: rig.id } },
@@ -3975,13 +4756,16 @@ function registerRigTools(
3975
4756
  "rig_verify",
3976
4757
  {
3977
4758
  description:
3978
- "Trigger rig verification. Pass changeId for a proposed change, or omit it to re-verify the active version's checks.",
4759
+ "Trigger sandbox environment verification. Pass changeId for a proposed change, or omit it to re-verify the active version's checks.",
3979
4760
  inputSchema: {
3980
4761
  rigId: z4.string().uuid(),
3981
4762
  changeId: z4.string().uuid().optional(),
3982
4763
  },
3983
4764
  },
3984
4765
  async ({ rigId, changeId }) => {
4766
+ // A Site-started agent does not change sandbox environments.
4767
+ const refused = await siteAgentClampRefusal(deps, grant, "sandbox environments");
4768
+ if (refused) return refused;
3985
4769
  const rig = await requireMutableRig(rigId);
3986
4770
  if (changeId) {
3987
4771
  const change = await requireRigChangeForApi(deps.db, rig.workspaceId, rig.id, changeId);
@@ -4045,7 +4829,7 @@ function registerRigTools(
4045
4829
  );
4046
4830
  }
4047
4831
  if (!rig.activeVersion) {
4048
- throw new Error("rig has no active version");
4832
+ throw new Error("sandbox environment has no active version");
4049
4833
  }
4050
4834
  await deps.workflowClient.startRigVerification({
4051
4835
  workspaceId: rig.workspaceId,
@@ -4078,13 +4862,16 @@ function registerRigTools(
4078
4862
  "rig_promote",
4079
4863
  {
4080
4864
  description:
4081
- "Promote a verified definition_edit rig change to a new active immutable version. Requires rigs:manage.",
4865
+ "Promote a verified definition_edit sandbox environment change to a new active immutable version. Requires rigs:manage.",
4082
4866
  inputSchema: {
4083
4867
  rigId: z4.string().uuid(),
4084
4868
  changeId: z4.string().uuid(),
4085
4869
  },
4086
4870
  },
4087
4871
  async ({ rigId, changeId }) => {
4872
+ // A Site-started agent does not change sandbox environments.
4873
+ const refused = await siteAgentClampRefusal(deps, grant, "sandbox environments");
4874
+ if (refused) return refused;
4088
4875
  const rig = await requireMutableRig(rigId);
4089
4876
  const change = await requireRigChangeForApi(deps.db, rig.workspaceId, rig.id, changeId);
4090
4877
  const originGrant = { ...(await resourceGrant()), workspaceId: rig.workspaceId };
@@ -4163,42 +4950,236 @@ function registerWorkspaceOrchestrationTools(
4163
4950
  can: (permission: Permission) => boolean,
4164
4951
  callerSessionId: string | null,
4165
4952
  sessionCreateVisible: boolean,
4953
+ childSelectableTools: readonly FirstPartyMcpToolName[],
4166
4954
  json: JsonResult,
4167
4955
  ): void {
4168
- if (can("sessions:read")) {
4956
+ const acknowledgeReads = async (children: { sessionId: string; sequences: number[] }[]) => {
4957
+ if (
4958
+ !callerSessionId ||
4959
+ !exactAgentAttemptClaims(grant) ||
4960
+ !children.some((child) => child.sequences.length > 0)
4961
+ )
4962
+ return;
4963
+ // Never use grant.subjectId, session creator, or the latest queue human.
4964
+ // Reauthorize after the bounded read, including after a blocking wait.
4965
+ const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, callerSessionId);
4966
+ if (!actor.initiatingHumanSubjectId) return;
4967
+ await acknowledgeConsumedChildEvents(deps.db, {
4968
+ workspaceId: grant.workspaceId,
4969
+ sessionId: callerSessionId,
4970
+ subjectId: actor.initiatingHumanSubjectId,
4971
+ children,
4972
+ });
4973
+ };
4974
+ // The caller's exact live attempt just returned a direct child's complete
4975
+ // answer to its model. Record it on the reading turn: when that attempt
4976
+ // completes its turn, a pending terminal result repeating only answers the
4977
+ // model already holds is superseded instead of starting another inference.
4978
+ // Only a direct model call counts: a Codemode script may keep the output to
4979
+ // itself. Best-effort: a failure leaves that result to be delivered.
4980
+ const recordConsumedChildResults = async (
4981
+ children: { sessionId: string; sequences: number[] }[],
4982
+ extra: { _meta?: Record<string, unknown> } | undefined,
4983
+ ): Promise<void> => {
4984
+ const claims = exactAgentAttemptClaims(grant);
4985
+ if (
4986
+ extra?._meta?.[FIRST_PARTY_MCP_CALLER_META_KEY] !== "model" ||
4987
+ !callerSessionId ||
4988
+ !claims ||
4989
+ claims.sessionId !== callerSessionId ||
4990
+ !children.some((child) => child.sequences.length > 0)
4991
+ )
4992
+ return;
4993
+ try {
4994
+ await recordConsumedChildAnswers(deps.db, {
4995
+ accountId: grant.accountId,
4996
+ workspaceId: grant.workspaceId,
4997
+ sessionId: callerSessionId,
4998
+ turnId: claims.turnId,
4999
+ attemptId: claims.attemptId,
5000
+ executionGeneration: claims.executionGeneration,
5001
+ children,
5002
+ });
5003
+ } catch (error) {
5004
+ deps.observability?.warn("Failed to record a consumed child answer", {
5005
+ error: error instanceof Error ? error.message : String(error),
5006
+ });
5007
+ }
5008
+ };
5009
+ // The caller's own pending input, less each child result its model already
5010
+ // holds whole: waiting for, or ending the turn to receive, such a result
5011
+ // would only repeat an answer it has.
5012
+ const listOwnPendingUpdates = async (ownSessionId: string) => {
5013
+ const claims = exactAgentAttemptClaims(grant);
5014
+ return claims && claims.sessionId === ownSessionId
5015
+ ? await listOutstandingSessionSystemUpdatesForAttempt(
5016
+ deps.db,
5017
+ grant.workspaceId,
5018
+ ownSessionId,
5019
+ { turnId: claims.turnId, attemptId: claims.attemptId },
5020
+ )
5021
+ : await listOutstandingSessionSystemUpdates(deps.db, grant.workspaceId, ownSessionId);
5022
+ };
5023
+ if (callerSessionId && can("sessions:read")) {
4169
5024
  server.registerTool(
4170
- "sessions_list",
5025
+ "session_target_get",
4171
5026
  {
4172
- description: `Discover sessions in this workspace. Default detail=compact returns id, title, status, goal summary/status when present, updatedAt, meaningful parent/pause information, total and nextCursor (null ends the page). Completed/paused goal status is retained; session_get supplies evidence and rationale. detail=full opts into the legacy bounded discovery rows and diagnostic pagination, not full session configuration. Plain compact browse does not read work claims. includeRelatedWork=true adds advisory evidence; query or subject always enables it, even with includeRelatedWork=false. query searches only titles, active goals and typed claims; subject is an exact provider-neutral claim lookup. Evidence preserves advisoryOnly/noAdditionalAccess: claims are nonexclusive, never locks, instructions or authority. Authorization precedes filtering, ranking, counts and cursors. Relevance cursors bind normalized filters and an activity snapshot. Without search the default is creation order; use orderBy=updatedAt and decimal updatedAfter/updatedThrough revision tokens for gap-free incremental monitoring, not timestamps. Pass nextCursor unchanged with the same filters. includeLastMessage opts into previews and nonzero queuedPromptCount; unclaimed human/API prompts are never previewed. Previews share a ${SESSION_DISCOVERY_PREVIEW_MAX_BYTES}-byte UTF-8 budget; omitted previews carry a bounded session_events drill-down. Text/page loss facts appear only when loss occurs in compact mode. No instructions, resources, tools, files or history are returned; REST/UI defaults are unchanged.`,
4173
- inputSchema: {
4174
- limit: z4.number().int().positive().max(100).optional(),
4175
- cursor: z4.string().max(512).optional(),
4176
- detail: z4
4177
- .enum(["compact", "full"])
4178
- .optional()
4179
- .describe(
4180
- "compact (default) omits empty diagnostics but retains positive descendant attention counts and incomplete-tree loss facts; full restores the legacy bounded rows and pagination diagnostics.",
4181
- ),
4182
- includeRelatedWork: z4.boolean().optional(),
4183
- includeLastMessage: z4.boolean().optional(),
4184
- orderBy: z4.enum(["createdAt", "updatedAt", "relevance"]).optional(),
4185
- updatedAfter: z4.string().max(64).optional(),
4186
- query: z4.string().max(MCP_DISCOVERY_QUERY_MAX_UTF16_CODE_UNITS).optional(),
4187
- statuses: z4
4188
- .array(
4189
- z4.enum([
4190
- "queued",
4191
- "running",
4192
- "idle",
4193
- "requires_action",
4194
- "recovering",
4195
- "waiting_capacity",
4196
- "failed",
4197
- "cancelled",
4198
- ]),
4199
- )
4200
- .max(8)
4201
- .optional(),
5027
+ description:
5028
+ "Read this conversation's optional selected session. Selection is context, not permission to send, steer, resume or transfer a call. Access is checked live; clear an unavailable target with session_target_set(sessionId=null).",
5029
+ inputSchema: {},
5030
+ },
5031
+ async () => {
5032
+ await authorizeFirstPartySession(deps, grant, callerSessionId, "session.read");
5033
+ const own = await getSession(deps.db, grant.workspaceId, callerSessionId);
5034
+ if (!own) throw new Error("Calling session is unavailable");
5035
+ const target = sessionTargetContext(own.metadata);
5036
+ if (target.sessionId) {
5037
+ try {
5038
+ await authorizeFirstPartySession(deps, grant, target.sessionId, "session.read");
5039
+ } catch (error) {
5040
+ if (!(error instanceof SessionAuthorizationDeniedError)) throw error;
5041
+ return json({
5042
+ version: target.version,
5043
+ sessionId: null,
5044
+ unavailable: true,
5045
+ selectionOnly: true,
5046
+ });
5047
+ }
5048
+ const selected = await getSession(deps.db, grant.workspaceId, target.sessionId);
5049
+ if (!selected) throw new Error("Selected session is unavailable");
5050
+ return json({ ...target, title: selected.title, selectionOnly: true });
5051
+ }
5052
+ return json({ ...target, title: null, selectionOnly: true });
5053
+ },
5054
+ );
5055
+ server.registerTool(
5056
+ "session_message_status",
5057
+ {
5058
+ description:
5059
+ "Follow the exact update ID returned by session_send_message or session_steer. Returns delivery state, consuming turn and an exact outcome read when present; acceptance or another turn finishing is not completion. Never resends. Use session_wait for changes, then read again. A paused pending target requires an explicitly authorized resume; do not silently steer it.",
5060
+ inputSchema: { sessionId: z4.string().uuid(), updateId: z4.string().uuid() },
5061
+ },
5062
+ async ({ sessionId, updateId }) => {
5063
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.events.read");
5064
+ const update = await getSessionSystemUpdateById(
5065
+ deps.db,
5066
+ grant.workspaceId,
5067
+ sessionId,
5068
+ updateId,
5069
+ );
5070
+ if (
5071
+ !update ||
5072
+ update.sourceId !== callerSessionId ||
5073
+ !["agent_message", "agent_steer_instruction"].includes(update.kind)
5074
+ )
5075
+ throw new Error("Message receipt not found for this caller and target");
5076
+ const turn = update.deliveredTurnId
5077
+ ? await getSessionTurn(deps.db, grant.workspaceId, update.deliveredTurnId)
5078
+ : null;
5079
+ if (turn && turn.sessionId !== sessionId) throw new Error("Message receipt turn mismatch");
5080
+ const outcome = turn
5081
+ ? await getSessionMessageOutcomeEvent(
5082
+ deps.db,
5083
+ grant.workspaceId,
5084
+ sessionId,
5085
+ turn.id,
5086
+ turn.executionGeneration,
5087
+ )
5088
+ : null;
5089
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.events.read");
5090
+ return json({
5091
+ sessionId,
5092
+ updateId,
5093
+ delivery: update.state,
5094
+ turnId: turn?.id ?? null,
5095
+ turnStatus: turn?.status ?? null,
5096
+ outcome: outcome ? { type: outcome.type, sequence: outcome.sequence } : null,
5097
+ nextAction: outcome
5098
+ ? {
5099
+ tool: "session_events",
5100
+ arguments: {
5101
+ sessionId,
5102
+ ...(outcome.type === "turn.cancelled" || outcome.type === "turn.superseded"
5103
+ ? {
5104
+ view: "debug",
5105
+ includeTypes: [outcome.type],
5106
+ payloadMode: "full",
5107
+ }
5108
+ : { view: "results" }),
5109
+ after: outcome.sequence - 1,
5110
+ before: outcome.sequence + 1,
5111
+ limit: 1,
5112
+ },
5113
+ }
5114
+ : { tool: "session_get", arguments: { sessionId } },
5115
+ });
5116
+ },
5117
+ );
5118
+ if (can("sessions:control"))
5119
+ server.registerTool(
5120
+ "session_target_set",
5121
+ {
5122
+ description:
5123
+ "Select an accessible session as conversational context, or clear with sessionId=null. Read session_target_get first and pass its version. Does not dispatch work, change authority or transfer/restart voice. Keep the same operationId for an exact retry.",
5124
+ inputSchema: SetSessionTargetRequest.shape,
5125
+ },
5126
+ async (request) => {
5127
+ await authorizeFirstPartySession(
5128
+ deps,
5129
+ grant,
5130
+ callerSessionId,
5131
+ "session.first_party_mcp.call",
5132
+ );
5133
+ const claims = exactAgentAttemptClaims(grant);
5134
+ if (!claims || claims.sessionId !== callerSessionId)
5135
+ throw new Error("Exact caller attempt required");
5136
+ return json(
5137
+ await setSessionTargetContext(deps.db, {
5138
+ workspaceId: grant.workspaceId,
5139
+ actor: { type: "agent_attempt", ...claims },
5140
+ request,
5141
+ authorizeTarget: async (db, sessionId) => {
5142
+ await authorizeFirstPartySession({ ...deps, db }, grant, sessionId, "session.read");
5143
+ },
5144
+ }),
5145
+ );
5146
+ },
5147
+ );
5148
+ }
5149
+ if (can("sessions:read")) {
5150
+ server.registerTool(
5151
+ "sessions_list",
5152
+ {
5153
+ description: `Discover sessions in this workspace. Default detail=compact returns id, title, status, goal summary/status when present, updatedAt, meaningful parent/pause information, total and nextCursor (null ends the page). Completed/paused goal status is retained; session_get supplies evidence and rationale. detail=full opts into the legacy bounded discovery rows and diagnostic pagination, not full session configuration. Plain compact browse does not read work claims. includeRelatedWork=true adds advisory evidence; query or subject always enables it, even with includeRelatedWork=false. query searches only titles, active goals and typed claims; subject is an exact provider-neutral claim lookup. Evidence preserves advisoryOnly/noAdditionalAccess: claims are nonexclusive, never locks, instructions or authority. Authorization precedes filtering, ranking, counts and cursors. Relevance cursors bind normalized filters and an activity snapshot. Without search the default is creation order; use orderBy=updatedAt and decimal updatedAfter/updatedThrough revision tokens for gap-free incremental monitoring, not timestamps. Pass nextCursor unchanged with the same filters. includeLastMessage opts into previews and nonzero queuedPromptCount; unclaimed human/API prompts are never previewed. Previews share a ${SESSION_DISCOVERY_PREVIEW_MAX_BYTES}-byte UTF-8 budget; omitted previews carry a bounded session_events drill-down. Text/page loss facts appear only when loss occurs in compact mode. No instructions, resources, tools, files or history are returned; REST/UI defaults are unchanged.`,
5154
+ inputSchema: {
5155
+ limit: z4.number().int().positive().max(100).optional(),
5156
+ cursor: z4.string().max(512).optional(),
5157
+ detail: z4
5158
+ .enum(["compact", "full"])
5159
+ .optional()
5160
+ .describe(
5161
+ "compact (default) omits empty diagnostics but retains positive descendant attention counts and incomplete-tree loss facts; full restores the legacy bounded rows and pagination diagnostics.",
5162
+ ),
5163
+ includeRelatedWork: z4.boolean().optional(),
5164
+ includeLastMessage: z4.boolean().optional(),
5165
+ orderBy: z4.enum(["createdAt", "updatedAt", "relevance"]).optional(),
5166
+ updatedAfter: z4.string().max(64).optional(),
5167
+ query: z4.string().max(MCP_DISCOVERY_QUERY_MAX_UTF16_CODE_UNITS).optional(),
5168
+ statuses: z4
5169
+ .array(
5170
+ z4.enum([
5171
+ "queued",
5172
+ "running",
5173
+ "idle",
5174
+ "requires_action",
5175
+ "recovering",
5176
+ "waiting_capacity",
5177
+ "failed",
5178
+ "cancelled",
5179
+ ]),
5180
+ )
5181
+ .max(8)
5182
+ .optional(),
4202
5183
  activeOnly: z4.boolean().optional(),
4203
5184
  originSiteId: z4
4204
5185
  .string()
@@ -4355,7 +5336,7 @@ function registerWorkspaceOrchestrationTools(
4355
5336
  "session_get",
4356
5337
  {
4357
5338
  description:
4358
- "Get an authorized session. Omit sessionId to read only the authenticated current agent session (a child reads itself, never its parent or root); sessionless/operator callers must provide an explicit ID. Both forms retain live-attempt and target authorization checks. Default detail=compact returns status, goal (including completion evidence or pause rationale), latest recorded goal progress, meaningful pause/wait state, queue counts, active turn and snapshot lastSequence. Queued status and updatedAt are not proof of execution; inspect the active turn and durable results. Goal completion is not a terminal child result: join with session_wait waitFor=completion from the last consumed event cursor (0 if none), not this snapshot lastSequence, which may already include an unread completion. For an already-settled child, retrieve its result-bearing completion with session_events or join from the last consumed cursor. Use detail=full for the legacy bounded configuration including resources, persisted tool refs, effectiveToolPolicy and variableSet ids (never variable values). Full mode is configuration, not a substitute for compact goal/progress facts. Self reads inspect session state, not conversation history, which is supplied directly. Text loss is explicit; REST/UI defaults are unchanged.",
5339
+ "Get an authorized session. Omit sessionId to read only the authenticated current agent session (a child reads itself, never its parent or root); sessionless/operator callers must provide an explicit ID. Both forms retain live-attempt and target authorization checks. Default detail=compact returns status, goal (including completion evidence or pause rationale), latest recorded goal progress, meaningful pause/wait state, queue counts, active turn and snapshot lastSequence. Queued status and updatedAt are not proof of execution; inspect the active turn and durable results. An unchanged snapshot is not new evidence of a child's progress: do not poll it between session_wait calls as a ritual. For a long or uncertain child wait, call wait_for_input instead; no preliminary snapshot or short wait is required. Its terminal result wakes you and carries its final answer (payload.finalAnswer). Meaningful user/task/Skill monitoring cadences remain supported. Goal completion is not a terminal child result: join with session_wait waitFor=completion from the last consumed event cursor (0 if none), not this snapshot lastSequence, which may already include an unread completion. For an already-settled child, retrieve its result-bearing completion with session_events or join from the last consumed cursor. Use detail=full for the legacy bounded configuration including resources, persisted tool refs, effectiveToolPolicy and variableSet ids (never variable values). Full mode is configuration, not a substitute for compact goal/progress facts. Self reads inspect session state, not conversation history, which is supplied directly. Text loss is explicit; REST/UI defaults are unchanged.",
4359
5340
  inputSchema: {
4360
5341
  sessionId: z4
4361
5342
  .string()
@@ -4403,7 +5384,15 @@ function registerWorkspaceOrchestrationTools(
4403
5384
  }
4404
5385
  return json(
4405
5386
  boundSessionDetailMcp(
4406
- await withMcpEffectivePolicy(deps, grant.workspaceId, grant.subjectId, projected),
5387
+ await withMcpEffectivePolicy(
5388
+ deps,
5389
+ grant.workspaceId,
5390
+ grant.subjectId,
5391
+ // The Codex pointers by cutover disposition, as REST shows them.
5392
+ (await loadCodexSessionPointerProjection(deps.db, grant.workspaceId, [projected]))(
5393
+ projected,
5394
+ ),
5395
+ ),
4407
5396
  ),
4408
5397
  );
4409
5398
  },
@@ -4413,45 +5402,58 @@ function registerWorkspaceOrchestrationTools(
4413
5402
  "session_events",
4414
5403
  {
4415
5404
  description:
4416
- "Read session history. Default view=conversation returns roughly ten complete user/assistant messages, including completed commentary, in a 16 KiB envelope; no token deltas or execution records. Prefer fewer complete messages; a single oversized message has fragment offsets and a lossless nextCursor continuation over retained source text, including large legacy rows. Fragment unit is codepoint for plain text or utf16 for codec text; pass the opaque v2 cursor unchanged (v1 cursors must restart). Pass cursor=nextCursor with sessionId, omitting other selectors; it binds the view, detail and direction. after/nextAfter and before/nextBefore only change position, never view or detail; use nextCursor when present to avoid skipping a message fragment. view=results returns final turn answers and actionable outcomes without duplicate message-completion text. view=tools returns compact call/result identities; includeArguments/includeOutput opt into one text or JSON-encoded value, and callId selects an exact call (sparse scans can return an empty advancing page). sourceExact=false and sourceOmitted identify oversized structured values that were omitted, never partial JSON presented as complete; scalar text remains resumable. Conversation/results/tools omit never-claimed human/API prompts and stale duplicate events. view=debug exposes the existing authorized audit query with explicit type/class filters, mode=monitoring|forensic and payloadMode=none|summary|full; raw deltas and never-claimed prompts require mode=forensic. Explicit legacy audit selectors remain supported without view. latest is an exclusive semantic-class lookup; resultMode=compact requires latest. No read observes commands or changes append-only history. REST behavior is unchanged.",
5405
+ "Read session history. Default view=conversation returns roughly ten complete user/assistant messages, including completed commentary, in a 16 KiB envelope; no token deltas or execution records. Prefer fewer complete messages; a single oversized message has fragment offsets and a lossless nextCursor continuation over retained source text, including large legacy rows. Fragment unit is codepoint for plain text or utf16 for codec text; pass the opaque v2 cursor unchanged (v1 cursors must restart). Pass cursor=nextCursor with sessionId, omitting other selectors; it preserves the view, detail, direction and page size. Copy nextCursor byte-for-byte, never retype or build one; when you cannot, repeat the call with before=nextBefore (or after=nextAfter) instead. limit is 1–50 for content views; larger values are rejected. after/nextAfter and before/nextBefore only change position, never view or detail; use nextCursor when present to avoid skipping a message fragment. view=results returns final turn answers and actionable outcomes without duplicate message-completion text. view=tools returns compact call/result identities; toolName finds exact named calls across retained history. includeArguments/includeOutput opt into one text or JSON-encoded value, and callId selects an exact call (sparse scans can return an empty advancing page). toolName with includeOutput=true returns each named call with its result, limit 1-3 (default 1, the newest unless direction=after). sourceExact=false and sourceOmitted identify oversized structured values that were omitted, never partial JSON presented as complete; scalar text remains resumable. Conversation/results/tools omit never-claimed human/API prompts and stale duplicate events. view=debug exposes the existing authorized audit query with explicit type/class filters, mode=monitoring|forensic and payloadMode=none|summary|full; raw deltas and never-claimed prompts require mode=forensic. Explicit legacy audit selectors remain supported without view. latest is an exclusive semantic-class lookup; resultMode=compact requires latest. No read observes commands or changes append-only history. REST behavior is unchanged.",
4417
5406
  inputSchema: {
4418
5407
  sessionId: z4.string().uuid(),
4419
5408
  view: z4.enum(["conversation", "results", "tools", "debug"]).optional(),
4420
5409
  cursor: z4.string().max(4096).optional(),
4421
5410
  callId: z4.string().max(512).optional(),
5411
+ toolName: z4
5412
+ .string()
5413
+ .min(1)
5414
+ .max(256)
5415
+ .optional()
5416
+ .describe(
5417
+ "Exact tool name; selects calls in view=tools. With includeOutput=true each call carries its result.",
5418
+ ),
4422
5419
  includeArguments: z4.boolean().optional(),
4423
5420
  includeOutput: z4.boolean().optional(),
4424
5421
  after: z4.number().int().nonnegative().optional(),
4425
5422
  before: z4.number().int().positive().optional(),
4426
- limit: z4.number().int().positive().optional(),
5423
+ limit: z4
5424
+ .number()
5425
+ .int()
5426
+ .positive()
5427
+ .optional()
5428
+ .describe("Page size, 1–50 for conversation/results/tools. Continuations preserve it."),
4427
5429
  direction: z4.enum(SessionEventReadDirection.options).optional(),
4428
5430
  mode: z4.enum(SessionEventReadMode.options).optional(),
4429
5431
  payloadMode: z4.enum(SessionEventPayloadMode.options).optional(),
4430
5432
  resultMode: z4.enum(SessionEventResultMode.options).optional(),
4431
5433
  includeTypes: z4
4432
- .array(
4433
- z4
4434
- .string()
4435
- .refine(
4436
- (value) => SessionEventType.safeParse(value).success,
4437
- "Unknown session event type",
4438
- ),
4439
- )
5434
+ .array(z4.string())
4440
5435
  .max(100)
5436
+ .superRefine((values, context) =>
5437
+ addUnknownSessionEventTypeIssues(
5438
+ values,
5439
+ (value) => SessionEventType.safeParse(value).success,
5440
+ (issue) => context.addIssue(issue),
5441
+ ),
5442
+ )
4441
5443
  .describe(
4442
5444
  "Debug audit event types, e.g. turn.completed, user.message, agent.message.completed, agent.toolCall.output. Validated against the canonical event-type registry.",
4443
5445
  )
4444
5446
  .optional(),
4445
5447
  excludeTypes: z4
4446
- .array(
4447
- z4
4448
- .string()
4449
- .refine(
4450
- (value) => SessionEventType.safeParse(value).success,
4451
- "Unknown session event type",
4452
- ),
4453
- )
5448
+ .array(z4.string())
4454
5449
  .max(100)
5450
+ .superRefine((values, context) =>
5451
+ addUnknownSessionEventTypeIssues(
5452
+ values,
5453
+ (value) => SessionEventType.safeParse(value).success,
5454
+ (issue) => context.addIssue(issue),
5455
+ ),
5456
+ )
4455
5457
  .describe(
4456
5458
  "Debug audit event types to exclude; validated against the canonical event-type registry.",
4457
5459
  )
@@ -4467,31 +5469,40 @@ function registerWorkspaceOrchestrationTools(
4467
5469
  latest: z4.enum(SessionEventLatestClass.options).optional(),
4468
5470
  },
4469
5471
  },
4470
- async ({
4471
- sessionId,
4472
- view,
4473
- cursor,
4474
- callId,
4475
- includeArguments,
4476
- includeOutput,
4477
- after,
4478
- before,
4479
- limit,
4480
- direction: requestedDirection,
4481
- mode: requestedMode,
4482
- payloadMode: requestedPayloadMode,
4483
- resultMode: requestedResultMode,
4484
- includeTypes: requestedIncludeTypes,
4485
- excludeTypes: requestedExcludeTypes,
4486
- includeClasses,
4487
- excludeClasses,
4488
- latest,
4489
- }) => {
5472
+ async (
5473
+ {
5474
+ sessionId,
5475
+ view,
5476
+ cursor,
5477
+ callId,
5478
+ toolName,
5479
+ includeArguments,
5480
+ includeOutput,
5481
+ after,
5482
+ before,
5483
+ limit,
5484
+ direction: requestedDirection,
5485
+ mode: requestedMode,
5486
+ payloadMode: requestedPayloadMode,
5487
+ resultMode: requestedResultMode,
5488
+ includeTypes: requestedIncludeTypes,
5489
+ excludeTypes: requestedExcludeTypes,
5490
+ includeClasses,
5491
+ excludeClasses,
5492
+ latest,
5493
+ },
5494
+ extra,
5495
+ ) => {
4490
5496
  await authorizeFirstPartySession(deps, grant, sessionId, "session.events.read");
4491
5497
  // Keep the model schema compact without weakening either MCP validation
4492
5498
  // or direct adapter calls: the canonical registry owns accepted types.
4493
- const includeTypes = requestedIncludeTypes?.map((type) => SessionEventType.parse(type));
4494
- const excludeTypes = requestedExcludeTypes?.map((type) => SessionEventType.parse(type));
5499
+ const canonicalType = (type: string) => {
5500
+ const parsed = SessionEventType.safeParse(type);
5501
+ if (!parsed.success) throw new Error(unknownSessionEventTypeMessage(type));
5502
+ return parsed.data;
5503
+ };
5504
+ const includeTypes = requestedIncludeTypes?.map(canonicalType);
5505
+ const excludeTypes = requestedExcludeTypes?.map(canonicalType);
4495
5506
  const latestClass =
4496
5507
  latest === undefined ? undefined : sessionEventLatestClassToSemanticClass(latest);
4497
5508
  if (requestedResultMode === "compact" && latestClass === undefined) {
@@ -4510,45 +5521,80 @@ function registerWorkspaceOrchestrationTools(
4510
5521
  ].some((value) => value !== undefined);
4511
5522
  if (((view !== undefined && view !== "debug") || cursor !== undefined) && auditRequested) {
4512
5523
  throw new Error(
4513
- "Audit selectors require view=debug and cannot change a conversation cursor",
5524
+ sessionEventAuditSelectorMessage({
5525
+ sessionId,
5526
+ view,
5527
+ cursor,
5528
+ audit: {
5529
+ mode: requestedMode,
5530
+ payloadMode: requestedPayloadMode,
5531
+ resultMode: requestedResultMode,
5532
+ includeTypes,
5533
+ excludeTypes,
5534
+ includeClasses,
5535
+ excludeClasses,
5536
+ latest,
5537
+ },
5538
+ position: { after, before, direction: requestedDirection, limit },
5539
+ }),
4514
5540
  );
4515
5541
  }
4516
5542
  if (view !== "debug" && !auditRequested) {
4517
5543
  const { readSessionEventView } = await import("./session-event-view");
4518
5544
  const { listSessionEventSlices } = await import("@opengeni/db/session-event-slices");
4519
- return json(
4520
- await readSessionEventView(
4521
- {
5545
+ const page = await readSessionEventView(
5546
+ {
5547
+ sessionId,
5548
+ view,
5549
+ cursor,
5550
+ callId,
5551
+ toolName,
5552
+ includeArguments,
5553
+ includeOutput,
5554
+ after,
5555
+ before,
5556
+ direction: requestedDirection,
5557
+ limit,
5558
+ },
5559
+ (options) =>
5560
+ listSessionEventSlices(
5561
+ deps.db,
5562
+ grant.workspaceId,
4522
5563
  sessionId,
4523
- view,
4524
- cursor,
4525
- callId,
4526
- includeArguments,
4527
- includeOutput,
4528
- after,
4529
- before,
4530
- direction: requestedDirection,
4531
- limit,
4532
- },
4533
- (options) =>
4534
- listSessionEventSlices(
4535
- deps.db,
4536
- grant.workspaceId,
4537
- sessionId,
4538
- options,
4539
- (legacyOptions) =>
4540
- listSessionEventPage(deps.db, grant.workspaceId, sessionId, legacyOptions),
4541
- ),
4542
- ),
5564
+ options,
5565
+ (legacyOptions) =>
5566
+ listSessionEventPage(deps.db, grant.workspaceId, sessionId, legacyOptions),
5567
+ ),
4543
5568
  );
5569
+ const consumed = [{ sessionId, sequences: completeChildReadSequences(page) }];
5570
+ await acknowledgeReads(consumed);
5571
+ await recordConsumedChildResults(consumed, extra);
5572
+ return json(page);
4544
5573
  }
4545
5574
  if (
4546
5575
  cursor !== undefined ||
4547
5576
  callId !== undefined ||
5577
+ toolName !== undefined ||
4548
5578
  includeArguments !== undefined ||
4549
5579
  includeOutput !== undefined
4550
5580
  ) {
4551
- throw new Error("cursor/callId/includeArguments/includeOutput require a non-debug view");
5581
+ throw new Error(
5582
+ `cursor/callId/toolName/includeArguments/includeOutput require a non-debug view; view=debug pages with after/before. Use ${JSON.stringify(
5583
+ callId === undefined &&
5584
+ toolName === undefined &&
5585
+ includeArguments === undefined &&
5586
+ includeOutput === undefined
5587
+ ? { sessionId, cursor: "<nextCursor, unchanged>" }
5588
+ : {
5589
+ sessionId,
5590
+ view: "tools",
5591
+ ...(callId !== undefined ? { callId } : {}),
5592
+ ...(toolName !== undefined ? { toolName } : {}),
5593
+ ...(includeArguments !== undefined ? { includeArguments } : {}),
5594
+ ...(includeOutput !== undefined ? { includeOutput } : {}),
5595
+ },
5596
+ )} instead.`,
5597
+ );
4552
5598
  }
4553
5599
  if (
4554
5600
  latest &&
@@ -4584,40 +5630,62 @@ function registerWorkspaceOrchestrationTools(
4584
5630
  });
4585
5631
  if (requestedResultMode === "compact") {
4586
5632
  const event = dbPage.events[0];
4587
- return json(
4588
- event
4589
- ? boundSessionEventCompactResult(
4590
- compactSessionEventResult(
4591
- event,
4592
- latestClass!,
4593
- dbPage.coveredSequence ?? {
4594
- first: event.sequence,
4595
- last: event.sequence,
4596
- },
4597
- ),
4598
- )
4599
- : null,
4600
- );
5633
+ const result = event
5634
+ ? boundSessionEventCompactResult(
5635
+ compactSessionEventResult(
5636
+ event,
5637
+ latestClass!,
5638
+ dbPage.coveredSequence ?? {
5639
+ first: event.sequence,
5640
+ last: event.sequence,
5641
+ },
5642
+ ),
5643
+ )
5644
+ : null;
5645
+ // The compact text of a turn that ended waiting for input is its
5646
+ // empty output, never the reply its human's message received, so
5647
+ // returning it is not proof of reading that reply.
5648
+ if (
5649
+ result &&
5650
+ event &&
5651
+ ["turn.completed", "agent.message.completed"].includes(result.type) &&
5652
+ turnCompletedReply(event.payload) === null &&
5653
+ !result.truncation.truncated &&
5654
+ dbPage.fullPayloadsExact
5655
+ ) {
5656
+ const consumed = [{ sessionId, sequences: [result.sequence] }];
5657
+ await acknowledgeReads(consumed);
5658
+ await recordConsumedChildResults(consumed, extra);
5659
+ }
5660
+ return json(result);
4601
5661
  }
4602
- return json(
4603
- boundSessionEventMcpPage({
4604
- events: dbPage.events,
4605
- mode,
4606
- payloadMode,
4607
- direction,
4608
- sourceHasMore: dbPage.hasMore,
4609
- sourceTruncatedBy: dbPage.truncatedBy,
4610
- after: after ?? 0,
4611
- before: before ?? null,
4612
- }),
4613
- );
5662
+ const page = boundSessionEventMcpPage({
5663
+ events: dbPage.events,
5664
+ mode,
5665
+ payloadMode,
5666
+ direction,
5667
+ sourceHasMore: dbPage.hasMore,
5668
+ sourceTruncatedBy: dbPage.truncatedBy,
5669
+ after: after ?? 0,
5670
+ before: before ?? null,
5671
+ });
5672
+ if (
5673
+ payloadMode === "full" &&
5674
+ dbPage.fullPayloadsExact &&
5675
+ !page.truncation?.reasons.includes("model_payload")
5676
+ ) {
5677
+ const consumed = [{ sessionId, sequences: page.events.map((event) => event.sequence) }];
5678
+ await acknowledgeReads(consumed);
5679
+ await recordConsumedChildResults(consumed, extra);
5680
+ }
5681
+ return json(page);
4614
5682
  },
4615
5683
  );
4616
5684
 
4617
5685
  server.registerTool(
4618
5686
  "session_wait",
4619
5687
  {
4620
- description: `Wait once for durable session changes, your pending machine input, or maxWaitSeconds (default ${SESSION_WAIT_DEFAULT_SECONDS}, max ${SESSION_WAIT_MAX_SECONDS}). Pass targets with sessionId and afterSequence: the last consumed cursor, or 0. Never substitute session_get.lastSequence, which may include an unread completion. waitFor=change returns on turn lifecycle, completed messages, terminal commands, blockers, goal facts, or session control. waitFor=completion joins a child result: only a result-bearing final turn or blocker qualifies, not commentary, goal.completed, background commands, maintenance turns, or continuation segments. Neither mode wakes on raw deltas or tool receipts. Each target contains up to ${SESSION_WAIT_EVENTS_PER_TARGET} bounded summaries, latestSequence (the next afterSequence), and hasMore. For omitted rows use session_events view=results after=latestSequence for final outcomes, or view=debug with explicit filters for diagnostics; the default conversation view does not contain execution records. Byte limits can leave events=[] with hasMore=true. ownPendingUpdates > 0 means input will arrive when your next turn is claimed: finish this turn, or use includeOwnPendingUpdates=false to keep waiting. timedOut=true means no matching change; liveFanout=false means the deadline re-check supplied durable truth without the live bus. Do not immediately repeat a timeout without new evidence. For long or uncertain waits call wait_for_input once and end the turn.`,
5688
+ description: `Wait once for durable session changes, your pending machine input, or maxWaitSeconds (default ${SESSION_WAIT_DEFAULT_SECONDS}, max ${SESSION_WAIT_MAX_SECONDS}). This is an optional in-turn execution wait, not a prerequisite for wait_for_input. Pass targets with sessionId and afterSequence: the last consumed cursor, or 0. Never substitute session_get.lastSequence, which may include an unread completion. waitFor=change returns on turn lifecycle, settled answers, terminal commands, blockers, goal facts, or session control. waitFor=completion joins a child result: only a result-bearing final turn or blocker qualifies, not commentary, goal.completed, background commands, maintenance turns, or continuation segments. Neither mode wakes on raw deltas, progress commentary, or tool receipts. Each target contains up to ${SESSION_WAIT_EVENTS_PER_TARGET} bounded summaries, latestSequence (the next afterSequence), and hasMore. For omitted rows use session_events view=results after=latestSequence for final outcomes, or view=debug with explicit filters for diagnostics; the default conversation view does not contain execution records. Byte limits can leave events=[] with hasMore=true. ownPendingUpdates > 0 means input will arrive when your next turn is claimed: finish this turn, or use includeOwnPendingUpdates=false to keep waiting. timedOut=true means no matching change; liveFanout=false means the deadline re-check supplied durable truth without the live bus. Do not immediately repeat a timeout without new evidence; an unchanged session_get snapshot between waits is not new evidence. For long or uncertain waits, including a child that needs minutes, call wait_for_input once and end the turn: the child's terminal result wakes you and carries its final answer (payload.finalAnswer). Use meaningful user/task/Skill monitoring cadences when needed; an out-of-turn wait may span hours or days within its own limits.`,
4621
5689
  inputSchema: {
4622
5690
  targets: z4
4623
5691
  .array(
@@ -4665,56 +5733,81 @@ function registerWorkspaceOrchestrationTools(
4665
5733
  // (tests) may pass no extra at all.
4666
5734
  const signal: AbortSignal | undefined = extra?.signal;
4667
5735
  const workspaceId = grant.workspaceId;
5736
+ const incompleteWaitEvents = new Set<string>();
4668
5737
  // A NATS subscription is live fanout only; the durable session_events
4669
5738
  // read below is the authority. Bun.serve idleTimeout (255 s) and the
4670
5739
  // 60 s MCP client request timeout both exceed the 50 s cap.
4671
- return json(
4672
- await waitForSessionChanges({
4673
- targets,
4674
- ownSessionId,
4675
- maxWaitMs: (maxWaitSeconds ?? SESSION_WAIT_DEFAULT_SECONDS) * 1_000,
4676
- targetEventTypes,
4677
- targetEventMatches:
4678
- waitFor === "completion" ? sessionWaitCompletionEventMatches : undefined,
4679
- signal,
4680
- source: {
4681
- reauthorizeTargets: async (sessionIds) => {
4682
- for (const targetSessionId of sessionIds) {
4683
- await authorizeFirstPartySession(
4684
- deps,
4685
- grant,
4686
- targetSessionId,
4687
- "session.events.read",
4688
- );
4689
- }
4690
- },
4691
- readTargetEvents: async (target) => {
4692
- const page = await listSessionEventPage(deps.db, workspaceId, target.sessionId, {
4693
- after: target.afterSequence,
4694
- direction: "after",
4695
- limit: SESSION_WAIT_EVENTS_PER_TARGET,
4696
- payloadMode: "full",
4697
- includeTypes: targetEventTypes,
4698
- maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
4699
- });
4700
- return { events: page.events, hasMore: page.hasMore };
4701
- },
4702
- readOwnPendingUpdateKinds:
4703
- ownSessionId === null
4704
- ? null
4705
- : async () =>
4706
- (
4707
- await listOutstandingSessionSystemUpdates(
4708
- deps.db,
4709
- workspaceId,
4710
- ownSessionId,
4711
- )
4712
- ).map((update) => update.kind),
4713
- subscribe: (targetSessionId, onEvents) =>
4714
- deps.bus.subscribe(workspaceId, targetSessionId, onEvents),
5740
+ const result = await waitForSessionChanges({
5741
+ targets,
5742
+ ownSessionId,
5743
+ maxWaitMs: (maxWaitSeconds ?? SESSION_WAIT_DEFAULT_SECONDS) * 1_000,
5744
+ targetEventTypes,
5745
+ targetEventMatches:
5746
+ waitFor === "completion"
5747
+ ? sessionWaitCompletionEventMatches
5748
+ : sessionWaitChangeEventMatches,
5749
+ signal,
5750
+ source: {
5751
+ reauthorizeTargets: async (sessionIds) => {
5752
+ for (const targetSessionId of sessionIds) {
5753
+ await authorizeFirstPartySession(
5754
+ deps,
5755
+ grant,
5756
+ targetSessionId,
5757
+ "session.events.read",
5758
+ );
5759
+ }
4715
5760
  },
4716
- }),
4717
- );
5761
+ readTargetEvents: async (target) => {
5762
+ const page = await listSessionEventPage(deps.db, workspaceId, target.sessionId, {
5763
+ after: target.afterSequence,
5764
+ direction: "after",
5765
+ limit: SESSION_WAIT_EVENTS_PER_TARGET,
5766
+ payloadMode: "full",
5767
+ includeTypes: targetEventTypes,
5768
+ // Progress notes must not fill the page ahead of the outcome.
5769
+ excludeStreamedAssistantMessages: waitFor !== "completion",
5770
+ maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
5771
+ });
5772
+ if (!page.fullPayloadsExact) {
5773
+ for (const event of page.events) incompleteWaitEvents.add(event.id);
5774
+ }
5775
+ return { events: page.events, hasMore: page.hasMore };
5776
+ },
5777
+ readOwnPendingUpdateKinds:
5778
+ ownSessionId === null
5779
+ ? null
5780
+ : async () =>
5781
+ (await listOwnPendingUpdates(ownSessionId)).map((update) => update.kind),
5782
+ subscribe: (targetSessionId, onEvents) =>
5783
+ deps.bus.subscribe(workspaceId, targetSessionId, onEvents),
5784
+ },
5785
+ });
5786
+ if (!result.aborted && !result.truncated) {
5787
+ const consumed = result.changed.map((target) => ({
5788
+ sessionId: target.sessionId,
5789
+ sequences: target.events
5790
+ .filter((event) => event.contentComplete && !incompleteWaitEvents.has(event.id))
5791
+ .map((event) => event.sequence),
5792
+ }));
5793
+ await acknowledgeReads(consumed);
5794
+ await recordConsumedChildResults(consumed, extra);
5795
+ if (result.ownPendingUpdates > 0 && ownSessionId !== null) {
5796
+ // The returned answer may be the pending input this result
5797
+ // counted; report what is still pending so the caller is not told
5798
+ // to end its turn only to receive the answer it already has.
5799
+ const pending = await listOwnPendingUpdates(ownSessionId);
5800
+ if (pending.length !== result.ownPendingUpdates) {
5801
+ return json(
5802
+ withOwnPendingUpdateKinds(
5803
+ result,
5804
+ pending.map((update) => update.kind),
5805
+ ),
5806
+ );
5807
+ }
5808
+ }
5809
+ }
5810
+ return json(result);
4718
5811
  },
4719
5812
  );
4720
5813
 
@@ -4723,7 +5816,7 @@ function registerWorkspaceOrchestrationTools(
4723
5816
  server.registerTool(
4724
5817
  toolName,
4725
5818
  {
4726
- description: `${toolName === "command_read" ? "Read retained output immediately, or briefly wait" : "Briefly wait using the same read operation as command_read"} for one command owned by the current session, across sandbox and Connected Machine providers. Returns bounded stdout/stderr chunks, durable state and exitCode, nextCursor and hasMore, plus explicit retention limitations. Pass nextCursor to continue; output remains readable after the process exits. A terminal read observes completion and suppresses only its still-pending inbox notification; running reads and already claimed history are unchanged. waitSeconds is at most 50; command_wait defaults to 45, command_read to 0. For long waits use wait_for_input. Neither tool sends stdin or cancels the command.`,
5819
+ description: `${toolName === "command_read" ? "Read retained output immediately, or briefly wait" : "Briefly wait using the same read operation as command_read"} for one command owned by the current session, across sandbox and Connected Machine providers. Returns bounded stdout/stderr chunks, durable state and exitCode, nextCursor and hasMore, plus explicit retention limitations. Pass nextCursor to continue; output remains readable after the process exits. A terminal read observes completion and suppresses only its still-pending inbox notification; running reads and already claimed history are unchanged. waitSeconds is at most 50; command_wait defaults to 45, command_read to 0. These execution-wait limits do not cap wait_for_input: use it for long out-of-turn waits, except pending Codemode calls require observation in the same live attempt. Neither tool sends stdin or cancels the command.`,
4727
5820
  inputSchema: {
4728
5821
  commandId: z4.string().uuid(),
4729
5822
  cursor: z4.string().max(128).optional(),
@@ -4770,131 +5863,15 @@ function registerWorkspaceOrchestrationTools(
4770
5863
  }
4771
5864
 
4772
5865
  if (can("sessions:create") && sessionCreateVisible) {
4773
- const sessionCreateInput = z4
4774
- .object({
4775
- initialMessage: z4.string().min(1),
4776
- projectId: z4
4777
- .string()
4778
- .uuid()
4779
- .nullable()
4780
- .optional()
4781
- .describe(
4782
- "Workspace project to file the new session into. Omit for existing default behavior; does not change runtime or visibility.",
4783
- ),
4784
- title: z4
4785
- .string()
4786
- .min(1)
4787
- .max(SESSION_TITLE_MAX_CHARACTERS)
4788
- .optional()
4789
- .describe(
4790
- "Concise semantic title for the child session. Omit only when the delegated goal or initial message already provides a suitable title; OpenGeni derives a sensitive-safe bounded fallback from that text.",
4791
- ),
4792
- instructions: z4.string().min(1).max(SESSION_INSTRUCTIONS_MAX_CHARACTERS).optional(),
4793
- goal: GoalSpec.optional(),
4794
- resources: z4
4795
- .array(z4.unknown())
4796
- .optional()
4797
- .describe(
4798
- "Omit to inherit parent repositories only. Files are never inherited automatically; explicitly include file resources to attach them. An empty array inherits no resources.",
4799
- ),
4800
- tools: z4.array(z4.unknown()).optional(),
4801
- mcpServers: z4.array(z4.unknown()).optional(),
4802
- variableSetId: z4.string().uuid().optional(),
4803
- variableSetIds: z4.array(z4.string().uuid()).max(MAX_SELECTED_VARIABLE_SETS).optional(),
4804
- environmentId: z4.string().uuid().optional(),
4805
- rigId: z4.string().uuid().optional(),
4806
- model: z4
4807
- .string()
4808
- .min(1)
4809
- .optional()
4810
- .describe(
4811
- "Model for the worker. Omit to inherit the exact calling turn's model, including its Codex subscription billing path.",
4812
- ),
4813
- reasoningEffort: z4
4814
- .string()
4815
- .optional()
4816
- .describe("Omit to inherit the exact calling turn's reasoning effort."),
4817
- latencyMode: z4
4818
- .enum(["standard", "priority", "fast"])
4819
- .optional()
4820
- .describe("Omit to inherit the exact calling turn's latency mode."),
4821
- sandboxBackend: z4.string().optional(),
4822
- // Model-only structural coupling: workingDir cannot exist without a
4823
- // targetSandboxId because both live inside one optional object. The
4824
- // handler maps this back to the stable public REST/SDK request fields.
4825
- machineTarget: z4
4826
- .object({
4827
- targetSandboxId: z4.string().uuid(),
4828
- workingDir: z4.string().optional(),
4829
- })
4830
- .strict()
4831
- .optional(),
4832
- metadata: z4.record(z4.string(), z4.unknown()).optional(),
4833
- idempotencyKey: z4.string().min(1).max(200).optional(),
4834
- firstPartyMcpPermissions: z4
4835
- .array(z4.string())
4836
- .optional()
4837
- .describe(
4838
- "Optional first-party capability set for the child. Omit to inherit this session's effective permissions. An explicit set may only narrow capabilities held by this session. A goal-bearing child requires goals:manage in the resulting set; creation fails rather than adding it implicitly.",
4839
- ),
4840
- firstPartyMcpTools: z4
4841
- .array(z4.enum(FIRST_PARTY_MCP_TOOL_NAMES))
4842
- .optional()
4843
- .describe(
4844
- "Exact model-visible first-party tool selection for the child. Omit to inherit this session's effective selection. An explicit selection may only narrow that selection: every listed tool must already be available to this session, and a wider list is rejected. To create a non-delegating leaf, provide a selection that omits session_create. This does not grant permissions.",
4845
- ),
4846
- // The child's agent-access scope and end-user label are never model
4847
- // choices: it inherits this session's exactly. Only the Memory
4848
- // selector may be narrowed here (workspace > user > off).
4849
- memoryScope: z4
4850
- .enum(["workspace", "user", "off"])
4851
- .optional()
4852
- .describe(
4853
- "Optional Memory selector for the child. Omit to inherit this session's selector. An explicit value may only narrow it (workspace > user > off); off gives the child no Memory tools. Use task notes for task-local findings.",
4854
- ),
4855
- // Omission is the ordinary safe sharing path. Literal "shared" remains
4856
- // available to advanced REST/SDK callers but is intentionally absent
4857
- // from the model surface because it turns compatibility drift into a
4858
- // deterministic failure instead of the omission path's safe own-box fallback.
4859
- sandbox: z4
4860
- .union([z4.literal("new"), z4.object({ groupId: z4.string().uuid() })])
4861
- .optional(),
4862
- })
4863
- .superRefine((value, context) => {
4864
- if (!value.variableSetIds) return;
4865
- if (new Set(value.variableSetIds).size !== value.variableSetIds.length) {
4866
- context.addIssue({
4867
- code: z4.ZodIssueCode.custom,
4868
- path: ["variableSetIds"],
4869
- message: "variableSetIds must not contain duplicates",
4870
- });
4871
- }
4872
- const singular = value.variableSetId ?? value.environmentId;
4873
- if (singular === undefined) return;
4874
- const expected = value.variableSetIds[value.variableSetIds.length - 1];
4875
- if (singular !== expected) {
4876
- context.addIssue({
4877
- code: z4.ZodIssueCode.custom,
4878
- path: ["variableSetId"],
4879
- message: "variableSetId must match the last variableSetIds entry",
4880
- });
4881
- }
4882
- })
4883
- .strict();
4884
5866
  server.registerTool(
4885
5867
  "session_create",
4886
5868
  {
4887
5869
  description:
4888
- "Spawn a new agent session (a worker) only for a concrete, bounded subtask that can run independently and has a defined integration point in your current work. Do not delegate work you will also perform yourself; track the child and join its actual result before completing dependent work. Give the child a concise semantic title; if omitted, OpenGeni derives one from its delegated goal or initial message. The child inherits this session's visibility, agent-access scope and end-user label; a private session can only create a same-owner private child, and memoryScope may only narrow this session's selector. Give a goal-bearing child its delegated objective. Its goal.rootConstraints may be an exact applicable subset of this accepted turn's frozen root constraints; omit that field to inherit all of them. Omit sandbox for the safe default: compatible children share the creator's box, while a different Variable Set, Rig, or machineTarget gets its own box. Use 'new' for deliberate isolation or {groupId} for a strict compatible sibling join. Put targetSandboxId and its optional workingDir together inside machineTarget; a machineTarget is always an own-box create even when the parent is backend none. To create a non-delegating leaf, pass a narrowed firstPartyMcpTools list that omits session_create; do not use a child-local depth override. Public REST/SDK callers retain advanced absolute depth and explicit shared-placement controls.",
4889
- inputSchema: sessionCreateInput,
5870
+ "Spawn a new agent session (a worker) only for a concrete, bounded subtask that can run independently and has a defined integration point in your current work. Delegation has setup and coordination overhead: by default, answer directly when the work takes only a few steps, and send a related follow-up to a worker you already spawned with session_send_message instead of spawning another. Explicit user requests and applicable Skill guidance for delegation, independent review, or fresh workers override that default within existing authority. After spawning work that needs minutes, once nothing else can advance, call wait_for_input and end the turn instead of alternating session_wait and session_get; no preliminary short wait or status recheck is required. The worker's terminal result wakes you and carries its final answer (payload.finalAnswer). Do not duplicate a child's implementation; independent review or comparison may intentionally examine the same subject with a distinct deliverable. Track the child and join its actual result before completing dependent work. Give the child a concise semantic title; if omitted, Opengeni derives one from its delegated goal or initial message. The child inherits this session's visibility, agent-access scope and end-user label; a private session can only create a same-owner private child, and memoryScope may only narrow this session's selector. Give a goal-bearing child its delegated objective. Its goal.rootConstraints may be an exact applicable subset of this accepted turn's frozen root constraints; omit that field to inherit all of them. Omit sandbox for the safe default: compatible children share the creator's box, while a different Sandbox Environment or machineTarget gets its own box. Use 'new' for deliberate isolation or {groupId} for a strict compatible sibling join. Put targetSandboxId and its optional workingDir together inside machineTarget; a machineTarget is always an own-box create even when the parent is backend none. Normally omit tools, mcpServers, and firstPartyMcpTools so the worker inherits this session's exact selections; specialize its task through initialMessage rather than a hand-written partial tool list. To create a worker that cannot start, message, or follow other sessions, use agent: { capabilities: { from: 'all', subagents: false } } and keep tool lists omitted; its final answer still reaches you automatically. Explicit tool arrays replace the inherited selection and are only for deliberate restrictions. Do not use a child-local depth override. Public REST/SDK callers retain advanced absolute depth and explicit shared-placement controls.",
5871
+ inputSchema: sessionCreateToolInput(childSelectableTools),
4890
5872
  },
4891
5873
  async (args) => {
4892
5874
  try {
4893
- requireVariableSetsUseForMcpAttachments(grant, {
4894
- variableSetIds: args.variableSetIds,
4895
- variableSetId: args.variableSetId,
4896
- environmentId: args.environmentId,
4897
- });
4898
5875
  if (callerSessionId !== null) {
4899
5876
  await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
4900
5877
  }
@@ -4926,7 +5903,7 @@ function registerWorkspaceOrchestrationTools(
4926
5903
  );
4927
5904
  return json(sessionCreateMutationReceipt(result, Boolean(request.idempotencyKey)));
4928
5905
  } catch (error) {
4929
- return orchestrationFailureResult("session_create", error);
5906
+ return orchestrationFailureResult("session_create", error, deps, grant);
4930
5907
  }
4931
5908
  },
4932
5909
  );
@@ -4937,14 +5914,14 @@ function registerWorkspaceOrchestrationTools(
4937
5914
  "session_send_message",
4938
5915
  {
4939
5916
  description:
4940
- "Acceptance is not execution. Keep resource.id: for agent messages, match that ID in payload.updateIds from session_events view=debug, includeTypes=[system.update.delivered], payloadMode=full; retain the event turnId and read its relevant result. An unrelated in-flight turn completing does not prove delivery. Do not resend an unconsumed message; inspect blockers. Worker messages are coalescible machine input, added to history when claimed. Sessionless operator calls append a human/API prompt and resource.id is its turn ID. Use your last consumed event sequence. Report stalled delivery if it cannot safely progress.",
5917
+ "To continue related work, by default message a worker you already spawned instead of spawning a new one; it keeps its context. Acceptance is not execution. Keep resource.id: for agent messages, match that ID in payload.updateIds from session_events view=debug, includeTypes=[system.update.delivered], payloadMode=full; retain the event turnId and read its relevant result. An unrelated in-flight turn completing does not prove delivery. Explicit user requests and applicable Skill guidance for independent review or fresh workers override that default within existing authority. Do not resend an unconsumed message; inspect blockers. Worker messages are coalescible machine input, added to history when claimed. Sessionless operator calls append a human/API prompt and resource.id is its turn ID. Use your last consumed event sequence. Report stalled delivery if it cannot safely progress.",
4941
5918
  inputSchema: {
4942
5919
  sessionId: z4.string().uuid(),
4943
5920
  text: z4.string().min(1),
4944
5921
  idempotencyKey: z4.string().uuid(),
4945
5922
  // Header-value rotation only. URL/name/tool settings are immutable
4946
5923
  // after create; core enforces mcp_servers:attach on this field.
4947
- mcpCredentialUpdates: z4.array(z4.unknown()).optional(),
5924
+ mcpCredentialUpdates: z4.array(SessionMcpCredentialUpdateInput).optional(),
4948
5925
  },
4949
5926
  },
4950
5927
  async ({ sessionId: targetSessionId, text, idempotencyKey, mcpCredentialUpdates }) => {
@@ -5031,7 +6008,7 @@ function registerWorkspaceOrchestrationTools(
5031
6008
  }),
5032
6009
  );
5033
6010
  } catch (error) {
5034
- return orchestrationFailureResult("session_send_message", error);
6011
+ return orchestrationFailureResult("session_send_message", error, deps, grant);
5035
6012
  }
5036
6013
  },
5037
6014
  );
@@ -5187,33 +6164,37 @@ function registerWorkspaceOrchestrationTools(
5187
6164
  },
5188
6165
  },
5189
6166
  async ({ sessionId, instruction, idempotencyKey }) => {
5190
- const result = await steerAgentSession(
5191
- deps,
5192
- exactAgentCommandContext(grant, callerSessionId, "first_party_mcp"),
5193
- { targetSessionId: sessionId, instruction, idempotencyKey },
5194
- );
5195
- return json(
5196
- mcpMutationReceipt({
5197
- operation: "session_steer",
5198
- committed: true,
5199
- outcome: result.replay ? "replayed" : "updated",
5200
- changed: !result.replay,
5201
- resource: {
5202
- type: "session_system_update",
5203
- id: result.updateId,
5204
- state: result.effectiveState,
5205
- },
5206
- relatedResources: [{ type: "session", id: sessionId }],
5207
- timestamp: result.receipt.createdAt.toISOString(),
5208
- idempotency: { status: result.replay ? "replayed" : "applied" },
5209
- facts: {
5210
- interruptionCount: result.interruptionCount,
5211
- stoppingPreviousAttempt: result.interruptionCount > 0,
5212
- },
5213
- updateId: result.updateId,
5214
- nextAction: { tool: "session_get", arguments: { sessionId } },
5215
- }),
5216
- );
6167
+ try {
6168
+ const result = await steerAgentSession(
6169
+ deps,
6170
+ exactAgentCommandContext(grant, callerSessionId, "first_party_mcp"),
6171
+ { targetSessionId: sessionId, instruction, idempotencyKey },
6172
+ );
6173
+ return json(
6174
+ mcpMutationReceipt({
6175
+ operation: "session_steer",
6176
+ committed: true,
6177
+ outcome: result.replay ? "replayed" : "updated",
6178
+ changed: !result.replay,
6179
+ resource: {
6180
+ type: "session_system_update",
6181
+ id: result.updateId,
6182
+ state: result.effectiveState,
6183
+ },
6184
+ relatedResources: [{ type: "session", id: sessionId }],
6185
+ timestamp: result.receipt.createdAt.toISOString(),
6186
+ idempotency: { status: result.replay ? "replayed" : "applied" },
6187
+ facts: {
6188
+ interruptionCount: result.interruptionCount,
6189
+ stoppingPreviousAttempt: result.interruptionCount > 0,
6190
+ },
6191
+ updateId: result.updateId,
6192
+ nextAction: { tool: "session_get", arguments: { sessionId } },
6193
+ }),
6194
+ );
6195
+ } catch (error) {
6196
+ return orchestrationFailureResult("session_steer", error, deps, grant);
6197
+ }
5217
6198
  },
5218
6199
  );
5219
6200
 
@@ -5225,22 +6206,47 @@ function registerWorkspaceOrchestrationTools(
5225
6206
  inputSchema: {
5226
6207
  sessionId: z4.string().uuid(),
5227
6208
  requestId: z4.string().uuid(),
5228
- response: z4.unknown(),
6209
+ response: SubmitHumanInputResponseRequest,
5229
6210
  idempotencyKey: z4.string().uuid(),
5230
6211
  },
5231
6212
  },
5232
6213
  async ({ sessionId, requestId, response, idempotencyKey }) => {
5233
6214
  await authorizeFirstPartySession(deps, grant, sessionId, "session.human_input.write");
5234
6215
  const context = exactAgentCommandContext(grant, callerSessionId, "first_party_mcp");
5235
- const parsedResponse = SubmitHumanInputResponseRequest.parse(response);
6216
+ // Secret questions take a {{secret:...}} reference in `values` or a
6217
+ // generate spec in secretAnswers; a raw value is refused (AC-7).
6218
+ const { response: parsedResponse, secretAnswers: rawSecretAnswers } = splitSecretAnswers(
6219
+ SubmitHumanInputResponseRequest.parse(response),
6220
+ undefined,
6221
+ );
5236
6222
  let accepted: Awaited<ReturnType<typeof acceptSessionHumanInputResponse>>;
5237
6223
  try {
6224
+ const secretAnswers = prepareSecretAnswers(deps.settings, rawSecretAnswers, "agent");
6225
+ if (secretAnswers.some((answer) => answer.saveTo === "user")) {
6226
+ throw new SecretAnswerRejectedError(
6227
+ "An agent cannot save to Only me; use saveTo task or workspace.",
6228
+ );
6229
+ }
5238
6230
  accepted = await acceptSessionHumanInputResponse(deps.db, {
5239
6231
  accountId: grant.accountId,
5240
6232
  workspaceId: grant.workspaceId,
5241
6233
  sessionId,
5242
6234
  requestId,
5243
6235
  response: parsedResponse,
6236
+ ...(secretAnswers.length
6237
+ ? {
6238
+ secretAnswers,
6239
+ secretWriteContext: attemptSecretWriteContext({
6240
+ kind: "attempt",
6241
+ accountId: grant.accountId,
6242
+ workspaceId: grant.workspaceId,
6243
+ sessionId: context.callerSessionId,
6244
+ turnId: context.callerTurnId,
6245
+ attemptId: context.callerAttemptId,
6246
+ executionGeneration: context.callerExecutionGeneration,
6247
+ }),
6248
+ }
6249
+ : {}),
5244
6250
  respondedBy: `agent_attempt:${context.callerAttemptId}`,
5245
6251
  respondedByKind: "agent_attempt",
5246
6252
  clientEventId: idempotencyKey,
@@ -5249,6 +6255,18 @@ function registerWorkspaceOrchestrationTools(
5249
6255
  if (error instanceof HumanInputResponseValidationError) {
5250
6256
  throw new Error(`human-input response rejected: ${error.message}`, { cause: error });
5251
6257
  }
6258
+ if (error instanceof SecretAnswerRejectedError) {
6259
+ throw new Error(`human-input response rejected: ${error.message}`, { cause: error });
6260
+ }
6261
+ const vaultError = isVaultError(error) ? error : vaultErrorFromDatabase(error);
6262
+ if (vaultError) {
6263
+ throw new Error(
6264
+ `human-input response rejected: ${vaultError.code}: ${vaultError.message}`,
6265
+ {
6266
+ cause: error,
6267
+ },
6268
+ );
6269
+ }
5252
6270
  throw error;
5253
6271
  }
5254
6272
  if (accepted.action === "not_found") {
@@ -5318,6 +6336,45 @@ function registerWorkspaceOrchestrationTools(
5318
6336
  });
5319
6337
  },
5320
6338
  );
6339
+
6340
+ server.registerTool(
6341
+ "session_set_model",
6342
+ {
6343
+ description:
6344
+ "Set an existing session's model and reasoning defaults for future turns. Use a model from list_models and specify the intended reasoning effort. Does not send a message, resume a paused session, wake an idle session, change latency mode, or rewrite already accepted turns/scheduled occurrences. Older queued turns keep their settings but cannot undo this choice when they start. Reuse the exact idempotencyKey for retries; session_get detail=full reads current effective defaults. Requires sessions:control and ordinary target-session authorization.",
6345
+ inputSchema: {
6346
+ sessionId: z4.string().uuid(),
6347
+ model: z4.string().min(1).max(512),
6348
+ reasoningEffort: z4.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]),
6349
+ idempotencyKey: z4.string().uuid(),
6350
+ },
6351
+ },
6352
+ async ({ sessionId, ...request }) => {
6353
+ const result = await setSessionModel(deps, grant, sessionId, request, "first_party_mcp");
6354
+ return json(
6355
+ mcpMutationReceipt({
6356
+ operation: "session_set_model",
6357
+ committed: true,
6358
+ outcome: result.replay ? "replayed" : "updated",
6359
+ changed: !result.replay,
6360
+ resource: { type: "session", id: sessionId },
6361
+ relatedResources: [
6362
+ { type: "session_command_receipt", id: result.receiptId },
6363
+ { type: "session_event", id: result.eventId },
6364
+ ],
6365
+ timestamp: result.timestamp,
6366
+ idempotency: { status: result.replay ? "replayed" : "applied" },
6367
+ facts: {
6368
+ model: result.model,
6369
+ reasoningEffort: result.reasoningEffort,
6370
+ latencyMode: result.latencyMode,
6371
+ effectiveFrom: result.effectiveFrom,
6372
+ },
6373
+ nextAction: { tool: "session_get", arguments: { sessionId, detail: "full" } },
6374
+ }),
6375
+ );
6376
+ },
6377
+ );
5321
6378
  }
5322
6379
  }
5323
6380
 
@@ -5356,340 +6413,93 @@ function registerProjectTools(
5356
6413
  );
5357
6414
  }
5358
6415
  if (can("sessions:create")) {
5359
- server.registerTool(
5360
- "project_create",
5361
- {
5362
- description:
5363
- "Create a workspace-shared sidebar group for related sessions. A project is organizational metadata, not a repository, working directory, or inherited agent configuration.",
5364
- inputSchema: { name: nameSchema, description: descriptionSchema.optional() },
5365
- },
5366
- async ({ name, description }) =>
5367
- json({
5368
- project: await createChannel(deps.db, {
5369
- accountId: grant.accountId,
5370
- workspaceId: grant.workspaceId,
5371
- name,
5372
- description: description ?? null,
5373
- createdBy: grant.subjectId,
5374
- }),
5375
- }),
5376
- );
5377
- server.registerTool(
5378
- "project_update",
5379
- {
5380
- description:
5381
- "Rename, describe or pin/unpin a workspace project. Pin state is shared by everyone in the workspace. Omitted fields stay unchanged; null clears the description.",
5382
- inputSchema: {
5383
- projectId: projectIdSchema,
5384
- name: nameSchema.optional(),
5385
- description: descriptionSchema.nullable().optional(),
5386
- pinned: z4.boolean().optional(),
5387
- },
5388
- },
5389
- async ({ projectId, ...update }) => {
5390
- const project = await updateChannel(deps.db, grant.workspaceId, projectId, update);
5391
- if (!project) throw new Error("Project not found");
5392
- return json({ project });
5393
- },
5394
- );
5395
- server.registerTool(
5396
- "project_reorder",
5397
- {
5398
- description:
5399
- "Replace the shared sidebar project order. Pass every current project ID exactly once, from project_list. Pinned projects still appear first. If projects changed, list again before retrying.",
5400
- inputSchema: { projectIds: z4.array(projectIdSchema).min(1).max(200) },
5401
- },
5402
- async ({ projectIds }) => {
5403
- const projects = await reorderChannels(deps.db, grant.workspaceId, projectIds);
5404
- if (!projects)
5405
- throw new Error("Projects changed or order is invalid; call project_list and retry");
5406
- return json({ projects });
5407
- },
5408
- );
5409
- server.registerTool(
5410
- "project_delete",
5411
- {
5412
- description:
5413
- "Delete a workspace-shared project. Its sessions are preserved and become unfiled; no running work is stopped.",
5414
- inputSchema: { projectId: projectIdSchema },
5415
- },
5416
- async ({ projectId }) => {
5417
- if (!(await deleteChannel(deps.db, grant.workspaceId, projectId)))
5418
- throw new Error("Project not found");
5419
- return json({ ok: true, projectId });
5420
- },
5421
- );
5422
- }
5423
- if (can("sessions:control")) {
5424
- server.registerTool(
5425
- "session_set_project",
5426
- {
5427
- description:
5428
- "File a session into a workspace project, or pass projectId=null to unfile it. Projects group root sessions in the user's sidebar. Filing changes organization only, not visibility, instructions, runtime, or history.",
5429
- inputSchema: { sessionId: z4.string().uuid(), projectId: projectIdSchema.nullable() },
5430
- },
5431
- async ({ sessionId, projectId }) => {
5432
- await authorizeFirstPartySession(deps, grant, sessionId, "session.control");
5433
- await requireSession(deps.db, grant.workspaceId, sessionId);
5434
- if (
5435
- !(await setSessionChannel(deps.db, {
5436
- workspaceId: grant.workspaceId,
5437
- sessionId,
5438
- channelId: projectId,
5439
- }))
5440
- )
5441
- throw new Error("Session not found");
5442
- return json({ ok: true, sessionId, projectId });
5443
- },
5444
- );
5445
- }
5446
- }
5447
-
5448
- // Variable-set management for agents. Generic reads remain metadata-only.
5449
- // Plaintext has one dedicated tool that additionally requires literal
5450
- // secrets:read plus exact live attempt/session authorization.
5451
- function registerVariableSetTools(
5452
- server: McpServer,
5453
- deps: ApiRouteDeps,
5454
- grant: AccessGrant,
5455
- can: (permission: Permission) => boolean,
5456
- sessionId: string | null,
5457
- json: JsonResult,
5458
- ): void {
5459
- const variableSetAccess = {
5460
- accountId: grant.accountId,
5461
- workspaceId: grant.workspaceId,
5462
- subjectId: grant.subjectId,
5463
- };
5464
- const registerListTool = (name: string, description: string): void => {
5465
- server.registerTool(
5466
- name,
5467
- {
5468
- description,
5469
- inputSchema: {},
5470
- },
5471
- async () => {
5472
- const variableSets = await listVariableSets(deps.db, variableSetAccess);
5473
- return json({ variableSets, environments: variableSets });
5474
- },
5475
- );
5476
- };
5477
- const setVariableHandler =
5478
- (operation: "variable_set_set_variable" | "environment_set_variable") =>
5479
- async ({
5480
- variableSetId,
5481
- variableSetName,
5482
- environmentId,
5483
- environmentName,
5484
- name,
5485
- value,
5486
- }: {
5487
- variableSetId?: string | undefined;
5488
- variableSetName?: string | undefined;
5489
- environmentId?: string | undefined;
5490
- environmentName?: string | undefined;
5491
- name: string;
5492
- value: string;
5493
- }) => {
5494
- const key = requireVariableSetEncryption(deps.settings);
5495
- const parsedName = VariableSetVariableName.safeParse(name);
5496
- if (!parsedName.success) {
5497
- throw new Error("variable set/environment variable names must match ^[A-Z][A-Z0-9_]*$");
5498
- }
5499
- assertAllowedVariableSetVariableName(parsedName.data);
5500
- const targetId = variableSetId ?? environmentId;
5501
- const targetName = variableSetName ?? environmentName;
5502
- if ((targetId === undefined) === (targetName === undefined)) {
5503
- throw new Error(
5504
- "provide exactly one of variableSetId or variableSetName; deprecated aliases must provide exactly one of environmentId or environmentName",
5505
- );
5506
- }
5507
- const trimmedVariableSetName = targetName?.trim();
5508
- if (targetName !== undefined && !trimmedVariableSetName) {
5509
- throw new Error("variable set name is required");
5510
- }
5511
- let created = false;
5512
- let variableSet =
5513
- targetId !== undefined
5514
- ? await getVariableSet(deps.db, variableSetAccess, targetId)
5515
- : await getVariableSetByName(deps.db, variableSetAccess, trimmedVariableSetName!);
5516
- if (!variableSet && targetId !== undefined) {
5517
- throw new Error("variable set/environment not found");
5518
- }
5519
- if (!variableSet) {
5520
- if (
5521
- (await countVariableSets(deps.db, variableSetAccess)) >= MAX_ENVIRONMENTS_PER_WORKSPACE
5522
- ) {
5523
- throw new Error(
5524
- `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} variable sets`,
5525
- );
5526
- }
5527
- variableSet = await createVariableSet(deps.db, {
5528
- accountId: grant.accountId,
5529
- workspaceId: grant.workspaceId,
5530
- scope: "workspace",
5531
- subjectId: grant.subjectId,
5532
- name: trimmedVariableSetName!,
5533
- });
5534
- created = true;
5535
- await recordVariableSetAuditEvent(deps.db, {
5536
- grant,
5537
- action: "variable_set.created",
5538
- variableSetId: variableSet.id,
5539
- });
5540
- }
5541
- const exists = variableSet.variables.some((variable) => variable.name === parsedName.data);
5542
- if (!exists && variableSet.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) {
5543
- throw new Error(
5544
- `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`,
5545
- );
5546
- }
5547
- const metadata = await setVariableSetVariable(deps.db, {
5548
- accountId: grant.accountId,
5549
- workspaceId: grant.workspaceId,
5550
- subjectId: grant.subjectId,
5551
- variableSetId: variableSet.id,
5552
- name: parsedName.data,
5553
- valueEncrypted: encryptVariableSetValue(key, value),
5554
- });
5555
- await recordVariableSetAuditEvent(deps.db, {
5556
- grant,
5557
- action: "variable_set.variable.set",
5558
- variableSetId: variableSet.id,
5559
- variableName: parsedName.data,
5560
- });
5561
- return json(
5562
- mcpMutationReceipt({
5563
- operation,
5564
- committed: true,
5565
- outcome: exists ? "updated" : "created",
5566
- changed: true,
5567
- resource: {
5568
- type: "variable_set",
5569
- id: variableSet.id,
5570
- version: metadata.version,
5571
- state: "variable_written",
5572
- },
5573
- timestamp: metadata.updatedAt,
5574
- idempotency: { status: "not_supported" },
5575
- facts: {
5576
- variableCreated: !exists,
5577
- variableSetCreated: created,
5578
- deprecatedAlias: operation === "environment_set_variable",
5579
- },
5580
- nextAction: { tool: "variable_set_list", arguments: {} },
6416
+ server.registerTool(
6417
+ "project_create",
6418
+ {
6419
+ description:
6420
+ "Create a workspace-shared sidebar group for related sessions. A project is organizational metadata, not a repository, working directory, or inherited agent configuration.",
6421
+ inputSchema: { name: nameSchema, description: descriptionSchema.optional() },
6422
+ },
6423
+ async ({ name, description }) =>
6424
+ json({
6425
+ project: await createChannel(deps.db, {
6426
+ accountId: grant.accountId,
6427
+ workspaceId: grant.workspaceId,
6428
+ name,
6429
+ description: description ?? null,
6430
+ createdBy: grant.subjectId,
6431
+ }),
5581
6432
  }),
5582
- );
5583
- };
5584
- const registerSetTool = (
5585
- name: "variable_set_set_variable" | "environment_set_variable",
5586
- description: string,
5587
- ): void => {
6433
+ );
5588
6434
  server.registerTool(
5589
- name,
6435
+ "project_update",
5590
6436
  {
5591
- description,
6437
+ description:
6438
+ "Rename, describe or pin/unpin a workspace project. Pin state is shared by everyone in the workspace. Omitted fields stay unchanged; null clears the description.",
5592
6439
  inputSchema: {
5593
- variableSetId: z4.string().uuid().optional(),
5594
- variableSetName: z4.string().min(1).optional(),
5595
- environmentId: z4.string().uuid().optional(),
5596
- environmentName: z4.string().min(1).optional(),
5597
- name: z4.string().min(1),
5598
- value: z4.string().min(1).max(32768),
6440
+ projectId: projectIdSchema,
6441
+ name: nameSchema.optional(),
6442
+ description: descriptionSchema.nullable().optional(),
6443
+ pinned: z4.boolean().optional(),
5599
6444
  },
5600
6445
  },
5601
- setVariableHandler(name),
6446
+ async ({ projectId, ...update }) => {
6447
+ const project = await updateChannel(deps.db, grant.workspaceId, projectId, update);
6448
+ if (!project) throw new Error("Project not found");
6449
+ return json({ project });
6450
+ },
5602
6451
  );
5603
- };
5604
- if (can("variable-sets:list") && can("secrets:list")) {
5605
- registerListTool(
5606
- "variable_set_list",
5607
- "List variable sets with variable names and metadata (versions, timestamps). Plaintext values are never returned by list operations.",
6452
+ server.registerTool(
6453
+ "project_reorder",
6454
+ {
6455
+ description:
6456
+ "Replace the shared sidebar project order. Pass every current project ID exactly once, from project_list. Pinned projects still appear first. If projects changed, list again before retrying.",
6457
+ inputSchema: { projectIds: z4.array(projectIdSchema).min(1).max(200) },
6458
+ },
6459
+ async ({ projectIds }) => {
6460
+ const projects = await reorderChannels(deps.db, grant.workspaceId, projectIds);
6461
+ if (!projects)
6462
+ throw new Error("Projects changed or order is invalid; call project_list and retry");
6463
+ return json({ projects });
6464
+ },
5608
6465
  );
5609
- registerListTool(
5610
- "environment_list",
5611
- "(deprecated alias of variable_set_list) List variable sets with variable names and metadata (versions, timestamps). Plaintext values are never returned by list operations.",
6466
+ server.registerTool(
6467
+ "project_delete",
6468
+ {
6469
+ description:
6470
+ "Delete a workspace-shared project. Its sessions are preserved and become unfiled; no running work is stopped.",
6471
+ inputSchema: { projectId: projectIdSchema },
6472
+ },
6473
+ async ({ projectId }) => {
6474
+ if (!(await deleteChannel(deps.db, grant.workspaceId, projectId)))
6475
+ throw new Error("Project not found");
6476
+ return json({ ok: true, projectId });
6477
+ },
5612
6478
  );
5613
6479
  }
5614
-
5615
- if (
5616
- sessionId !== null &&
5617
- can("variable-sets:read") &&
5618
- hasLiteralPermission(grant.permissions, "secrets:read")
5619
- ) {
6480
+ if (can("sessions:control")) {
5620
6481
  server.registerTool(
5621
- "variable_set_get_variable",
6482
+ "session_set_project",
5622
6483
  {
5623
6484
  description:
5624
- "Retrieve one exact plaintext variable value. This is a dedicated high-trust read: use it only when the current task requires the configured value. The access is audited against this exact live attempt.",
5625
- inputSchema: {
5626
- variableSetId: z4.string().uuid().optional(),
5627
- variableSetName: z4.string().min(1).max(120).optional(),
5628
- name: z4.string().min(1),
5629
- },
6485
+ "File a session into a workspace project, or pass projectId=null to unfile it. Projects group root sessions in the user's sidebar. Filing changes organization only, not visibility, instructions, runtime, or history.",
6486
+ inputSchema: { sessionId: z4.string().uuid(), projectId: projectIdSchema.nullable() },
5630
6487
  },
5631
- async ({ variableSetId, variableSetName, name }) => {
5632
- let target: { variableSetId: string } | { variableSetName: string };
5633
- if (variableSetId !== undefined) {
5634
- if (variableSetName !== undefined) {
5635
- throw new Error("provide exactly one of variableSetId or variableSetName");
5636
- }
5637
- target = { variableSetId };
5638
- } else {
5639
- if (variableSetName === undefined) {
5640
- throw new Error("provide exactly one of variableSetId or variableSetName");
5641
- }
5642
- target = { variableSetName };
5643
- }
6488
+ async ({ sessionId, projectId }) => {
6489
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.control");
6490
+ await requireSession(deps.db, grant.workspaceId, sessionId);
5644
6491
  if (
5645
- ("variableSetId" in target && target.variableSetId.length === 0) ||
5646
- ("variableSetName" in target && target.variableSetName.trim().length === 0)
5647
- ) {
5648
- throw new Error("provide exactly one of variableSetId or variableSetName");
5649
- }
5650
- const parsedName = VariableSetVariableName.safeParse(name);
5651
- if (!parsedName.success) {
5652
- throw new Error("variable set variable names must match ^[A-Z][A-Z0-9_]*$");
5653
- }
5654
- assertAllowedVariableSetVariableName(parsedName.data);
5655
- const claims = exactAgentAttemptClaims(grant);
5656
- if (!claims || claims.sessionId !== sessionId) {
5657
- throw new Error("Exact signed secret-read attempt authority is required.");
5658
- }
5659
- await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
5660
- await authorizeFirstPartySession(deps, grant, sessionId, "session.secret.read");
5661
- const key = requireVariableSetEncryption(deps.settings);
5662
- const secret = await readVariableSetSecretAtomically(deps.db, {
5663
- accountId: grant.accountId,
5664
- workspaceId: grant.workspaceId,
5665
- subjectId: grant.subjectId,
5666
- ...target,
5667
- name: parsedName.data,
5668
- actor: {
5669
- kind: "agent_attempt",
5670
- sessionId: claims.sessionId,
5671
- turnId: claims.turnId,
5672
- attemptId: claims.attemptId,
5673
- executionGeneration: claims.executionGeneration,
5674
- },
5675
- decrypt: (valueEncrypted) => decryptVariableSetValue(key, valueEncrypted),
5676
- });
5677
- if (!secret) throw new Error("variable set variable not found");
5678
- return json(secret);
6492
+ !(await setSessionChannel(deps.db, {
6493
+ workspaceId: grant.workspaceId,
6494
+ sessionId,
6495
+ channelId: projectId,
6496
+ }))
6497
+ )
6498
+ throw new Error("Session not found");
6499
+ return json({ ok: true, sessionId, projectId });
5679
6500
  },
5680
6501
  );
5681
6502
  }
5682
-
5683
- if (can("variable-sets:write") && can("secrets:write")) {
5684
- registerSetTool(
5685
- "variable_set_set_variable",
5686
- "Set or rotate one variable in a variable set. Target by variableSetId, or by variableSetName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the variable set is attached to. Reading it requires the dedicated permissioned secret-read operation.",
5687
- );
5688
- registerSetTool(
5689
- "environment_set_variable",
5690
- "(deprecated alias of variable_set_set_variable) Set or rotate one variable in a variable set. Target by variableSetId, or by variableSetName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the variable set is attached to. Reading it requires the dedicated permissioned secret-read operation.",
5691
- );
5692
- }
5693
6503
  }
5694
6504
 
5695
6505
  type CapabilitySetupProjection =
@@ -5708,6 +6518,8 @@ function registerCapabilityDiscoveryTools(
5708
6518
  sessionId: string,
5709
6519
  json: JsonResult,
5710
6520
  ): void {
6521
+ const signedTools = grant.metadata?.["firstPartyMcpTools"];
6522
+ const secretsSelected = Array.isArray(signedTools) && signedTools.includes("secret_search");
5711
6523
  const catalog = () =>
5712
6524
  buildCapabilityCatalog({
5713
6525
  db: deps.db,
@@ -5724,7 +6536,14 @@ function registerCapabilityDiscoveryTools(
5724
6536
  // This is a readiness projection, never credential resolution or a new grant.
5725
6537
  const setupProjections = async (items: CapabilityCatalogItem[]) => {
5726
6538
  const availableServerIds = new Set<string>();
5727
- if (items.some((item) => item.enabled && item.connectionRef?.subjectScope === "subject")) {
6539
+ const availableFirstPartyTools = new Set<string>();
6540
+ const acceptedServerIds = new Set<string>();
6541
+ let accountSelectionKnown = false;
6542
+ if (
6543
+ items.some(
6544
+ (item) => item.enabled && (item.connectionRef || item.surfaceType === "first_party_fiken"),
6545
+ )
6546
+ ) {
5728
6547
  const claims = exactAgentCommandContext(grant, sessionId);
5729
6548
  const attemptCatalog = await getAttemptToolCatalog(deps.db, {
5730
6549
  accountId: grant.accountId,
@@ -5736,12 +6555,35 @@ function registerCapabilityDiscoveryTools(
5736
6555
  attemptCatalog.turnId === claims.callerTurnId &&
5737
6556
  attemptCatalog.executionGeneration === claims.callerExecutionGeneration
5738
6557
  ) {
5739
- for (const entry of attemptCatalog.entries) availableServerIds.add(entry.identity.serverId);
6558
+ for (const entry of attemptCatalog.entries) {
6559
+ availableServerIds.add(entry.identity.serverId);
6560
+ if (entry.identity.serverId === "opengeni")
6561
+ availableFirstPartyTools.add(entry.identity.toolName);
6562
+ }
6563
+ const bindings = await getSessionTurnMcpAccountBindings(
6564
+ deps.db,
6565
+ grant.workspaceId,
6566
+ sessionId,
6567
+ claims.callerTurnId,
6568
+ );
6569
+ accountSelectionKnown = bindings !== null;
6570
+ const readiness = capabilityAccountReadiness(availableServerIds, bindings);
6571
+ for (const id of readiness.available) availableServerIds.add(id);
6572
+ for (const id of readiness.accepted) acceptedServerIds.add(id);
5740
6573
  }
5741
6574
  }
5742
6575
  return await Promise.all(
5743
6576
  items.map((item) =>
5744
- capabilitySetupProjection(deps, grant.workspaceId, item, availableServerIds),
6577
+ capabilitySetupProjection(
6578
+ deps,
6579
+ grant.workspaceId,
6580
+ item,
6581
+ availableServerIds,
6582
+ acceptedServerIds,
6583
+ accountSelectionKnown,
6584
+ availableFirstPartyTools,
6585
+ new Set((grant.metadata?.firstPartyMcpTools as string[] | undefined) ?? []),
6586
+ ),
5745
6587
  ),
5746
6588
  );
5747
6589
  };
@@ -5750,7 +6592,7 @@ function registerCapabilityDiscoveryTools(
5750
6592
  "capability_catalog_search",
5751
6593
  {
5752
6594
  description:
5753
- "Search OpenGeni's reviewed workspace capability catalog when the user asks to add an integration or the task needs a capability that is not currently usable. Search by the outcome needed (for example `GitHub repositories`, `product analytics`, or `Slack notifications`), compare the returned candidates, and prefer a ready or verified exact match. This only reads secret-free metadata; it never installs, connects, or authorizes anything.",
6595
+ "Find integrations in Opengeni's reviewed workspace catalog when the user asks to add one or needed access is missing. Search by integration name or task outcome; names are matched with typo tolerance, and a match with approximate: true matched only by a near spelling. Results describe setup status and provide setup.nextAction when human setup can be requested. Use available tools directly for ready candidates. When matches is empty, suggestions lists the closest catalog names: check whether one is what the user meant (or ask) before saying an integration does not exist. This reads metadata only and does not connect or authorize anything.",
5754
6596
  inputSchema: {
5755
6597
  query: z4.string().min(1).max(500),
5756
6598
  limit: z4.number().int().min(1).max(20).optional(),
@@ -5759,13 +6601,15 @@ function registerCapabilityDiscoveryTools(
5759
6601
  async ({ query, limit }) => {
5760
6602
  await authorize();
5761
6603
  const current = await catalog();
5762
- const ranked = searchCapabilityCatalogItems(
5763
- [...current.items, ...nativeConnectionCapabilityRecommendations()],
5764
- query,
5765
- limit ?? 8,
5766
- );
6604
+ const candidates = [
6605
+ ...current.items,
6606
+ ...nativeConnectionCapabilityRecommendations({
6607
+ connectedMachines: deps.settings.sandboxSelfhostedEnabled,
6608
+ }),
6609
+ ];
6610
+ const ranked = searchCapabilityCatalogItems(candidates, query, limit ?? 8);
5767
6611
  const setups = await setupProjections(ranked.map(({ item }) => item));
5768
- const matches = ranked.map(({ item, matchedOn }, index) => ({
6612
+ const matches = ranked.map(({ item, matchedOn, approximate }, index) => ({
5769
6613
  capabilityId: item.id,
5770
6614
  name: item.name,
5771
6615
  description: item.description,
@@ -5777,12 +6621,40 @@ function registerCapabilityDiscoveryTools(
5777
6621
  authKind: item.authKind,
5778
6622
  tier: item.tier,
5779
6623
  matchedOn,
6624
+ approximate,
5780
6625
  setup: {
5781
6626
  ...setups[index]!,
5782
6627
  requiredVariables: capabilityRequiredVariables(item),
6628
+ nextAction:
6629
+ setups[index]!.status === "authorization_required"
6630
+ ? { toolName: "capability_authorization_request", capabilityId: item.id }
6631
+ : null,
5783
6632
  },
5784
6633
  }));
5785
- return json({ query, matches });
6634
+ // A miss must not read as "this integration does not exist" while the
6635
+ // workspace catalog has entries: return the closest names, clearly
6636
+ // labelled as suggestions rather than matches.
6637
+ const suggestions =
6638
+ matches.length === 0
6639
+ ? suggestCapabilityCatalogItems(candidates, query, 5).map(({ item, similarity }) => ({
6640
+ capabilityId: item.id,
6641
+ name: item.name,
6642
+ kind: item.kind,
6643
+ source: item.source,
6644
+ providerDomain: item.providerDomain,
6645
+ similarity,
6646
+ }))
6647
+ : [];
6648
+ return json({
6649
+ query,
6650
+ matches,
6651
+ suggestions,
6652
+ ...(suggestions.length > 0
6653
+ ? {
6654
+ note: "No catalog entry matched this query. suggestions are the closest names in this workspace catalog, not matches; check whether one is what the user meant before concluding the integration does not exist.",
6655
+ }
6656
+ : {}),
6657
+ });
5786
6658
  },
5787
6659
  );
5788
6660
 
@@ -5790,7 +6662,10 @@ function registerCapabilityDiscoveryTools(
5790
6662
  "capability_authorization_request",
5791
6663
  {
5792
6664
  description:
5793
- "After capability_catalog_search and after explaining one chosen recommendation to the user, post exactly one in-session human authorization card for that catalog capability. This tool never grants access, enables a capability, reads a secret, or mints provider credentials; the authenticated user must click and confirm the provider/domain flow. Do not call it for a candidate reported ready or unavailable.",
6665
+ "Show a Connect card in this chat for a suitable capability returned by capability_catalog_search with setup.nextAction. Supply its capability ID and a brief rationale explaining how it helps the task; no separate confirmation is needed before showing the card. Requesting setup needs no integration-management permission and grants no access. The authenticated human completes setup through the card; never ask them to paste credentials into chat. Do not request another card for the same pending setup, or for a candidate reported ready or unavailable. GitHub App (api:github-app) and Connected Machine (api:connected-machine) are exceptions: when already connected their card is still shown, listing repositories or machines the person can use in this chat. Use api:connected-machine when the person should connect their own computer: the card gives them a one-line connect command and the Opengeni Browser Chrome extension link; never paste an enroll token into the chat yourself." +
6666
+ (secretsSelected
6667
+ ? ' For a single password, API key or code for a service with no connector here, ask with a kind: "secret" question in request_human_input instead; you get a saved reference back.'
6668
+ : ""),
5794
6669
  inputSchema: {
5795
6670
  capabilityId: z4.string().min(1).max(512),
5796
6671
  rationale: z4.string().min(1).max(2000),
@@ -5799,15 +6674,26 @@ function registerCapabilityDiscoveryTools(
5799
6674
  async ({ capabilityId, rationale }) => {
5800
6675
  await authorize();
5801
6676
  const current = await catalog();
5802
- const item = [...current.items, ...nativeConnectionCapabilityRecommendations()].find(
5803
- (candidate) => candidate.id === capabilityId,
5804
- );
6677
+ const item = [
6678
+ ...current.items,
6679
+ ...nativeConnectionCapabilityRecommendations({
6680
+ connectedMachines: deps.settings.sandboxSelfhostedEnabled,
6681
+ }),
6682
+ ].find((candidate) => candidate.id === capabilityId);
5805
6683
  if (!item || !capabilityCatalogItemIsTrustedForExposure(item)) {
5806
6684
  throw new Error("Unknown or untrusted capability; search the catalog again.");
5807
6685
  }
5808
6686
  const [setup] = await setupProjections([item]);
5809
6687
  if (!setup) throw new Error("Capability setup projection is unavailable.");
5810
- if (setup.status === "ready") {
6688
+ // GitHub is the one capability whose card stays useful once connected:
6689
+ // it lists the shared repositories with a "Use" action that attaches one
6690
+ // to this chat, so a ready GitHub still gets its card.
6691
+ const githubCardWhenReady = setup.status === "ready" && item.id === "api:github-app";
6692
+ // The machine card likewise lists connected machines with "Use in this
6693
+ // chat" and can connect another, so it is shown when machines exist too.
6694
+ const machineCardWhenReady =
6695
+ setup.status === "ready" && item.id === CONNECTED_MACHINE_CAPABILITY_ID;
6696
+ if (setup.status === "ready" && !githubCardWhenReady && !machineCardWhenReady) {
5811
6697
  return json({
5812
6698
  capabilityId: item.id,
5813
6699
  status: "ready",
@@ -5821,22 +6707,147 @@ function registerCapabilityDiscoveryTools(
5821
6707
  message: setup.detail,
5822
6708
  });
5823
6709
  }
5824
- const claims = exactAgentCommandContext(grant, sessionId);
6710
+ const eventId = await appendCapabilityCardEvent(deps, grant, sessionId, {
6711
+ item,
6712
+ action: setup.action ?? "connect",
6713
+ rationale,
6714
+ });
6715
+ if (machineCardWhenReady) {
6716
+ return json({
6717
+ capabilityId: item.id,
6718
+ status: "ready",
6719
+ eventId,
6720
+ message: `${setup.detail} The Connected Machine card is in this chat: the person can pick a machine with "Use in this chat" or connect another. Chrome needs the Opengeni Browser extension (${OPENGENI_BROWSER_EXTENSION_URL}) on a connected machine.`,
6721
+ });
6722
+ }
6723
+ if (githubCardWhenReady) {
6724
+ return json({
6725
+ capabilityId: item.id,
6726
+ status: "ready",
6727
+ eventId,
6728
+ message: `${setup.detail} The GitHub card is in this chat: the person picks a repository with its "Use" button, which attaches it to this chat.`,
6729
+ });
6730
+ }
6731
+ return json({
6732
+ capabilityId: item.id,
6733
+ status: "authorization_requested",
6734
+ action: setup.action,
6735
+ eventId,
6736
+ message:
6737
+ item.id === CONNECTED_MACHINE_CAPABILITY_ID
6738
+ ? "The Connected Machine card was posted. The person copies its connect command, runs it on their machine, and picks Use in this chat; nothing is connected yet. Do not paste an enroll token into the chat."
6739
+ : "The recommendation was posted for human confirmation. No access has been granted yet.",
6740
+ });
6741
+ },
6742
+ );
6743
+
6744
+ server.registerTool(
6745
+ "custom_mcp_setup_request",
6746
+ {
6747
+ description:
6748
+ "Prepare a remote HTTPS MCP connection when the person has the missing key. Search the catalog first and reuse available connections. Use only an endpoint supplied by the user or reliable documentation. For API-key or bearer auth, include explicit personal/workspace ownership and the complete non-secret mcpSetup (matching name and endpointUrl, fixed non-secret headers, secret references with any prefix/suffix, and labeled secret fields). The inline card asks only for those secret values; never put credentials in these tool arguments or chat. If you already have the key and delegated connections:read, connections:write and capabilities:manage, use environmentCodemodeClient().sessionRequest with the native /v1/workspaces/site-host/connect/attempts begin/advance lifecycle and the same mcpSetup; supply values only in that protected credential request, not a tool call. No human card is needed for that authorized path. Omit ownership/mcpSetup for a legacy server-review or OAuth setup. Posting this card does not contact or connect the server.",
6749
+ inputSchema: {
6750
+ name: z4.string().trim().min(1).max(256),
6751
+ endpointUrl: z4
6752
+ .string()
6753
+ .url()
6754
+ .max(2048)
6755
+ .refine((url) => {
6756
+ const parsed = new URL(url);
6757
+ return (
6758
+ parsed.protocol === "https:" &&
6759
+ !parsed.username &&
6760
+ !parsed.password &&
6761
+ !parsed.hash &&
6762
+ !parsed.search
6763
+ );
6764
+ }),
6765
+ rationale: z4.string().trim().min(1).max(2000),
6766
+ ownership: z4.enum(["personal", "workspace"]).optional(),
6767
+ mcpSetup: z4
6768
+ .object({
6769
+ name: z4.string().trim().min(1).max(256),
6770
+ endpointUrl: z4.string().url().max(2048),
6771
+ headers: z4
6772
+ .array(
6773
+ z4.union([
6774
+ z4
6775
+ .object({
6776
+ name: z4.string().min(1).max(256),
6777
+ value: z4.string().min(1).max(16_384),
6778
+ })
6779
+ .strict(),
6780
+ z4
6781
+ .object({
6782
+ name: z4.string().min(1).max(256),
6783
+ secret: z4.string().min(1).max(64),
6784
+ prefix: z4.string().max(16_384).optional(),
6785
+ suffix: z4.string().max(16_384).optional(),
6786
+ })
6787
+ .strict(),
6788
+ ]),
6789
+ )
6790
+ .min(1)
6791
+ .max(32),
6792
+ secretFields: z4
6793
+ .array(
6794
+ z4
6795
+ .object({
6796
+ id: z4.string().min(1).max(64),
6797
+ label: z4.string().min(1).max(256),
6798
+ })
6799
+ .strict(),
6800
+ )
6801
+ .max(32),
6802
+ })
6803
+ .strict()
6804
+ .optional(),
6805
+ },
6806
+ },
6807
+ async ({ name, endpointUrl, rationale, ownership, mcpSetup }) => {
6808
+ await authorize();
5825
6809
  const payload = ToolAuthNeededPayload.parse({
5826
- serverId: item.runtime.mcpServerId ?? "opengeni",
5827
- toolName: "capability_authorization_request",
5828
- providerDomain: capabilityProviderDomain(item),
6810
+ serverId: "opengeni",
6811
+ toolName: "custom_mcp_setup_request",
6812
+ providerDomain: new URL(endpointUrl).hostname,
5829
6813
  reason: "missing_connection",
5830
- capability: {
5831
- id: item.id,
5832
- name: item.name,
5833
- kind: item.kind,
5834
- source: item.source,
5835
- action: setup.action,
6814
+ setupRequest: {
6815
+ kind: "mcp",
6816
+ name,
6817
+ endpointUrl,
5836
6818
  rationale,
5837
- requiredVariables: capabilityRequiredVariables(item),
6819
+ ...(ownership ? { ownership } : {}),
6820
+ ...(mcpSetup ? { mcpSetup } : {}),
5838
6821
  },
5839
6822
  });
6823
+ const current = await catalog();
6824
+ const candidates = current.items.filter(
6825
+ (item) => item.kind === "mcp" && item.endpointUrl === endpointUrl && !item.stale,
6826
+ );
6827
+ // Workspace is the default scope in public connection projections. Match
6828
+ // scope before choosing a catalog entry; one endpoint can have both.
6829
+ const existing = mcpSetup
6830
+ ? candidates.find(
6831
+ (item) =>
6832
+ item.connectionRef &&
6833
+ (item.connectionRef.subjectScope ?? "workspace") ===
6834
+ (ownership === "personal" ? "subject" : "workspace"),
6835
+ )
6836
+ : candidates[0];
6837
+ const setup =
6838
+ existing?.enabled && mcpSetup ? (await setupProjections([existing]))[0] : undefined;
6839
+ // A missing tool does not establish that the saved key is missing. Keep
6840
+ // unknown discovery failures distinct from an actual connect request.
6841
+ const reusablePrepared = setup && setup.status !== "authorization_required";
6842
+ if (existing && (!mcpSetup || reusablePrepared)) {
6843
+ return json({
6844
+ status: "already_in_catalog",
6845
+ capabilityId: existing.id,
6846
+ ...(setup ? { setup } : {}),
6847
+ message: "Use the catalog authorization flow for this server instead.",
6848
+ });
6849
+ }
6850
+ const claims = exactAgentCommandContext(grant, sessionId);
5840
6851
  const appended = await appendAndPublishTurnEventsFenced(
5841
6852
  deps.db,
5842
6853
  deps.bus,
@@ -5848,17 +6859,14 @@ function registerCapabilityDiscoveryTools(
5848
6859
  [{ type: "tool.auth_needed", payload }],
5849
6860
  );
5850
6861
  if (!appended.accepted) {
5851
- throw new Error(
5852
- "The calling turn was replaced before the authorization request committed.",
5853
- );
6862
+ throw new Error("The calling turn was replaced before the setup request committed.");
5854
6863
  }
5855
6864
  return json({
5856
- capabilityId: item.id,
5857
- status: "authorization_requested",
5858
- action: setup.action,
6865
+ status: "setup_requested",
5859
6866
  eventId: appended.events[0]?.id ?? null,
5860
- message:
5861
- "The recommendation was posted for human confirmation. No access has been granted yet.",
6867
+ message: mcpSetup
6868
+ ? "The prepared connection card was posted. The person enters only the missing key in its protected fields. No server was added or contacted."
6869
+ : "The human review card was posted. No server was added or contacted.",
5862
6870
  });
5863
6871
  },
5864
6872
  );
@@ -5869,7 +6877,87 @@ async function capabilitySetupProjection(
5869
6877
  workspaceId: string,
5870
6878
  item: CapabilityCatalogItem,
5871
6879
  availableServerIds: ReadonlySet<string>,
6880
+ acceptedServerIds: ReadonlySet<string>,
6881
+ accountSelectionKnown: boolean,
6882
+ availableFirstPartyTools: ReadonlySet<string>,
6883
+ selectedFirstPartyTools: ReadonlySet<string>,
5872
6884
  ): Promise<CapabilitySetupProjection> {
6885
+ if (item.surfaceType === "first_party_onepassword") {
6886
+ // 1Password is connected and its vaults linked in Settings > Secrets; the
6887
+ // agent reaches linked vaults through secret references, not tools.
6888
+ return item.enabled
6889
+ ? {
6890
+ status: "ready",
6891
+ action: null,
6892
+ detail:
6893
+ "1Password is connected. Its linked vaults are available through secret references and the secrets tools.",
6894
+ }
6895
+ : {
6896
+ status: "authorization_required",
6897
+ action: "connect",
6898
+ detail:
6899
+ "Connect a 1Password service account and choose its vaults through the protected setup form.",
6900
+ };
6901
+ }
6902
+ if (item.surfaceType === "first_party_fiken") {
6903
+ if (!item.enabled || item.metadata.connectionStatus !== "active") {
6904
+ return {
6905
+ status: "authorization_required",
6906
+ action: "connect",
6907
+ detail:
6908
+ item.metadata.connectionStatus === "needs_reauth"
6909
+ ? "The workspace Fiken connection needs reconnection."
6910
+ : "Connect Fiken through the protected setup form.",
6911
+ };
6912
+ }
6913
+ const selected = FIRST_PARTY_MCP_TOOL_NAMES.filter(
6914
+ (name) => name.startsWith("fiken_") && selectedFirstPartyTools.has(name),
6915
+ );
6916
+ if (selected.length === 0) {
6917
+ return {
6918
+ status: "authorization_required",
6919
+ action: "enable",
6920
+ detail:
6921
+ "Fiken is connected, but its tools are not selected for this conversation. Ask the user to add its tools through the setup card, then send a new message.",
6922
+ };
6923
+ }
6924
+ if (selected.some((name) => availableFirstPartyTools.has(name))) {
6925
+ return {
6926
+ status: "ready",
6927
+ action: null,
6928
+ detail: "Fiken is connected and has tools available in this turn.",
6929
+ };
6930
+ }
6931
+ return {
6932
+ status: "unavailable",
6933
+ action: null,
6934
+ detail:
6935
+ "Fiken is connected, but its selected tools are unavailable in this execution. Check tool permissions and setup before retrying; reconnection is not required by this status.",
6936
+ };
6937
+ }
6938
+ if (item.id === CONNECTED_MACHINE_CAPABILITY_ID) {
6939
+ if (!deps.settings.sandboxSelfhostedEnabled) {
6940
+ return {
6941
+ status: "unavailable",
6942
+ action: null,
6943
+ detail: "Connected Machines are not enabled on this deployment.",
6944
+ };
6945
+ }
6946
+ const enrolled = await listEnrollments(deps.db, workspaceId, { status: "active" });
6947
+ if (enrolled.length > 0) {
6948
+ return {
6949
+ status: "ready",
6950
+ action: null,
6951
+ detail: "Machines are connected to this workspace; sandboxes_list shows which are online.",
6952
+ };
6953
+ }
6954
+ return {
6955
+ status: "authorization_required",
6956
+ action: "connect",
6957
+ detail:
6958
+ "No machine is connected yet. The person copies a one-line connect command from the chat card and runs it on their machine.",
6959
+ };
6960
+ }
5873
6961
  if (item.id === "api:github-app" || item.surfaceType === "first_party_github") {
5874
6962
  const missing = githubAppMissingSettings(deps.settings);
5875
6963
  if (missing.length > 0) {
@@ -5909,7 +6997,7 @@ async function capabilitySetupProjection(
5909
6997
  detail: "A workspace admin must designate an authorized Codex Apps subscription.",
5910
6998
  };
5911
6999
  }
5912
- if (item.enabled && item.connectionRef?.subjectScope === "subject") {
7000
+ if (item.enabled && item.connectionRef) {
5913
7001
  if (item.runtime.mcpServerId && availableServerIds.has(item.runtime.mcpServerId)) {
5914
7002
  return {
5915
7003
  status: "ready",
@@ -5917,11 +7005,22 @@ async function capabilitySetupProjection(
5917
7005
  detail: "This capability has tools available in this turn.",
5918
7006
  };
5919
7007
  }
7008
+ if (
7009
+ !accountSelectionKnown ||
7010
+ (item.runtime.mcpServerId && acceptedServerIds.has(item.runtime.mcpServerId))
7011
+ ) {
7012
+ return {
7013
+ status: "unavailable",
7014
+ action: null,
7015
+ detail:
7016
+ "This integration's tools are unavailable in this execution. Check its connection or setup failure before requesting reconnection; missing tools alone do not establish an authorization failure.",
7017
+ };
7018
+ }
5920
7019
  return {
5921
7020
  status: "authorization_required",
5922
7021
  action: "connect",
5923
7022
  detail:
5924
- "This capability is enabled, but its personal account is not available in this turn. The account owner must review access using Use in this conversation, then send a new message. Shared conversations require acknowledgement that results are visible to workspace members.",
7023
+ "This integration is enabled in the workspace, but no account was selected for this execution. Connect or select an account available to the sender, then send a new message.",
5925
7024
  };
5926
7025
  }
5927
7026
  if (item.enabled) {
@@ -5959,6 +7058,54 @@ async function capabilitySetupProjection(
5959
7058
  };
5960
7059
  }
5961
7060
 
7061
+ /**
7062
+ * Post a capability Connect card in the calling turn. The card carries identity
7063
+ * and rationale only; the authenticated person completes setup through it.
7064
+ */
7065
+ async function appendCapabilityCardEvent(
7066
+ deps: ApiRouteDeps,
7067
+ grant: AccessGrant,
7068
+ sessionId: string,
7069
+ input: {
7070
+ item: CapabilityCatalogItem;
7071
+ action: "connect" | "add_credentials" | "enable";
7072
+ rationale: string;
7073
+ toolName?: string;
7074
+ },
7075
+ ): Promise<string | null> {
7076
+ const { item } = input;
7077
+ const claims = exactAgentCommandContext(grant, sessionId);
7078
+ const payload = ToolAuthNeededPayload.parse({
7079
+ serverId: item.runtime.mcpServerId ?? "opengeni",
7080
+ toolName: input.toolName ?? "capability_authorization_request",
7081
+ providerDomain: capabilityProviderDomain(item),
7082
+ reason: "missing_connection",
7083
+ capability: {
7084
+ id: item.id,
7085
+ name: item.name,
7086
+ kind: item.kind,
7087
+ source: item.source,
7088
+ action: input.action,
7089
+ rationale: input.rationale,
7090
+ requiredVariables: capabilityRequiredVariables(item),
7091
+ },
7092
+ });
7093
+ const appended = await appendAndPublishTurnEventsFenced(
7094
+ deps.db,
7095
+ deps.bus,
7096
+ grant.workspaceId,
7097
+ sessionId,
7098
+ claims.callerTurnId,
7099
+ claims.callerExecutionGeneration,
7100
+ claims.callerAttemptId,
7101
+ [{ type: "tool.auth_needed", payload }],
7102
+ );
7103
+ if (!appended.accepted) {
7104
+ throw new Error("The calling turn was replaced before the authorization request committed.");
7105
+ }
7106
+ return appended.events[0]?.id ?? null;
7107
+ }
7108
+
5962
7109
  function capabilityRequiredVariables(item: CapabilityCatalogItem): string[] {
5963
7110
  const variableSet = item.metadata.variableSet;
5964
7111
  if (!variableSet || typeof variableSet !== "object" || Array.isArray(variableSet)) return [];
@@ -6047,44 +7194,6 @@ function registerGitHubConnectTool(
6047
7194
  );
6048
7195
  }
6049
7196
 
6050
- // Defense-in-depth for invariant "agents cannot self-attach": the worker's
6051
- // first-party delegated token must carry both exact attachment and use
6052
- // permissions, so a token narrowed to only one cannot attach a variable set.
6053
- // agents calling these MCP tools cannot attach a variable set.
6054
- // Explicit detach (variableSetId: null) is also an attachment change and is
6055
- // blocked the same way.
6056
- function requireVariableSetsUseForMcpAttachment(
6057
- grant: AccessGrant,
6058
- variableSetId: string | null | undefined,
6059
- ): void {
6060
- if (variableSetId === undefined) return;
6061
- if (!hasPermission(grant.permissions, "variable-sets:attach")) {
6062
- throw new Error("missing permission: variable-sets:attach");
6063
- }
6064
- if (variableSetId !== null && !hasPermission(grant.permissions, "variable-sets:use")) {
6065
- throw new Error("missing permission: variable-sets:use");
6066
- }
6067
- }
6068
-
6069
- function requireVariableSetsUseForMcpAttachments(
6070
- grant: AccessGrant,
6071
- selection: {
6072
- variableSetIds?: string[] | undefined;
6073
- variableSetId?: string | undefined;
6074
- environmentId?: string | undefined;
6075
- },
6076
- ): void {
6077
- const singular = selection.variableSetId ?? selection.environmentId;
6078
- const variableSetIds = selection.variableSetIds ?? (singular ? [singular] : undefined);
6079
- if (variableSetIds === undefined) return;
6080
- if (!hasPermission(grant.permissions, "variable-sets:attach")) {
6081
- throw new HTTPException(403, { message: "missing permission: variable-sets:attach" });
6082
- }
6083
- if (variableSetIds.length > 0 && !hasPermission(grant.permissions, "variable-sets:use")) {
6084
- throw new HTTPException(403, { message: "missing permission: variable-sets:use" });
6085
- }
6086
- }
6087
-
6088
7197
  /**
6089
7198
  * Project one allowlisted GitHub App repository into the resource an agent or
6090
7199
  * scheduled task attaches. Every listed repository is in the workspace
@@ -6095,25 +7204,7 @@ function requireVariableSetsUseForMcpAttachments(
6095
7204
  export function repositoryWithScheduledTaskResource(
6096
7205
  repository: GitHubRepository,
6097
7206
  ): GitHubRepository & { resource: ResourceRef } {
6098
- const uri = normalizedRepositoryUri(repository.cloneUrl);
6099
- return {
6100
- ...repository,
6101
- resource: {
6102
- kind: "repository",
6103
- uri,
6104
- ref: repository.defaultBranch,
6105
- provider: "github",
6106
- mountPath: defaultRepositoryMountPath(uri, "github"),
6107
- githubInstallationId: repository.installationId,
6108
- githubRepositoryId: repository.id,
6109
- },
6110
- };
6111
- }
6112
-
6113
- function normalizedRepositoryUri(value: string): string {
6114
- const url = new URL(value);
6115
- const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
6116
- return `https://${url.host.toLowerCase()}/${path}.git`;
7207
+ return { ...repository, resource: githubRepositoryResourceRef(repository) };
6117
7208
  }
6118
7209
 
6119
7210
  function boundedMcpLimit(limit: number | undefined): number {
@@ -6645,9 +7736,15 @@ async function withMcpEffectivePolicy(
6645
7736
  subjectId: string,
6646
7737
  session: Session,
6647
7738
  ): Promise<Session> {
6648
- const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
7739
+ const [workspaceServerIds, workspaceDefaultServerIds, effectiveToolsContext] = await Promise.all([
6649
7740
  workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
6650
7741
  workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
7742
+ workspaceSessionEffectiveToolsContext(deps, workspaceId, subjectId, [session]),
6651
7743
  ]);
6652
- return sessionWithEffectiveToolPolicy(session, workspaceServerIds, workspaceDefaultServerIds);
7744
+ return sessionWithEffectiveToolPolicy(
7745
+ session,
7746
+ workspaceServerIds,
7747
+ workspaceDefaultServerIds,
7748
+ effectiveToolsContext,
7749
+ );
6653
7750
  }