@opengeni/api-router 0.30.3 → 2.2.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
package/src/mcp/server.ts CHANGED
@@ -14,7 +14,6 @@ import {
14
14
  stableJson,
15
15
  compactSessionEventResult,
16
16
  sessionEventLatestClassToSemanticClass,
17
- MemorySlackPublicationDistribution,
18
17
  SessionMcpCredentialUpdateInput,
19
18
  ToolAuthNeededPayload,
20
19
  VariableSetVariableName,
@@ -38,19 +37,20 @@ import {
38
37
  SESSION_GOAL_RATIONALE_MAX_BYTES,
39
38
  SESSION_GOAL_SUCCESS_CRITERIA_MAX_BYTES,
40
39
  SESSION_GOAL_TEXT_MAX_BYTES,
40
+ SESSION_INSTRUCTIONS_MAX_CHARACTERS,
41
41
  sessionGoalUtf8Bytes,
42
42
  TASK_NOTE_LIST_DEFAULT_LIMIT,
43
43
  TASK_NOTE_LIST_MAX_LIMIT,
44
44
  TASK_NOTE_MAX_LIFETIME_DAYS,
45
45
  TASK_NOTE_REASON_MAX_BYTES,
46
46
  TASK_NOTE_TEXT_MAX_BYTES,
47
+ SubmitHumanInputResponseRequest,
47
48
  } from "@opengeni/contracts";
48
49
  import {
49
50
  countVariableSets,
50
51
  beginRigChangeVerificationAttempt,
51
52
  createVariableSet,
52
53
  decryptVariableSetValue,
53
- deleteScheduledTask,
54
54
  encryptVariableSetValue,
55
55
  getSession,
56
56
  getSessionGoal,
@@ -63,7 +63,9 @@ import {
63
63
  listScheduledTaskRuns,
64
64
  listScheduledTasks,
65
65
  listSessionEventPage,
66
+ listOutstandingSessionSystemUpdates,
66
67
  listSessionDiscoverySummaries,
68
+ listEnrollments,
67
69
  projectEffectiveControlForRelatedAccess,
68
70
  projectSessionForRelatedAccess,
69
71
  type SessionDiscoveryCursor,
@@ -77,8 +79,6 @@ import {
77
79
  readVariableSetSecretAtomically,
78
80
  recordSyncedSocialPosts,
79
81
  listVariableSets,
80
- MEMORY_CORRECT_TOOL_DESCRIPTION,
81
- MEMORY_SAVE_TOOL_DESCRIPTION,
82
82
  MEMORY_SEARCH_TOOL_DESCRIPTION,
83
83
  requireScheduledTask,
84
84
  requireSession,
@@ -86,8 +86,10 @@ import {
86
86
  serializeEffectiveSessionControl,
87
87
  setSessionGoalStatusWithEvent,
88
88
  recordSessionGoalProgressWithEvent,
89
+ holdSessionGoalContinuationWithEvent,
90
+ SESSION_GOAL_HOLD_MAX_SECONDS,
91
+ SESSION_GOAL_HOLD_MIN_SECONDS,
89
92
  setVariableSetVariable,
90
- updateScheduledTask,
91
93
  updateSessionGoalWithEvent,
92
94
  upsertSessionGoalWithEvent,
93
95
  RigChangeTransitionError,
@@ -100,13 +102,12 @@ import {
100
102
  archiveTaskNote,
101
103
  createTaskNote,
102
104
  listTaskNotes,
105
+ replaceTaskNote,
106
+ acceptSessionHumanInputResponse,
107
+ HumanInputResponseValidationError,
103
108
  } from "@opengeni/db";
104
- import {
105
- appendAndPublishEvents,
106
- appendAndPublishTurnEventsFenced,
107
- publishDurableSessionEvents,
108
- } from "@opengeni/events";
109
- import { allowedFirstPartyMcpToolsForSession } from "@opengeni/config";
109
+ import { appendAndPublishTurnEventsFenced, publishDurableSessionEvents } from "@opengeni/events";
110
+ import { allowedFirstPartyMcpToolsForSession, codemodeWorkspaceUrl } from "@opengeni/config";
110
111
  import {
111
112
  createSignedState,
112
113
  GitHubAppConfigurationError,
@@ -128,24 +129,24 @@ import {
128
129
  authorizedAtlassianConnectionsForGrant,
129
130
  buildCapabilityCatalog,
130
131
  nativeConnectionCapabilityRecommendations,
131
- correctWorkspaceMemoryWithSlackPublication,
132
132
  requireLiveAgentAttemptAuthorization,
133
133
  requireSessionAuthorization,
134
134
  requireSessionAuthorizationListScope,
135
135
  SessionAuthorizationDeniedError,
136
136
  SessionAuthorizationUnavailableError,
137
- saveWorkspaceMemoryWithSlackPublication,
138
137
  searchCapabilityCatalogItems,
139
138
  type ResolvedSessionAuthorization,
140
139
  } from "@opengeni/core";
141
140
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
142
141
  import type { ApiRouteDeps } from "@opengeni/core";
142
+ import { retryWhileMissing } from "@opengeni/storage";
143
143
  import {
144
144
  githubBindingStatus,
145
145
  listWorkspaceGitHubInstallationBindings,
146
146
  listWorkspaceGitHubRepositories,
147
147
  } from "../github-access";
148
148
  import { githubBrowserBaseUrl, githubBrowserGrantClaims } from "../github-browser-flow";
149
+ import { publishSandboxFileArtifact } from "../sandbox-file-artifacts";
149
150
  import {
150
151
  assertSocialConnectionProvider,
151
152
  socialMentionsLive,
@@ -154,7 +155,6 @@ import {
154
155
  socialSearchLive,
155
156
  socialThreadLive,
156
157
  } from "../integrations/social-api";
157
- import { revokeKnowledgeSourceScheduleAuthorization } from "../integrations/google-drive";
158
158
  import {
159
159
  promoteVerifiedDefinitionEditChangeForApi,
160
160
  proposeRigChangeForApi,
@@ -179,6 +179,7 @@ import {
179
179
  syncCreatedScheduledTask,
180
180
  syncUpdatedScheduledTask,
181
181
  validateScheduledTaskTarget,
182
+ updateScheduledTaskForApi,
182
183
  validatedScheduledTaskUpdate,
183
184
  } from "@opengeni/core";
184
185
  import {
@@ -213,6 +214,14 @@ import {
213
214
  boundRigDetailMcp,
214
215
  SESSION_EVENT_MCP_MAX_BYTES,
215
216
  } from "./session-view";
217
+ import {
218
+ SESSION_WAIT_DEFAULT_SECONDS,
219
+ SESSION_WAIT_EVENT_TYPES,
220
+ SESSION_WAIT_EVENTS_PER_TARGET,
221
+ SESSION_WAIT_MAX_SECONDS,
222
+ SESSION_WAIT_MAX_TARGETS,
223
+ waitForSessionChanges,
224
+ } from "./session-wait";
216
225
  import { mcpMutationReceipt, sessionCreateMutationReceipt } from "./receipts";
217
226
  import {
218
227
  boundScheduledTaskDetailMcp,
@@ -228,11 +237,15 @@ import { createFikenClient, resolveFikenConnectionForTool } from "../integration
228
237
  import {
229
238
  browseAtlassianSources,
230
239
  getAtlassianLiveItem,
231
- revokeAtlassianScheduleAuthorization,
232
240
  searchAtlassianLive,
233
241
  } from "../integrations/atlassian";
234
242
  import { AtlassianConnectionMetadata } from "@opengeni/contracts/atlassian";
235
243
  import { registerEditableArtifactAgentTools } from "./editable-artifacts";
244
+ import { registerCompanyBrainGovernedWriteTools } from "./company-brain-governed-writes";
245
+ import { registerCompanyProfileAgentAdminTools } from "./company-profile-agent-admin";
246
+ import { registerRememberTools } from "./remember";
247
+ import { mintSandboxCodemodeToken } from "@opengeni/runtime/sandbox";
248
+ import { deleteScheduledTaskWithDurableCleanup } from "../scheduled-task-deletion";
236
249
 
237
250
  export type McpServerOptions = {
238
251
  // Origin of the HTTP request that reached the MCP route. Browser-oriented
@@ -322,12 +335,17 @@ function orchestrationFailureEnvelope(tool: OrchestrationToolName, error: unknow
322
335
  : typedCode === "CALLER_INTERRUPTED"
323
336
  ? ["caller_interrupted", "The calling session was interrupted before delivery."]
324
337
  : typedCode === "TARGET_NOT_VERTICAL"
325
- ? ["target_not_vertical", "Agents may message only their parent or immediate children."]
338
+ ? ["target_not_vertical", "The calling agent cannot act on that session."]
326
339
  : typedCode === "CONTROL_CHANGED"
327
340
  ? ["conflict", "The target session control state changed; refresh and retry."]
328
341
  : typedCode === "IDEMPOTENCY_KEY_REUSED"
329
342
  ? ["idempotency_key_reused", "The idempotency key was reused with different input."]
330
- : null;
343
+ : typedCode === "WORKSPACE_CONTROL_BUSY"
344
+ ? [
345
+ "workspace_busy",
346
+ "The workspace is busy applying other session commands; nothing was applied. Retry the same call shortly.",
347
+ ]
348
+ : null;
331
349
  if (knownFailure) {
332
350
  return {
333
351
  error: {
@@ -369,9 +387,13 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
369
387
  goal_set: { sessionRequired: true, allOf: ["goals:manage"] },
370
388
  goal_update: { sessionRequired: true, allOf: ["goals:manage"] },
371
389
  goal_progress: { sessionRequired: true, allOf: ["goals:manage"] },
390
+ goal_wait: { sessionRequired: true, allOf: ["goals:manage"] },
372
391
  goal_complete: { sessionRequired: true, allOf: ["goals:manage"] },
373
392
  goal_pause: { sessionRequired: true, allOf: ["goals:manage"] },
374
393
  memory_search: { sessionRequired: true, allOf: ["documents:search"] },
394
+ // Retired: never registered, so these are never consulted. The map must stay
395
+ // total over the tool-name union, which still carries both names so that
396
+ // previously written scheduled-task snapshots keep parsing.
375
397
  memory_save: { sessionRequired: true, allOf: ["documents:search"] },
376
398
  memory_correct: { sessionRequired: true, allOf: ["documents:search"] },
377
399
  preference_registry_summary: {
@@ -382,6 +404,47 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
382
404
  task_notes_list: { sessionRequired: true, allOf: ["sessions:read"] },
383
405
  task_note_save: { sessionRequired: true, allOf: ["sessions:control"] },
384
406
  task_note_archive: { sessionRequired: true, allOf: ["sessions:control"] },
407
+ task_note_replace: { sessionRequired: true, allOf: ["sessions:control"] },
408
+ knowledge_propose: { sessionRequired: true, allOf: ["documents:search"] },
409
+ knowledge_correct: { sessionRequired: true, allOf: ["documents:search"] },
410
+ task_note_promote_knowledge: {
411
+ sessionRequired: true,
412
+ allOf: ["documents:search", "sessions:control"],
413
+ },
414
+ task_note_promote_instruction_policy: {
415
+ sessionRequired: true,
416
+ allOf: ["documents:search", "sessions:control", "workspace:read"],
417
+ },
418
+ task_note_promote_preference: {
419
+ sessionRequired: true,
420
+ allOf: ["documents:search", "sessions:control", "workspace:read"],
421
+ },
422
+ instruction_policy_propose: {
423
+ sessionRequired: true,
424
+ allOf: ["documents:search", "workspace:read"],
425
+ },
426
+ preference_propose: {
427
+ sessionRequired: true,
428
+ allOf: ["documents:search", "workspace:read"],
429
+ },
430
+ // Explicit user-directed remember composes task-note evidence with the
431
+ // governed promotion path, so it needs the union of both permission sets.
432
+ remember: {
433
+ sessionRequired: true,
434
+ allOf: ["documents:search", "sessions:control", "workspace:read"],
435
+ },
436
+ remember_confirm: {
437
+ sessionRequired: true,
438
+ allOf: ["documents:search", "sessions:control", "workspace:read"],
439
+ },
440
+ company_profile_propose: {
441
+ sessionRequired: true,
442
+ allOf: ["sessions:control", "workspace:read"],
443
+ },
444
+ company_profile_confirm: {
445
+ sessionRequired: true,
446
+ allOf: ["sessions:control", "workspace:read"],
447
+ },
385
448
  sandboxes_list: { sessionRequired: true, allOf: ["sessions:read"] },
386
449
  sandbox_attach: { sessionRequired: true, allOf: ["sessions:control"] },
387
450
  sandbox_swap: { sessionRequired: true, allOf: ["sessions:control"] },
@@ -396,11 +459,17 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
396
459
  sessions_list: { allOf: ["sessions:read"] },
397
460
  session_get: { allOf: ["sessions:read"] },
398
461
  session_events: { allOf: ["sessions:read"] },
462
+ // Blocking wait inside a running turn: the live attempt's own session is the
463
+ // self target, so the tool exists only for session-scoped grants.
464
+ session_wait: { sessionRequired: true, allOf: ["sessions:read"] },
399
465
  session_create: { allOf: ["sessions:create"] },
400
466
  session_send_message: { allOf: ["sessions:control"] },
401
467
  session_pause: { allOf: ["sessions:control"] },
402
468
  session_resume: { allOf: ["sessions:control"] },
403
469
  session_steer: { sessionRequired: true, allOf: ["sessions:control"] },
470
+ // A live attempt may answer another session's structured human-input
471
+ // request (never a tool approval, which stays human-only).
472
+ session_human_input_respond: { sessionRequired: true, allOf: ["sessions:control"] },
404
473
  set_other_session_title: { allOf: ["sessions:control"] },
405
474
  interaction_discover: { sessionRequired: true, allOf: ["sessions:read"] },
406
475
  browser_open: { sessionRequired: true, allOf: ["sessions:control"] },
@@ -483,6 +552,7 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
483
552
  anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"],
484
553
  },
485
554
  slack_bot_list_channels: { allOf: ["connections:read"] },
555
+ slack_bot_search: { allOf: ["connections:read"] },
486
556
  slack_bot_channel_history: { allOf: ["connections:read"] },
487
557
  slack_bot_thread_replies: { allOf: ["connections:read"] },
488
558
  slack_bot_list_users: { allOf: ["connections:read"] },
@@ -511,6 +581,10 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
511
581
  artifacts_create: { sessionRequired: true, allOf: ["artifacts:publish"] },
512
582
  artifacts_publish: { sessionRequired: true, allOf: ["artifacts:publish"] },
513
583
  artifacts_rollback: { sessionRequired: true, allOf: ["artifacts:publish"] },
584
+ sandbox_file_publish: {
585
+ sessionRequired: true,
586
+ allOf: ["files:read", "files:upload"],
587
+ },
514
588
  editable_artifact_list: { sessionRequired: true, allOf: ["artifacts:read"] },
515
589
  editable_artifact_create: {
516
590
  sessionRequired: true,
@@ -706,15 +780,57 @@ export function buildOpenGeniMcpServer(
706
780
  grant,
707
781
  sessionId,
708
782
  json,
709
- options.workspaceMemoryPromptMode ?? "legacy_standing",
783
+ options.workspaceMemoryPromptMode ?? "retrieval_only",
710
784
  );
711
785
  }
712
786
  if (sessionId !== null && exactAgentAttemptClaims(grant) !== null) {
713
787
  registerPreferenceRegistryTools(server, deps, grant, json);
714
788
  registerTaskNoteTools(server, deps, grant, sessionId, json);
789
+ const attempt = exactAgentAttemptClaims(grant)!;
790
+ registerCompanyBrainGovernedWriteTools({
791
+ server,
792
+ db: deps.db,
793
+ attempt: {
794
+ accountId: grant.accountId,
795
+ workspaceId: grant.workspaceId,
796
+ ...attempt,
797
+ },
798
+ authorize: async () => {
799
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
800
+ },
801
+ json,
802
+ });
803
+ registerCompanyProfileAgentAdminTools({
804
+ server,
805
+ db: deps.db,
806
+ attempt: {
807
+ accountId: grant.accountId,
808
+ workspaceId: grant.workspaceId,
809
+ ...attempt,
810
+ agentSubjectId: grant.subjectId,
811
+ },
812
+ authorize: async () => {
813
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
814
+ },
815
+ json,
816
+ });
817
+ registerRememberTools({
818
+ server,
819
+ db: deps.db,
820
+ attempt: {
821
+ accountId: grant.accountId,
822
+ workspaceId: grant.workspaceId,
823
+ ...attempt,
824
+ },
825
+ authorize: async () => {
826
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
827
+ },
828
+ json,
829
+ });
715
830
  }
716
831
  if (sessionId !== null && exactAgentAttemptClaims(grant) !== null) {
717
832
  registerWorkspaceArtifactTools(server, deps, grant, sessionId, json);
833
+ registerSandboxFileArtifactTool(server, deps, grant, sessionId, json);
718
834
  registerEditableArtifactAgentTools({
719
835
  server,
720
836
  deps,
@@ -736,7 +852,7 @@ export function buildOpenGeniMcpServer(
736
852
  registerFleetTools(server, deps, grant, sessionId, json);
737
853
  }
738
854
  if (can("enrollments:manage") && deps.settings.sandboxSelfhostedEnabled) {
739
- registerConnectedMachineTools(server, deps, grant, json);
855
+ registerConnectedMachineTools(server, deps, grant, sessionId, json);
740
856
  }
741
857
  registerRigTools(server, deps, grant, can, sessionId, json);
742
858
  registerSlackBotTools(server, deps, grant, sessionId, json);
@@ -1383,6 +1499,9 @@ export function buildOpenGeniMcpServer(
1383
1499
  overlapPolicy: z4.string().optional(),
1384
1500
  agentConfig: z4.unknown(),
1385
1501
  status: z4.string().optional(),
1502
+ // Explicit credential-free connection authority selections; declared
1503
+ // so MCP validation doesn't strip them before the contract parse.
1504
+ connectionAuthorities: z4.array(z4.unknown()).optional(),
1386
1505
  variableSetId: z4.string().uuid().optional(),
1387
1506
  // Deprecated alias of variableSetId; declared so MCP validation doesn't
1388
1507
  // strip it before the contract parse maps it (rename back-compat).
@@ -1447,6 +1566,9 @@ export function buildOpenGeniMcpServer(
1447
1566
  overlapPolicy: z4.string().optional(),
1448
1567
  agentConfig: z4.unknown().optional(),
1449
1568
  status: z4.string().optional(),
1569
+ // Omitted preserves the frozen selections, [] clears them, and an
1570
+ // array replaces them; declared so MCP validation doesn't strip it.
1571
+ connectionAuthorities: z4.array(z4.unknown()).optional(),
1450
1572
  variableSetId: z4.string().uuid().nullable().optional(),
1451
1573
  // Deprecated alias of variableSetId (rename back-compat); declared so MCP
1452
1574
  // validation doesn't strip it before the contract parse maps it.
@@ -1475,7 +1597,7 @@ export function buildOpenGeniMcpServer(
1475
1597
  if (!scheduledTaskUpdateChangesState(existing, update)) {
1476
1598
  return json(scheduledTaskReceipt("scheduled_tasks_update", existing, "unchanged", false));
1477
1599
  }
1478
- const task = await updateScheduledTask(deps.db, grant.workspaceId, id, update);
1600
+ const task = await updateScheduledTaskForApi(deps.db, grant.workspaceId, id, update);
1479
1601
  await syncUpdatedScheduledTask({
1480
1602
  db: deps.db,
1481
1603
  workflowClient: deps.workflowClient,
@@ -1498,7 +1620,7 @@ export function buildOpenGeniMcpServer(
1498
1620
  return json(scheduledTaskReceipt("scheduled_tasks_pause", existing, "unchanged", false));
1499
1621
  }
1500
1622
  const previous = await captureScheduledTaskRestoreState(deps.db, existing);
1501
- const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
1623
+ const task = await updateScheduledTaskForApi(deps.db, grant.workspaceId, id, {
1502
1624
  status: "paused",
1503
1625
  });
1504
1626
  await syncUpdatedScheduledTask({
@@ -1523,9 +1645,17 @@ export function buildOpenGeniMcpServer(
1523
1645
  return json(scheduledTaskReceipt("scheduled_tasks_resume", existing, "unchanged", false));
1524
1646
  }
1525
1647
  const previous = await captureScheduledTaskRestoreState(deps.db, existing);
1526
- const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
1527
- status: "active",
1648
+ const update = await validatedScheduledTaskUpdate({
1649
+ settings: deps.settings,
1650
+ db: deps.db,
1651
+ objectStorage: deps.objectStorage,
1652
+ grant,
1653
+ existing,
1654
+ payload: { status: "active" },
1655
+ sessionAuthorization: deps.sessionAuthorization,
1656
+ authorizationSurface: "first_party_mcp",
1528
1657
  });
1658
+ const task = await updateScheduledTaskForApi(deps.db, grant.workspaceId, id, update);
1529
1659
  await syncUpdatedScheduledTask({
1530
1660
  db: deps.db,
1531
1661
  workflowClient: deps.workflowClient,
@@ -1629,41 +1759,19 @@ export function buildOpenGeniMcpServer(
1629
1759
  inputSchema: { id: z4.string().uuid() },
1630
1760
  },
1631
1761
  async ({ id }) => {
1632
- const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
1633
- if (task.metadata.connectorKind === "atlassian") {
1634
- await revokeAtlassianScheduleAuthorization(deps, {
1635
- task,
1636
- subjectId: grant.subjectId,
1637
- });
1638
- } else {
1639
- await revokeKnowledgeSourceScheduleAuthorization(deps, {
1640
- task,
1641
- subjectId: grant.subjectId,
1642
- });
1643
- }
1644
- await deps.workflowClient.deleteScheduledTaskSchedule({
1645
- temporalScheduleId: task.temporalScheduleId,
1762
+ const { task, changed } = await deleteScheduledTaskWithDurableCleanup(deps, {
1763
+ workspaceId: grant.workspaceId,
1764
+ taskId: id,
1765
+ subjectId: grant.subjectId,
1646
1766
  });
1647
- try {
1648
- await deleteScheduledTask(deps.db, grant.workspaceId, id);
1649
- } catch {
1650
- return json(
1651
- scheduledTaskReceipt("scheduled_tasks_delete", task, "partial_failure", true, {
1652
- partialFailure: { stage: "database_delete", retryable: true },
1653
- warnings: [
1654
- "The Temporal schedule was deleted, but the task database record remains.",
1655
- ],
1656
- }),
1657
- );
1658
- }
1659
1767
  return json(
1660
1768
  mcpMutationReceipt({
1661
1769
  operation: "scheduled_tasks_delete",
1662
1770
  committed: true,
1663
1771
  outcome: "deleted",
1664
- changed: true,
1772
+ changed,
1665
1773
  resource: { type: "scheduled_task", id: task.id, state: "deleted" },
1666
- idempotency: { status: "not_supported" },
1774
+ idempotency: { status: changed ? "applied" : "replayed" },
1667
1775
  }),
1668
1776
  );
1669
1777
  },
@@ -1692,6 +1800,36 @@ export function buildOpenGeniMcpServer(
1692
1800
  return server;
1693
1801
  }
1694
1802
 
1803
+ function registerSandboxFileArtifactTool(
1804
+ server: McpServer,
1805
+ deps: ApiRouteDeps,
1806
+ grant: AccessGrant,
1807
+ sessionId: string,
1808
+ json: JsonResult,
1809
+ ): void {
1810
+ server.registerTool(
1811
+ "sandbox_file_publish",
1812
+ {
1813
+ description:
1814
+ "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.",
1815
+ inputSchema: {
1816
+ path: z4.string().min(1).max(4_096),
1817
+ },
1818
+ },
1819
+ async ({ path }) => {
1820
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
1821
+ const session = await requireSession(deps.db, grant.workspaceId, sessionId);
1822
+ return json(
1823
+ await publishSandboxFileArtifact(deps, {
1824
+ grant,
1825
+ session,
1826
+ path,
1827
+ }),
1828
+ );
1829
+ },
1830
+ );
1831
+ }
1832
+
1695
1833
  function registerSlackBotTools(
1696
1834
  server: McpServer,
1697
1835
  deps: ApiRouteDeps,
@@ -1731,6 +1869,57 @@ function registerSlackBotTools(
1731
1869
  ),
1732
1870
  );
1733
1871
 
1872
+ server.registerTool(
1873
+ "slack_bot_search",
1874
+ {
1875
+ description:
1876
+ "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.",
1877
+ inputSchema: {
1878
+ connectionId: z4.string().uuid().optional(),
1879
+ query: z4.string().min(1).max(500),
1880
+ contentTypes: z4
1881
+ .array(z4.enum(["messages", "files", "channels"]))
1882
+ .min(1)
1883
+ .max(3)
1884
+ .optional(),
1885
+ includeBots: z4.boolean().optional(),
1886
+ before: z4.number().int().min(0).optional(),
1887
+ after: z4.number().int().min(0).optional(),
1888
+ sort: z4.enum(["score", "timestamp"]).optional(),
1889
+ sortDir: z4.enum(["asc", "desc"]).optional(),
1890
+ cursor: z4.string().max(1024).optional(),
1891
+ limit: z4.number().int().min(1).max(20).optional(),
1892
+ },
1893
+ },
1894
+ async ({
1895
+ connectionId,
1896
+ query,
1897
+ contentTypes,
1898
+ includeBots,
1899
+ before,
1900
+ after,
1901
+ sort,
1902
+ sortDir,
1903
+ cursor,
1904
+ limit,
1905
+ }) =>
1906
+ json(
1907
+ await (
1908
+ await clientFor(connectionId)
1909
+ ).searchContext({
1910
+ query,
1911
+ ...(contentTypes ? { contentTypes } : {}),
1912
+ ...(includeBots !== undefined ? { includeBots } : {}),
1913
+ ...(before !== undefined ? { before } : {}),
1914
+ ...(after !== undefined ? { after } : {}),
1915
+ ...(sort ? { sort } : {}),
1916
+ ...(sortDir ? { sortDir } : {}),
1917
+ ...(cursor ? { cursor } : {}),
1918
+ ...(limit !== undefined ? { limit } : {}),
1919
+ }),
1920
+ ),
1921
+ );
1922
+
1734
1923
  server.registerTool(
1735
1924
  "slack_bot_channel_history",
1736
1925
  {
@@ -2331,20 +2520,23 @@ function registerGoalTools(
2331
2520
  "goal_set",
2332
2521
  {
2333
2522
  description:
2334
- "Create a goal when this session has none. While active, idle moments synthesize continuation turns until goal_complete or goal_pause. To change an existing goal, use goal_update with its objective revision, a change kind, and rationale.",
2523
+ "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.",
2524
+ // `maxAutoContinuations` is deliberately not agent-facing: the ceiling is
2525
+ // API/scheduled-task pacing configuration, and an agent that set its own
2526
+ // cap used to silence its orchestration for hours. Continuation pacing is
2527
+ // the deployment's input-aware idle backoff instead.
2335
2528
  inputSchema: {
2336
2529
  text: goalText,
2337
2530
  successCriteria: successCriteriaSchema.optional(),
2338
- maxAutoContinuations: z4.number().int().positive().optional(),
2339
2531
  },
2340
2532
  },
2341
- async ({ text, successCriteria, maxAutoContinuations }) => {
2533
+ async ({ text, successCriteria }) => {
2342
2534
  await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
2343
2535
  await requireSession(deps.db, grant.workspaceId, sessionId);
2344
2536
  const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
2345
- if (existing) {
2537
+ if (existing && existing.status !== "completed") {
2346
2538
  throw new Error(
2347
- `this session already has a goal at objective revision ${existing.objectiveRevision}; use goal_update to revise it`,
2539
+ `this session's goal is ${existing.status} at objective revision ${existing.objectiveRevision}; use goal_update to revise it`,
2348
2540
  );
2349
2541
  }
2350
2542
  const callerTurnId =
@@ -2358,7 +2550,7 @@ function registerGoalTools(
2358
2550
  sessionId,
2359
2551
  text,
2360
2552
  successCriteria: successCriteria ?? null,
2361
- maxAutoContinuations: maxAutoContinuations ?? null,
2553
+ maxAutoContinuations: null,
2362
2554
  createdBy: "agent",
2363
2555
  actor: "agent",
2364
2556
  });
@@ -2390,19 +2582,13 @@ function registerGoalTools(
2390
2582
  "goal_update",
2391
2583
  {
2392
2584
  description:
2393
- "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. A rewrite never counts as execution progress; use goal_progress for that.",
2585
+ "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.",
2394
2586
  inputSchema: {
2395
2587
  text: goalText.optional(),
2396
2588
  successCriteria: successCriteriaSchema.nullable().optional(),
2397
- // Optional for rolling compatibility with the former goal_update
2398
- // surface. Omitted semantic metadata is classified as a refinement of
2399
- // the currently fenced objective; new callers should always supply it.
2400
- changeKind: z4.enum(["refinement", "adaptation", "replacement"]).optional(),
2401
- rationale: goalRationale.optional(),
2402
- expectedObjectiveRevision: z4.number().int().positive().optional(),
2403
- // Deprecated compatibility input. It is committed through the new
2404
- // progress operation and never makes a semantic rewrite count itself.
2405
- progressNote: progressNoteSchema.optional(),
2589
+ changeKind: z4.enum(["refinement", "adaptation", "replacement"]),
2590
+ rationale: goalRationale,
2591
+ expectedObjectiveRevision: z4.number().int().positive(),
2406
2592
  idempotencyKey: z4.string().uuid(),
2407
2593
  },
2408
2594
  },
@@ -2412,12 +2598,11 @@ function registerGoalTools(
2412
2598
  changeKind,
2413
2599
  rationale,
2414
2600
  expectedObjectiveRevision,
2415
- progressNote,
2416
2601
  idempotencyKey,
2417
2602
  }) => {
2418
2603
  await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
2419
- if (text === undefined && successCriteria === undefined && progressNote === undefined) {
2420
- throw new Error("goal_update requires semantic content or a progressNote");
2604
+ if (text === undefined && successCriteria === undefined) {
2605
+ throw new Error("goal_update requires text or successCriteria");
2421
2606
  }
2422
2607
  const context = exactAgentCommandContext(grant, sessionId);
2423
2608
  const command = {
@@ -2431,36 +2616,22 @@ function registerGoalTools(
2431
2616
  },
2432
2617
  operationKey: idempotencyKey,
2433
2618
  };
2434
- const semantic =
2435
- text !== undefined || successCriteria !== undefined
2436
- ? await updateSessionGoalWithEvent(deps.db, grant.workspaceId, sessionId, {
2437
- ...(text !== undefined ? { text } : {}),
2438
- ...(successCriteria !== undefined ? { successCriteria } : {}),
2439
- changeKind: changeKind ?? "refinement",
2440
- rationale: rationale ?? "Compatibility refinement from goal_update",
2441
- ...(expectedObjectiveRevision !== undefined ? { expectedObjectiveRevision } : {}),
2442
- actor: "agent",
2443
- command,
2444
- })
2445
- : null;
2446
- const progress = progressNote
2447
- ? await recordSessionGoalProgressWithEvent(deps.db, grant.workspaceId, sessionId, {
2448
- progressNote,
2449
- command,
2450
- })
2451
- : null;
2452
- await publishDurableSessionEvents(deps.bus, grant.workspaceId, sessionId, [
2453
- ...(semantic?.events ?? []),
2454
- ...(progress?.events ?? []),
2455
- ]);
2456
- const goal = progress?.goal ?? semantic?.goal;
2457
- if (!goal) throw new Error("goal_update produced no mutation");
2619
+ const semantic = await updateSessionGoalWithEvent(deps.db, grant.workspaceId, sessionId, {
2620
+ ...(text !== undefined ? { text } : {}),
2621
+ ...(successCriteria !== undefined ? { successCriteria } : {}),
2622
+ changeKind,
2623
+ rationale,
2624
+ expectedObjectiveRevision,
2625
+ actor: "agent",
2626
+ command,
2627
+ });
2628
+ await publishDurableSessionEvents(deps.bus, grant.workspaceId, sessionId, semantic.events);
2458
2629
  return json({
2459
- ...goal,
2460
- operationId: semantic?.operationId ?? progress?.operationId ?? null,
2461
- replay: Boolean(semantic?.replay || progress?.replay),
2462
- outcome: semantic?.outcome ?? "progress_recorded",
2463
- proposalId: semantic?.proposalId ?? null,
2630
+ ...semantic.goal,
2631
+ operationId: semantic.operationId,
2632
+ replay: semantic.replay,
2633
+ outcome: semantic.outcome,
2634
+ proposalId: semantic.proposalId,
2464
2635
  });
2465
2636
  },
2466
2637
  );
@@ -2502,6 +2673,64 @@ function registerGoalTools(
2502
2673
  },
2503
2674
  );
2504
2675
 
2676
+ server.registerTool(
2677
+ "goal_wait",
2678
+ {
2679
+ description:
2680
+ "Hold the active goal's automatic continuation while progress depends on child sessions or an external event. Use it instead of sleeping or polling sessions_list/session_get/session_events, and only after re-checking the child or external state once. Always end your turn right after calling it: you will be woken by a child result, an agent message, a human prompt, or at the deadline, and the goal stays active. The deadline (untilSeconds) is mandatory and capped at 7 days. Never use goal_wait instead of goal_pause when you are blocked on a human decision.",
2681
+ inputSchema: {
2682
+ reason: goalRationale,
2683
+ untilSeconds: z4
2684
+ .number()
2685
+ .int()
2686
+ .min(SESSION_GOAL_HOLD_MIN_SECONDS)
2687
+ .max(SESSION_GOAL_HOLD_MAX_SECONDS),
2688
+ idempotencyKey: z4.string().uuid().optional(),
2689
+ },
2690
+ },
2691
+ async ({ reason, untilSeconds, idempotencyKey }) => {
2692
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
2693
+ const context = exactAgentCommandContext(grant, sessionId);
2694
+ // Without a caller-supplied key the operation is idempotent per (goal
2695
+ // target, turn, exact arguments): a replacement attempt replaying the
2696
+ // same call receives the stored result instead of re-stamping the hold,
2697
+ // while a genuinely new same-turn wait (different reason/deadline) is a
2698
+ // new operation whose later hold wins.
2699
+ const operationKey =
2700
+ idempotencyKey ??
2701
+ `goal-wait:${context.callerTurnId}:${createHash("sha256")
2702
+ .update(JSON.stringify({ reason, untilSeconds }))
2703
+ .digest("hex")
2704
+ .slice(0, 32)}`;
2705
+ const { goal, events, operationId, replay, untilAt } =
2706
+ await holdSessionGoalContinuationWithEvent(deps.db, grant.workspaceId, sessionId, {
2707
+ reason,
2708
+ untilSeconds,
2709
+ command: {
2710
+ accountId: grant.accountId,
2711
+ actor: {
2712
+ type: "agent_attempt",
2713
+ attemptId: context.callerAttemptId,
2714
+ sessionId: context.callerSessionId,
2715
+ turnId: context.callerTurnId,
2716
+ executionGeneration: context.callerExecutionGeneration,
2717
+ },
2718
+ operationKey,
2719
+ },
2720
+ });
2721
+ await publishDurableSessionEvents(deps.bus, grant.workspaceId, sessionId, events);
2722
+ return json({
2723
+ status: "held",
2724
+ goalId: goal.id,
2725
+ untilAt,
2726
+ operationId,
2727
+ replay,
2728
+ nextAction:
2729
+ "End your turn now; you will be woken by a child result, a message, a human prompt, or at untilAt.",
2730
+ });
2731
+ },
2732
+ );
2733
+
2505
2734
  server.registerTool(
2506
2735
  "goal_complete",
2507
2736
  {
@@ -2713,7 +2942,9 @@ function registerWorkspaceArtifactTools(
2713
2942
  getWorkspaceArtifact(deps.db, grant.workspaceId, artifactId),
2714
2943
  getWorkspaceArtifactContentRef(deps.db, grant.workspaceId, artifactId, versionId),
2715
2944
  ]);
2716
- const object = await deps.objectStorage.getObjectBytes(ref.contentKey);
2945
+ const object = await retryWhileMissing(async () =>
2946
+ deps.objectStorage!.getObjectBytes(ref.contentKey),
2947
+ );
2717
2948
  if (!object) throw new Error("Artifact content is unavailable");
2718
2949
  const actualHash = createHash("sha256").update(object.bytes).digest("hex");
2719
2950
  if (actualHash !== ref.version.contentSha256)
@@ -2857,7 +3088,11 @@ function registerTaskNoteTools(
2857
3088
  if (!resolved || resolved.sessionId !== sessionId) {
2858
3089
  throw new Error("Exact signed task-note attempt authority is required.");
2859
3090
  }
2860
- return { accountId: grant.accountId, workspaceId: grant.workspaceId, ...resolved };
3091
+ return {
3092
+ accountId: grant.accountId,
3093
+ workspaceId: grant.workspaceId,
3094
+ ...resolved,
3095
+ };
2861
3096
  };
2862
3097
  const authorize = async () => {
2863
3098
  await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
@@ -2891,7 +3126,13 @@ function registerTaskNoteTools(
2891
3126
  },
2892
3127
  async ({ includeArchived, limit }) => {
2893
3128
  await authorize();
2894
- return json(await listTaskNotes(deps.db, { ...attemptClaims(), includeArchived, limit }));
3129
+ return json(
3130
+ await listTaskNotes(deps.db, {
3131
+ ...attemptClaims(),
3132
+ includeArchived,
3133
+ limit,
3134
+ }),
3135
+ );
2895
3136
  },
2896
3137
  );
2897
3138
 
@@ -2946,6 +3187,53 @@ function registerTaskNoteTools(
2946
3187
  );
2947
3188
  },
2948
3189
  );
3190
+
3191
+ server.registerTool(
3192
+ "task_note_replace",
3193
+ {
3194
+ description:
3195
+ "Atomically correct one exact active coordination note: archive the old immutable note and create a fresh linked replacement. Exact retries replay safely; stale versions or changed input fail closed.",
3196
+ inputSchema: {
3197
+ operationId: z4.string().uuid(),
3198
+ replacedNoteId: z4.string().uuid(),
3199
+ expectedReplacedVersion: z4.number().int().min(1).max(1),
3200
+ replacementKind: z4.enum([
3201
+ "finding",
3202
+ "decision",
3203
+ "blocker",
3204
+ "ownership",
3205
+ "artifact",
3206
+ "handoff",
3207
+ ]),
3208
+ replacementText: boundedUtf8(TASK_NOTE_TEXT_MAX_BYTES, "Task note replacement text"),
3209
+ replacementExpiresInDays: z4.number().int().min(1).max(TASK_NOTE_MAX_LIFETIME_DAYS),
3210
+ reason: boundedUtf8(TASK_NOTE_REASON_MAX_BYTES, "Task note replacement reason"),
3211
+ },
3212
+ },
3213
+ async ({
3214
+ operationId,
3215
+ replacedNoteId,
3216
+ expectedReplacedVersion,
3217
+ replacementKind,
3218
+ replacementText,
3219
+ replacementExpiresInDays,
3220
+ reason,
3221
+ }) => {
3222
+ await authorize();
3223
+ return json(
3224
+ await replaceTaskNote(deps.db, {
3225
+ ...attemptClaims(),
3226
+ operationId,
3227
+ replacedNoteId,
3228
+ expectedReplacedVersion,
3229
+ replacementKind,
3230
+ replacementText,
3231
+ replacementExpiresInDays,
3232
+ reason,
3233
+ }),
3234
+ );
3235
+ },
3236
+ );
2949
3237
  }
2950
3238
 
2951
3239
  function registerPreferenceRegistryTools(
@@ -3060,11 +3348,6 @@ function scheduledTaskUpdateChangesState(
3060
3348
  return false;
3061
3349
  }
3062
3350
 
3063
- function memoryPreview(text: string): string {
3064
- const normalized = text.replace(/\s+/g, " ").trim();
3065
- return normalized.length <= 120 ? normalized : `${normalized.slice(0, 119)}…`;
3066
- }
3067
-
3068
3351
  export function memorySlackPublicationActor(
3069
3352
  actor: Extract<SessionAuthorizationActor, { kind: "agent_attempt" }>,
3070
3353
  sessionId: string,
@@ -3091,24 +3374,10 @@ function registerMemoryTools(
3091
3374
  json: JsonResult,
3092
3375
  promptMode: WorkspaceMemoryPromptMode,
3093
3376
  ): void {
3094
- const publicationActor = async () => {
3095
- const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
3096
- return memorySlackPublicationActor(actor, sessionId, grant.subjectLabel ?? null);
3097
- };
3098
- const publicationInputSchema = z4.object({
3099
- importance: z4.enum(["major", "normal", "minor"]),
3100
- audience: z4.literal("workspace"),
3101
- slackMode: z4.enum(["auto", "review", "never"]),
3102
- shareSummary: z4.string().trim().min(1).max(4_096),
3103
- });
3104
-
3105
3377
  server.registerTool(
3106
3378
  "memory_search",
3107
3379
  {
3108
- description:
3109
- promptMode === "retrieval_only"
3110
- ? `${MEMORY_SEARCH_TOOL_DESCRIPTION} Legacy preference-kind records are excluded here because structured preferences are the only behavioral authority.`
3111
- : MEMORY_SEARCH_TOOL_DESCRIPTION,
3380
+ description: `${MEMORY_SEARCH_TOOL_DESCRIPTION} Legacy preference-kind records are excluded from this tool because structured preferences are the only behavioral authority. To save something the user explicitly asked to keep, use \`remember\`; for your own findings use task notes and their promotion tools.`,
3112
3381
  inputSchema: {
3113
3382
  query: z4.string().min(1),
3114
3383
  kind: MemoryKindSchema.optional(),
@@ -3131,204 +3400,10 @@ function registerMemoryTools(
3131
3400
  }),
3132
3401
  );
3133
3402
 
3134
- server.registerTool(
3135
- "memory_save",
3136
- {
3137
- description:
3138
- promptMode === "retrieval_only"
3139
- ? `${MEMORY_SAVE_TOOL_DESCRIPTION} A preference-kind save is retained only as a legacy observation and cannot become behavioral authority; use the structured preference proposal surface for behavioral guidance.`
3140
- : MEMORY_SAVE_TOOL_DESCRIPTION,
3141
- inputSchema: {
3142
- text: z4.string().min(1),
3143
- kind: MemoryKindSchema,
3144
- confidence: z4.number().min(0).max(1).optional(),
3145
- replaces_id: z4.string().min(1).optional(),
3146
- slack_publication: publicationInputSchema.optional(),
3147
- },
3148
- },
3149
- async ({ text, kind, confidence, replaces_id, slack_publication }) => {
3150
- const principal = slack_publication ? await publicationActor() : null;
3151
- const result = await saveWorkspaceMemoryWithSlackPublication(
3152
- deps.db,
3153
- {
3154
- accountId: grant.accountId,
3155
- workspaceId: grant.workspaceId,
3156
- sessionId,
3157
- text,
3158
- kind,
3159
- ...(confidence !== undefined ? { confidence } : {}),
3160
- ...(replaces_id ? { replacesId: replaces_id } : {}),
3161
- origin: "agent",
3162
- },
3163
- slack_publication
3164
- ? {
3165
- distribution: MemorySlackPublicationDistribution.parse(slack_publication),
3166
- actor: principal!.actor,
3167
- ownerLabel: principal!.ownerLabel,
3168
- }
3169
- : null,
3170
- deps.getDocumentServices().embedder,
3171
- );
3172
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
3173
- {
3174
- type: "memory.saved",
3175
- payload: {
3176
- memoryId: result.memory.id,
3177
- kind: result.memory.kind,
3178
- preview: memoryPreview(result.memory.text),
3179
- deduped: result.deduped,
3180
- ...(result.superseded ? { supersededMemoryId: result.superseded.id } : {}),
3181
- },
3182
- },
3183
- ]);
3184
- const changed = !result.deduped || result.updated || result.superseded !== null;
3185
- const outcome =
3186
- result.updated || result.superseded !== null
3187
- ? "updated"
3188
- : result.deduped
3189
- ? "unchanged"
3190
- : "created";
3191
- return json(
3192
- mcpMutationReceipt({
3193
- operation: "memory_save",
3194
- committed: true,
3195
- outcome,
3196
- changed,
3197
- resource: {
3198
- type: "knowledge_memory",
3199
- id: result.memory.id,
3200
- state: result.memory.status,
3201
- },
3202
- relatedResources: result.superseded
3203
- ? [
3204
- {
3205
- type: "knowledge_memory",
3206
- id: result.superseded.id,
3207
- state: result.superseded.status,
3208
- },
3209
- ]
3210
- : undefined,
3211
- timestamp: result.memory.updatedAt,
3212
- idempotency: { status: "not_supported" },
3213
- warnings: !result.embedded
3214
- ? ["Memory committed without a vector embedding; keyword search remains available."]
3215
- : [],
3216
- facts: {
3217
- deduped: result.deduped,
3218
- dedupeReason: result.dedupeReason,
3219
- updatedInPlace: result.updated,
3220
- embedded: result.embedded,
3221
- slackPublicationDecision: result.slackPublication.decision?.eligible
3222
- ? "eligible"
3223
- : (result.slackPublication.decision?.reason ?? "not_requested"),
3224
- slackPublicationId:
3225
- result.slackPublication.enqueue?.kind === "enqueued" ||
3226
- result.slackPublication.enqueue?.kind === "replayed"
3227
- ? result.slackPublication.enqueue.publication.id
3228
- : null,
3229
- slackPublicationState:
3230
- result.slackPublication.enqueue?.kind === "enqueued" ||
3231
- result.slackPublication.enqueue?.kind === "replayed"
3232
- ? result.slackPublication.enqueue.publication.state
3233
- : null,
3234
- },
3235
- }),
3236
- );
3237
- },
3238
- );
3239
-
3240
- server.registerTool(
3241
- "memory_correct",
3242
- {
3243
- description: MEMORY_CORRECT_TOOL_DESCRIPTION,
3244
- inputSchema: {
3245
- id: z4.string().min(1),
3246
- reason: z4.string().min(1).optional(),
3247
- replacement_text: z4.string().min(1).optional(),
3248
- slack_publication: publicationInputSchema.optional(),
3249
- },
3250
- },
3251
- async ({ id, reason, replacement_text, slack_publication }) => {
3252
- const principal = slack_publication ? await publicationActor() : null;
3253
- const result = await correctWorkspaceMemoryWithSlackPublication(
3254
- deps.db,
3255
- {
3256
- accountId: grant.accountId,
3257
- workspaceId: grant.workspaceId,
3258
- sessionId,
3259
- id,
3260
- ...(reason ? { reason } : {}),
3261
- ...(replacement_text ? { replacementText: replacement_text } : {}),
3262
- },
3263
- slack_publication
3264
- ? {
3265
- distribution: MemorySlackPublicationDistribution.parse(slack_publication),
3266
- actor: principal!.actor,
3267
- ownerLabel: principal!.ownerLabel,
3268
- }
3269
- : null,
3270
- deps.getDocumentServices().embedder,
3271
- );
3272
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
3273
- {
3274
- type: "memory.corrected",
3275
- payload: {
3276
- memoryId: result.memory.id,
3277
- kind: result.memory.kind,
3278
- preview: memoryPreview(result.memory.text),
3279
- action: result.action,
3280
- ...(reason ? { reason: memoryPreview(reason) } : {}),
3281
- ...(result.replacement
3282
- ? {
3283
- replacementMemoryId: result.replacement.id,
3284
- replacementPreview: memoryPreview(result.replacement.text),
3285
- }
3286
- : {}),
3287
- },
3288
- },
3289
- ]);
3290
- return json(
3291
- mcpMutationReceipt({
3292
- operation: "memory_correct",
3293
- committed: true,
3294
- outcome: "updated",
3295
- changed: true,
3296
- resource: {
3297
- type: "knowledge_memory",
3298
- id: result.memory.id,
3299
- state: result.memory.status,
3300
- },
3301
- relatedResources: result.replacement
3302
- ? [
3303
- {
3304
- type: "knowledge_memory",
3305
- id: result.replacement.id,
3306
- state: result.replacement.status,
3307
- },
3308
- ]
3309
- : undefined,
3310
- timestamp: (result.replacement ?? result.memory).updatedAt,
3311
- idempotency: { status: "not_supported" },
3312
- facts: {
3313
- correctionAction: result.action,
3314
- slackPublicationDecision: result.slackPublication.decision?.eligible
3315
- ? "eligible"
3316
- : (result.slackPublication.decision?.reason ?? "not_requested"),
3317
- slackPublicationId:
3318
- result.slackPublication.enqueue?.kind === "enqueued" ||
3319
- result.slackPublication.enqueue?.kind === "replayed"
3320
- ? result.slackPublication.enqueue.publication.id
3321
- : null,
3322
- slackPublicationState:
3323
- result.slackPublication.enqueue?.kind === "enqueued" ||
3324
- result.slackPublication.enqueue?.kind === "replayed"
3325
- ? result.slackPublication.enqueue.publication.state
3326
- : null,
3327
- },
3328
- }),
3329
- );
3330
- },
3331
- );
3403
+ // Memory V1 writes are retired. Explicit user-directed knowledge goes
3404
+ // through `remember`; an agent's own findings go through task notes and
3405
+ // governed promotion. `memory_search` stays: reading the existing record
3406
+ // set is still how an agent recalls what a workspace already knows.
3332
3407
  }
3333
3408
 
3334
3409
  // Fleet tools (M7 bring-your-own-compute). Session-scoped (they steer THIS
@@ -3353,7 +3428,12 @@ function registerFleetTools(
3353
3428
  const session = await requireSession(deps.db, ctx.workspaceId, ctx.sessionId);
3354
3429
  return await ensureViewerSessionGroupReady(
3355
3430
  { db: deps.db, settings: deps.settings, bus: deps.bus },
3356
- { accountId: ctx.accountId, workspaceId: ctx.workspaceId, session },
3431
+ {
3432
+ accountId: ctx.accountId,
3433
+ workspaceId: ctx.workspaceId,
3434
+ session,
3435
+ subjectId: ctx.subjectId ?? null,
3436
+ },
3357
3437
  );
3358
3438
  },
3359
3439
  };
@@ -3362,12 +3442,49 @@ function registerFleetTools(
3362
3442
  // call-time via the shared helper (same context the user-authenticated swap
3363
3443
  // REST route builds). Throws when the session has no box (backend:none) — the
3364
3444
  // fleet is only meaningful for a session that runs in a sandbox.
3365
- const fleetContext = async (): Promise<FleetContext> =>
3366
- await buildFleetContextForSession(deps, {
3445
+ const fleetContext = async (): Promise<FleetContext> => {
3446
+ const claims = exactAgentAttemptClaims(grant);
3447
+ const actor = claims
3448
+ ? await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId)
3449
+ : null;
3450
+ return await buildFleetContextForSession(deps, {
3367
3451
  accountId: grant.accountId,
3368
3452
  workspaceId: grant.workspaceId,
3369
3453
  sessionId,
3454
+ ...(actor?.initiatingHumanSubjectId ? { subjectId: actor.initiatingHumanSubjectId } : {}),
3455
+ ...(actor?.initiatingHumanSubjectId && claims
3456
+ ? {
3457
+ attemptAuthority: {
3458
+ turnId: claims.turnId,
3459
+ attemptId: claims.attemptId,
3460
+ executionGeneration: claims.executionGeneration,
3461
+ initiatingHumanSubjectId: actor.initiatingHumanSubjectId,
3462
+ },
3463
+ }
3464
+ : {}),
3370
3465
  });
3466
+ };
3467
+
3468
+ const oneOffCodemodeEnvironment = async (
3469
+ op: RunOnOp,
3470
+ ): Promise<Readonly<Record<string, string>> | undefined> => {
3471
+ if (op.kind !== "exec") return undefined;
3472
+ const claims = exactAgentAttemptClaims(grant);
3473
+ if (!claims || claims.sessionId !== sessionId) return undefined;
3474
+ const material = await mintSandboxCodemodeToken(
3475
+ deps.settings,
3476
+ { accountId: grant.accountId, workspaceId: grant.workspaceId },
3477
+ claims,
3478
+ );
3479
+ if (!material) return undefined;
3480
+ return {
3481
+ OPENGENI_CODEMODE_URL: codemodeWorkspaceUrl(deps.settings, grant.workspaceId),
3482
+ OPENGENI_CODEMODE_TOKEN: material.token,
3483
+ ...(deps.settings.ogtoolPackageSpec
3484
+ ? { OPENGENI_OGTOOL_PACKAGE_SPEC: deps.settings.ogtoolPackageSpec }
3485
+ : {}),
3486
+ };
3487
+ };
3371
3488
 
3372
3489
  server.registerTool(
3373
3490
  "sandboxes_list",
@@ -3383,7 +3500,7 @@ function registerFleetTools(
3383
3500
  "sandbox_attach",
3384
3501
  {
3385
3502
  description:
3386
- 'Attach this session to a sandbox for the next tool call. The target must be owned and verified ready. 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.',
3503
+ '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.',
3387
3504
  inputSchema: { target: z4.string().min(1) },
3388
3505
  },
3389
3506
  async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
@@ -3393,7 +3510,7 @@ function registerFleetTools(
3393
3510
  "sandbox_swap",
3394
3511
  {
3395
3512
  description:
3396
- 'Swap the active sandbox for this session mid-conversation. Validates ownership and verified readiness, then advances the route epoch. 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.',
3513
+ '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.',
3397
3514
  inputSchema: { target: z4.string().min(1) },
3398
3515
  },
3399
3516
  async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
@@ -3421,8 +3538,15 @@ function registerFleetTools(
3421
3538
  ]),
3422
3539
  },
3423
3540
  },
3424
- async ({ target, op }) =>
3425
- json(await runOnSandbox(services, await fleetContext(), target, op as RunOnOp)),
3541
+ async ({ target, op }) => {
3542
+ const typedOp = op as RunOnOp;
3543
+ const transientExecEnvironment = await oneOffCodemodeEnvironment(typedOp);
3544
+ return json(
3545
+ await runOnSandbox(services, await fleetContext(), target, typedOp, {
3546
+ ...(transientExecEnvironment ? { transientExecEnvironment } : {}),
3547
+ }),
3548
+ );
3549
+ },
3426
3550
  );
3427
3551
 
3428
3552
  server.registerTool(
@@ -3454,13 +3578,14 @@ function registerConnectedMachineTools(
3454
3578
  server: McpServer,
3455
3579
  deps: ApiRouteDeps,
3456
3580
  grant: AccessGrant,
3581
+ sessionId: string | null,
3457
3582
  json: JsonResult,
3458
3583
  ): void {
3459
3584
  server.registerTool(
3460
3585
  "connected_machine_remove",
3461
3586
  {
3462
3587
  description:
3463
- "Remove one enrolled self-hosted machine while it is offline. Access is revoked, future heartbeat/reconnect credentials are rejected, session/route/lease/archive history is retained, and a fresh human-approved device-flow enrollment is required to reconnect. Pass the enrollmentId from the Machines surface, never a Modal sandbox id. Blocked outcomes include every dependent session and the action needed before retrying. Move each dependent session through the canonical sandbox_swap target=default path before retrying removal; the removal authority never rewrites routes directly.",
3588
+ "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.",
3464
3589
  inputSchema: {
3465
3590
  enrollmentId: z4.string().uuid(),
3466
3591
  expectedUpdatedAt: z4.string().datetime({ offset: true }).optional(),
@@ -3468,13 +3593,31 @@ function registerConnectedMachineTools(
3468
3593
  },
3469
3594
  },
3470
3595
  async ({ enrollmentId, expectedUpdatedAt, idempotencyKey }) => {
3596
+ const resourceGrant =
3597
+ sessionId && exactAgentAttemptClaims(grant)
3598
+ ? {
3599
+ ...grant,
3600
+ subjectId:
3601
+ (await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId))
3602
+ .initiatingHumanSubjectId ?? grant.subjectId,
3603
+ }
3604
+ : grant;
3605
+ const enrollment = (await listEnrollments(deps.db, resourceGrant, { status: "active" })).find(
3606
+ (candidate) => candidate.id === enrollmentId,
3607
+ );
3608
+ if (!enrollment) {
3609
+ throw new Error("machine enrollment not found in this access scope");
3610
+ }
3611
+ if (enrollment.scope === "organization" && !grant.permissions.includes("account:admin")) {
3612
+ throw new Error("missing permission: account:admin");
3613
+ }
3471
3614
  const result = await removeEnrollment(deps.db, {
3472
3615
  accountId: grant.accountId,
3473
- workspaceId: grant.workspaceId,
3616
+ workspaceId: enrollment.workspaceId,
3474
3617
  enrollmentId,
3475
3618
  operationKey: idempotencyKey?.trim() || randomUUID(),
3476
3619
  ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
3477
- subjectId: grant.subjectId,
3620
+ subjectId: resourceGrant.subjectId,
3478
3621
  });
3479
3622
  if (!result) {
3480
3623
  throw new Error("machine enrollment not found in this workspace");
@@ -3518,6 +3661,22 @@ function registerRigTools(
3518
3661
  sessionId: string | null,
3519
3662
  json: JsonResult,
3520
3663
  ): void {
3664
+ const resourceGrant = async (): Promise<AccessGrant> => {
3665
+ if (!sessionId || exactAgentAttemptClaims(grant) === null) return grant;
3666
+ const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
3667
+ return actor.initiatingHumanSubjectId
3668
+ ? { ...grant, subjectId: actor.initiatingHumanSubjectId }
3669
+ : grant;
3670
+ };
3671
+ const requireMutableRig = async (rigId: string) => {
3672
+ const rig = await requireRigForApi(deps.db, await resourceGrant(), rigId);
3673
+ if (rig.scope === "organization") {
3674
+ throw new Error(
3675
+ "Organization rig mutation requires account-admin authority through the authenticated REST surface.",
3676
+ );
3677
+ }
3678
+ return rig;
3679
+ };
3521
3680
  if (can("rigs:use")) {
3522
3681
  server.registerTool(
3523
3682
  "rig_list",
@@ -3525,7 +3684,7 @@ function registerRigTools(
3525
3684
  description: "List workspace rigs and their active versions.",
3526
3685
  inputSchema: {},
3527
3686
  },
3528
- async () => json({ rigs: await listRigs(deps.db, grant.workspaceId) }),
3687
+ async () => json({ rigs: await listRigs(deps.db, await resourceGrant()) }),
3529
3688
  );
3530
3689
 
3531
3690
  server.registerTool(
@@ -3540,17 +3699,17 @@ function registerRigTools(
3540
3699
  },
3541
3700
  },
3542
3701
  async ({ rigId, versionLimit, changeLimit }) => {
3543
- const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
3702
+ const rig = await requireRigForApi(deps.db, await resourceGrant(), rigId);
3544
3703
  const [versions, changes] = await Promise.all([
3545
3704
  listRigVersionMonitoringSummaries(
3546
3705
  deps.db,
3547
- grant.workspaceId,
3706
+ rig.workspaceId,
3548
3707
  rig.id,
3549
3708
  boundedRigHistoryLimit(versionLimit),
3550
3709
  ),
3551
3710
  listRigChangeMonitoringSummaries(
3552
3711
  deps.db,
3553
- grant.workspaceId,
3712
+ rig.workspaceId,
3554
3713
  rig.id,
3555
3714
  boundedRigHistoryLimit(changeLimit),
3556
3715
  ),
@@ -3571,10 +3730,11 @@ function registerRigTools(
3571
3730
  },
3572
3731
  },
3573
3732
  async ({ rigId, command, note }) => {
3574
- const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
3733
+ const rig = await requireMutableRig(rigId);
3734
+ const originGrant = { ...(await resourceGrant()), workspaceId: rig.workspaceId };
3575
3735
  const change = await proposeRigChangeForApi(
3576
3736
  { db: deps.db },
3577
- grant,
3737
+ originGrant,
3578
3738
  rig,
3579
3739
  {
3580
3740
  kind: "setup_append",
@@ -3582,11 +3742,11 @@ function registerRigTools(
3582
3742
  },
3583
3743
  sessionId ? { proposedBy: `session:${sessionId}` } : {},
3584
3744
  );
3585
- const verifying = await beginMcpRigVerificationAttempt(deps, grant.workspaceId, change.id);
3745
+ const verifying = await beginMcpRigVerificationAttempt(deps, rig.workspaceId, change.id);
3586
3746
  const attempt = verificationAttempt(verifying);
3587
3747
  try {
3588
3748
  await deps.workflowClient.startRigVerification({
3589
- workspaceId: grant.workspaceId,
3749
+ workspaceId: rig.workspaceId,
3590
3750
  changeId: change.id,
3591
3751
  workflowId: `rig-verification-change-${change.id}-attempt-${attempt}`,
3592
3752
  });
@@ -3651,18 +3811,14 @@ function registerRigTools(
3651
3811
  },
3652
3812
  },
3653
3813
  async ({ rigId, changeId }) => {
3654
- const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
3814
+ const rig = await requireMutableRig(rigId);
3655
3815
  if (changeId) {
3656
- const change = await requireRigChangeForApi(deps.db, grant.workspaceId, rig.id, changeId);
3657
- const verifying = await beginMcpRigVerificationAttempt(
3658
- deps,
3659
- grant.workspaceId,
3660
- change.id,
3661
- );
3816
+ const change = await requireRigChangeForApi(deps.db, rig.workspaceId, rig.id, changeId);
3817
+ const verifying = await beginMcpRigVerificationAttempt(deps, rig.workspaceId, change.id);
3662
3818
  const attempt = verificationAttempt(verifying);
3663
3819
  try {
3664
3820
  await deps.workflowClient.startRigVerification({
3665
- workspaceId: grant.workspaceId,
3821
+ workspaceId: rig.workspaceId,
3666
3822
  changeId: change.id,
3667
3823
  workflowId: `rig-verification-change-${change.id}-attempt-${attempt}`,
3668
3824
  });
@@ -3721,7 +3877,7 @@ function registerRigTools(
3721
3877
  throw new Error("rig has no active version");
3722
3878
  }
3723
3879
  await deps.workflowClient.startRigVerification({
3724
- workspaceId: grant.workspaceId,
3880
+ workspaceId: rig.workspaceId,
3725
3881
  versionId: rig.activeVersion.id,
3726
3882
  workflowId: `rig-verification-version-${rig.activeVersion.id}-${crypto.randomUUID()}`,
3727
3883
  });
@@ -3758,11 +3914,12 @@ function registerRigTools(
3758
3914
  },
3759
3915
  },
3760
3916
  async ({ rigId, changeId }) => {
3761
- const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
3762
- const change = await requireRigChangeForApi(deps.db, grant.workspaceId, rig.id, changeId);
3917
+ const rig = await requireMutableRig(rigId);
3918
+ const change = await requireRigChangeForApi(deps.db, rig.workspaceId, rig.id, changeId);
3919
+ const originGrant = { ...(await resourceGrant()), workspaceId: rig.workspaceId };
3763
3920
  const promoted = await promoteVerifiedDefinitionEditChangeForApi(
3764
3921
  { db: deps.db },
3765
- grant,
3922
+ originGrant,
3766
3923
  rig,
3767
3924
  change,
3768
3925
  );
@@ -3841,7 +3998,7 @@ function registerWorkspaceOrchestrationTools(
3841
3998
  server.registerTool(
3842
3999
  "sessions_list",
3843
4000
  {
3844
- description: `List compact high-level session status in this workspace. Defaults to creation order; use orderBy=updatedAt with decimal activity-revision updatedAfter/updatedThrough tokens for gap-free indexed incremental monitoring independent of application clocks. Cursors are opaque revision-fenced keysets. includeLastMessage is opt-in; its rendered previews share a deterministic ${SESSION_DISCOVERY_PREVIEW_MAX_BYTES}-byte UTF-8 aggregate budget, and omitted previews include a bounded session_events drill-down input (exact message type, direction=before, limit=1, monitoring summary). Use session_get for exact known targets and detailed resources/tools/settings. The list never returns full session objects or history.`,
4001
+ description: `List compact high-level session status in this workspace. Defaults to creation order; use orderBy=updatedAt with decimal activity-revision updatedAfter/updatedThrough tokens for gap-free indexed incremental monitoring independent of application clocks. Cursors are opaque revision-fenced keysets. includeLastMessage is opt-in and never previews a human/API prompt whose turn was never claimed (still queued, or deleted/edited/cancelled before any claim): waiting work is represented by queuedPromptCount until the turn is claimed. Rendered previews share a deterministic ${SESSION_DISCOVERY_PREVIEW_MAX_BYTES}-byte UTF-8 aggregate budget, and omitted previews include a bounded session_events drill-down input (exact message type, direction=before, limit=1, monitoring summary). Use session_get for exact known targets and detailed resources/tools/settings. The list never returns full session objects or history.`,
3845
4002
  inputSchema: {
3846
4003
  limit: z4.number().int().positive().max(100).optional(),
3847
4004
  cursor: z4.string().max(512).optional(),
@@ -3888,7 +4045,7 @@ function registerWorkspaceOrchestrationTools(
3888
4045
  "session_get",
3889
4046
  {
3890
4047
  description:
3891
- "Get another session you are managing: status, goal-bearing metadata, resources, tools, and variableSet attachment (names/ids only, never variable values). Do not call this with your own current session id to reconstruct context; your model-facing conversation history and persistent setting state are already supplied directly. Unbounded agent-set fields are clamped so monitoring another session cannot flood this context.",
4048
+ "Get another session you are managing: status, goal-bearing metadata, resources, persisted tool refs, exact bounded effectiveToolPolicy, and variableSet attachment (names/ids only, never variable values). effectiveToolPolicy distinguishes selected refs from workspace defaults, mandatory carriers, configured/deferred refs, and dropped refs. Do not call this with your own current session id to reconstruct context; your model-facing conversation history and persistent setting state are already supplied directly. Unbounded agent-set fields are clamped so monitoring another session cannot flood this context.",
3892
4049
  inputSchema: { sessionId: z4.string().uuid() },
3893
4050
  },
3894
4051
  async ({ sessionId }) => {
@@ -3925,7 +4082,7 @@ function registerWorkspaceOrchestrationTools(
3925
4082
  "session_events",
3926
4083
  {
3927
4084
  description:
3928
- "Read a compact semantic tail only when session_get status is insufficient. With no cursor, this returns the newest matching events and excludes raw message/reasoning/command/PTY deltas. Use `latest` as an exclusive lookup for the authoritative newest durable sequence in exactly one semantic class; `receipt` is the concise alias for `tool_receipt`, and latest cannot be combined with type or class filters. Add `resultMode=compact` to latest for one bounded result-bearing completion/checkpoint/receipt without another inference. Use nextBefore to page older or explicit after/nextAfter to page forward. Type/class filters run in the RLS-scoped database query. payloadMode none|summary|full controls retained audit payload projection, but every model result is independently byte-capped with explicit truncation and exact covered sequence bounds. Exact retained forensic payloads require the access-controlled REST/SDK events API with mode=forensic&payloadMode=full; generic source bytes never retained by the audit boundary remain unavailable.",
4085
+ "Read a compact semantic tail only when session_get status is insufficient. With no cursor, this returns the newest matching events and excludes raw message/reasoning/command/PTY deltas. Monitoring mode also omits a human/API user.message whose turn was never claimed (still queued, or deleted/edited/cancelled before any claim; waiting work is represented by sessions_list queuedPromptCount); the row appears at its own sequence once the turn is claimed. Use `latest` as an exclusive lookup for the authoritative newest durable sequence in exactly one semantic class; `receipt` is the concise alias for `tool_receipt`, and latest cannot be combined with type or class filters. Add `resultMode=compact` to latest for one bounded result-bearing completion/checkpoint/receipt without another inference. Use nextBefore to page older or explicit after/nextAfter to page forward. Type/class filters run in the RLS-scoped database query. payloadMode none|summary|full controls retained audit payload projection, but every model result is independently byte-capped with explicit truncation and exact covered sequence bounds. Exact retained forensic payloads, including unclaimed prompt rows, require mode=forensic or the access-controlled REST/SDK events API with mode=forensic&payloadMode=full; generic source bytes never retained by the audit boundary remain unavailable.",
3929
4086
  inputSchema: {
3930
4087
  sessionId: z4.string().uuid(),
3931
4088
  after: z4.number().int().nonnegative().optional(),
@@ -3998,6 +4155,7 @@ function registerWorkspaceOrchestrationTools(
3998
4155
  includeClasses: latestClass ? [latestClass] : (includeClasses ?? []),
3999
4156
  excludeClasses: excludeClasses ?? [],
4000
4157
  ...(mode === "monitoring" ? { defaultExcludeTypes: SESSION_EVENT_RAW_DELTA_TYPES } : {}),
4158
+ ...(mode === "monitoring" ? { excludeUnclaimedHumanPrompts: true } : {}),
4001
4159
  ...(latestClass ? { authoritativeLatest: true } : {}),
4002
4160
  maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
4003
4161
  });
@@ -4032,13 +4190,106 @@ function registerWorkspaceOrchestrationTools(
4032
4190
  );
4033
4191
  },
4034
4192
  );
4193
+
4194
+ server.registerTool(
4195
+ "session_wait",
4196
+ {
4197
+ description: `Block until a watched session has new durable events after your cursor, until your own session has pending machine input (a child result, an agent message, a steer), or until maxWaitSeconds (default ${SESSION_WAIT_DEFAULT_SECONDS}, max ${SESSION_WAIT_MAX_SECONDS}) elapses. Use this for short waits inside the current turn instead of sleeping and polling session_events/session_get/sessions_list while a child or peer session works; for long waits end this turn with goal_wait rather than looping session_wait for hours while holding the turn and sandbox. Pass each target's sessionId and afterSequence (its last seen sequence, 0 for a new session); returns immediately when anything already changed. Only turn lifecycle, agent.message.completed, blocking failures, goal facts, and session status/control changes count as a change; raw deltas, tool receipts, and sandbox diagnostics never wake it. Each changed target returns a bounded compact summary of up to ${SESSION_WAIT_EVENTS_PER_TARGET} exact durable events plus latestSequence (pass it back as the next afterSequence) and hasMore (drill down with session_events after=latestSequence). ownPendingUpdates > 0 means your own session has machine input that is delivered only when your next turn is claimed: finish this turn to receive it, or pass includeOwnPendingUpdates=false to keep waiting on the targets. timedOut=true means nothing changed; liveFanout=false means the live bus was unavailable and the wait relied on the deadline re-check. The whole result is byte-bounded: summaries are shortened first, then newest rows dropped, so a changed target may come back with events=[] and hasMore=true; read those rows with session_events after=latestSequence. The wait cannot exceed ${SESSION_WAIT_MAX_SECONDS} seconds because the MCP client request timeout is 60 seconds.`,
4198
+ inputSchema: {
4199
+ targets: z4
4200
+ .array(
4201
+ z4.object({
4202
+ sessionId: z4.string().uuid(),
4203
+ afterSequence: z4.number().int().nonnegative(),
4204
+ }),
4205
+ )
4206
+ .min(1)
4207
+ .max(SESSION_WAIT_MAX_TARGETS),
4208
+ includeOwnPendingUpdates: z4
4209
+ .boolean()
4210
+ .optional()
4211
+ .describe(
4212
+ "Also return when your own session has pending machine input (default true).",
4213
+ ),
4214
+ maxWaitSeconds: z4.number().int().min(1).max(SESSION_WAIT_MAX_SECONDS).optional(),
4215
+ },
4216
+ },
4217
+ async ({ targets, includeOwnPendingUpdates, maxWaitSeconds }, extra) => {
4218
+ const distinct = new Set(targets.map((target) => target.sessionId));
4219
+ if (distinct.size !== targets.length) {
4220
+ throw new Error("session_wait targets must name distinct sessions");
4221
+ }
4222
+ // Authorize and resolve every target exactly as session_events does,
4223
+ // before any live-fanout subscription exists for it.
4224
+ for (const target of targets) {
4225
+ await authorizeFirstPartySession(deps, grant, target.sessionId, "session.events.read");
4226
+ await requireSession(deps.db, grant.workspaceId, target.sessionId);
4227
+ }
4228
+ const ownSessionId = includeOwnPendingUpdates === false ? null : callerSessionId;
4229
+ // The API serves one transport per POST, so the worker's MCP cancel
4230
+ // notification never reaches this handler; the route binds the HTTP
4231
+ // request's abort to transport.close() (mcp/request-abort.ts), which
4232
+ // aborts `extra.signal` when the worker drops the call on Steer/Pause.
4233
+ // The deadline bounds the wait regardless. Direct handler invocation
4234
+ // (tests) may pass no extra at all.
4235
+ const signal: AbortSignal | undefined = extra?.signal;
4236
+ const workspaceId = grant.workspaceId;
4237
+ // A NATS subscription is live fanout only; the durable session_events
4238
+ // read below is the authority. Bun.serve idleTimeout (255 s) and the
4239
+ // 60 s MCP client request timeout both exceed the 50 s cap.
4240
+ return json(
4241
+ await waitForSessionChanges({
4242
+ targets,
4243
+ ownSessionId,
4244
+ maxWaitMs: (maxWaitSeconds ?? SESSION_WAIT_DEFAULT_SECONDS) * 1_000,
4245
+ signal,
4246
+ source: {
4247
+ reauthorizeTargets: async (sessionIds) => {
4248
+ for (const targetSessionId of sessionIds) {
4249
+ await authorizeFirstPartySession(
4250
+ deps,
4251
+ grant,
4252
+ targetSessionId,
4253
+ "session.events.read",
4254
+ );
4255
+ }
4256
+ },
4257
+ readTargetEvents: async (target) => {
4258
+ const page = await listSessionEventPage(deps.db, workspaceId, target.sessionId, {
4259
+ after: target.afterSequence,
4260
+ direction: "after",
4261
+ limit: SESSION_WAIT_EVENTS_PER_TARGET,
4262
+ payloadMode: "full",
4263
+ includeTypes: SESSION_WAIT_EVENT_TYPES,
4264
+ maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
4265
+ });
4266
+ return { events: page.events, hasMore: page.hasMore };
4267
+ },
4268
+ readOwnPendingUpdateKinds:
4269
+ ownSessionId === null
4270
+ ? null
4271
+ : async () =>
4272
+ (
4273
+ await listOutstandingSessionSystemUpdates(
4274
+ deps.db,
4275
+ workspaceId,
4276
+ ownSessionId,
4277
+ )
4278
+ ).map((update) => update.kind),
4279
+ subscribe: (targetSessionId, onEvents) =>
4280
+ deps.bus.subscribe(workspaceId, targetSessionId, onEvents),
4281
+ },
4282
+ }),
4283
+ );
4284
+ },
4285
+ );
4035
4286
  }
4036
4287
 
4037
4288
  if (can("sessions:create") && sessionCreateVisible) {
4038
4289
  const sessionCreateInput = z4
4039
4290
  .object({
4040
4291
  initialMessage: z4.string().min(1),
4041
- instructions: z4.string().min(1).max(32768).optional(),
4292
+ instructions: z4.string().min(1).max(SESSION_INSTRUCTIONS_MAX_CHARACTERS).optional(),
4042
4293
  goal: z4.unknown().optional(),
4043
4294
  resources: z4.array(z4.unknown()).optional(),
4044
4295
  tools: z4.array(z4.unknown()).optional(),
@@ -4099,7 +4350,7 @@ function registerWorkspaceOrchestrationTools(
4099
4350
  "session_create",
4100
4351
  {
4101
4352
  description:
4102
- "Spawn a new agent session (a worker). Omit sandbox for the safe default: compatible children share the creator's box, while a different Variable Set or Rig gets its own compatible box. Use 'new' for deliberate isolation or {groupId} for a strict compatible sibling join. Put targetSandboxId and its optional workingDir together inside machineTarget. 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.",
4353
+ "Spawn a new agent session (a worker). The child inherits this session's visibility; a private session can only create a same-owner private child. 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.",
4103
4354
  inputSchema: sessionCreateInput,
4104
4355
  },
4105
4356
  async (args) => {
@@ -4211,7 +4462,11 @@ function registerWorkspaceOrchestrationTools(
4211
4462
  },
4212
4463
  relatedResources: [
4213
4464
  { type: "session", id: targetSessionId },
4214
- { type: "session_event", id: accepted.id, state: accepted.type },
4465
+ {
4466
+ type: "session_event",
4467
+ id: accepted.id,
4468
+ state: accepted.type,
4469
+ },
4215
4470
  ],
4216
4471
  timestamp: accepted.occurredAt,
4217
4472
  idempotency: { status: replay ? "replayed" : "applied" },
@@ -4404,6 +4659,85 @@ function registerWorkspaceOrchestrationTools(
4404
4659
  );
4405
4660
  },
4406
4661
  );
4662
+
4663
+ server.registerTool(
4664
+ "session_human_input_respond",
4665
+ {
4666
+ description:
4667
+ "Answer (or skip, when the request allows it) a structured human-input request that another session is blocked on, typically a worker you spawned after a `child_requires_action` update named its requestId. Use it only when you actually know the answer; otherwise report the exact blocker to the user. Tool approvals are decided by humans only and cannot be answered here. The response is recorded as an agent-attempt answer and resumes that session's blocked turn.",
4668
+ inputSchema: {
4669
+ sessionId: z4.string().uuid(),
4670
+ requestId: z4.string().uuid(),
4671
+ response: z4.unknown(),
4672
+ idempotencyKey: z4.string().uuid(),
4673
+ },
4674
+ },
4675
+ async ({ sessionId, requestId, response, idempotencyKey }) => {
4676
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.human_input.write");
4677
+ const context = exactAgentCommandContext(grant, callerSessionId, "first_party_mcp");
4678
+ const parsedResponse = SubmitHumanInputResponseRequest.parse(response);
4679
+ let accepted: Awaited<ReturnType<typeof acceptSessionHumanInputResponse>>;
4680
+ try {
4681
+ accepted = await acceptSessionHumanInputResponse(deps.db, {
4682
+ accountId: grant.accountId,
4683
+ workspaceId: grant.workspaceId,
4684
+ sessionId,
4685
+ requestId,
4686
+ response: parsedResponse,
4687
+ respondedBy: `agent_attempt:${context.callerAttemptId}`,
4688
+ respondedByKind: "agent_attempt",
4689
+ clientEventId: idempotencyKey,
4690
+ });
4691
+ } catch (error) {
4692
+ if (error instanceof HumanInputResponseValidationError) {
4693
+ throw new Error(`human-input response rejected: ${error.message}`, { cause: error });
4694
+ }
4695
+ throw error;
4696
+ }
4697
+ if (accepted.action === "not_found") {
4698
+ throw new Error("human-input request not found");
4699
+ }
4700
+ await publishDurableSessionEvents(
4701
+ deps.bus,
4702
+ grant.workspaceId,
4703
+ sessionId,
4704
+ accepted.events,
4705
+ );
4706
+ if (accepted.workflowWakeRevision !== null) {
4707
+ await deps.workflowClient.signalApprovalDecision({
4708
+ accountId: grant.accountId,
4709
+ workspaceId: grant.workspaceId,
4710
+ sessionId,
4711
+ eventId: accepted.events[0]?.id ?? requestId,
4712
+ workflowId: `session-${sessionId}`,
4713
+ workflowWakeRevision: accepted.workflowWakeRevision,
4714
+ });
4715
+ }
4716
+ if (accepted.action === "conflict") {
4717
+ throw new Error(`human-input request is ${accepted.request.status}`);
4718
+ }
4719
+ return json(
4720
+ mcpMutationReceipt({
4721
+ operation: "session_human_input_respond",
4722
+ committed: true,
4723
+ outcome: accepted.events.length === 0 ? "replayed" : "updated",
4724
+ changed: accepted.events.length > 0,
4725
+ resource: {
4726
+ type: "session_human_input_request",
4727
+ id: requestId,
4728
+ state: accepted.request.status,
4729
+ },
4730
+ relatedResources: [{ type: "session", id: sessionId }],
4731
+ timestamp: accepted.request.respondedAt ?? new Date().toISOString(),
4732
+ idempotency: {
4733
+ status: accepted.events.length === 0 ? "replayed" : "applied",
4734
+ },
4735
+ facts: { outcome: accepted.request.response?.outcome ?? null },
4736
+ nextAction: { tool: "session_get", arguments: { sessionId } },
4737
+ }),
4738
+ );
4739
+ },
4740
+ );
4407
4741
  }
4408
4742
 
4409
4743
  server.registerTool(
@@ -4441,6 +4775,11 @@ function registerVariableSetTools(
4441
4775
  sessionId: string | null,
4442
4776
  json: JsonResult,
4443
4777
  ): void {
4778
+ const variableSetAccess = {
4779
+ accountId: grant.accountId,
4780
+ workspaceId: grant.workspaceId,
4781
+ subjectId: grant.subjectId,
4782
+ };
4444
4783
  const registerListTool = (name: string, description: string): void => {
4445
4784
  server.registerTool(
4446
4785
  name,
@@ -4449,7 +4788,7 @@ function registerVariableSetTools(
4449
4788
  inputSchema: {},
4450
4789
  },
4451
4790
  async () => {
4452
- const variableSets = await listVariableSets(deps.db, grant.workspaceId);
4791
+ const variableSets = await listVariableSets(deps.db, variableSetAccess);
4453
4792
  return json({ variableSets, environments: variableSets });
4454
4793
  },
4455
4794
  );
@@ -4491,14 +4830,14 @@ function registerVariableSetTools(
4491
4830
  let created = false;
4492
4831
  let variableSet =
4493
4832
  targetId !== undefined
4494
- ? await getVariableSet(deps.db, grant.workspaceId, targetId)
4495
- : await getVariableSetByName(deps.db, grant.workspaceId, trimmedVariableSetName!);
4833
+ ? await getVariableSet(deps.db, variableSetAccess, targetId)
4834
+ : await getVariableSetByName(deps.db, variableSetAccess, trimmedVariableSetName!);
4496
4835
  if (!variableSet && targetId !== undefined) {
4497
4836
  throw new Error("variable set/environment not found");
4498
4837
  }
4499
4838
  if (!variableSet) {
4500
4839
  if (
4501
- (await countVariableSets(deps.db, grant.workspaceId)) >= MAX_ENVIRONMENTS_PER_WORKSPACE
4840
+ (await countVariableSets(deps.db, variableSetAccess)) >= MAX_ENVIRONMENTS_PER_WORKSPACE
4502
4841
  ) {
4503
4842
  throw new Error(
4504
4843
  `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} variable sets`,
@@ -4507,6 +4846,8 @@ function registerVariableSetTools(
4507
4846
  variableSet = await createVariableSet(deps.db, {
4508
4847
  accountId: grant.accountId,
4509
4848
  workspaceId: grant.workspaceId,
4849
+ scope: "workspace",
4850
+ subjectId: grant.subjectId,
4510
4851
  name: trimmedVariableSetName!,
4511
4852
  });
4512
4853
  created = true;
@@ -4525,6 +4866,7 @@ function registerVariableSetTools(
4525
4866
  const metadata = await setVariableSetVariable(deps.db, {
4526
4867
  accountId: grant.accountId,
4527
4868
  workspaceId: grant.workspaceId,
4869
+ subjectId: grant.subjectId,
4528
4870
  variableSetId: variableSet.id,
4529
4871
  name: parsedName.data,
4530
4872
  valueEncrypted: encryptVariableSetValue(key, value),
@@ -4982,7 +5324,8 @@ function registerGitHubConnectTool(
4982
5324
  }
4983
5325
 
4984
5326
  // Defense-in-depth for invariant "agents cannot self-attach": the worker's
4985
- // first-party delegated token never carries variable-sets:use, so sandboxed
5327
+ // first-party delegated token must carry both exact attachment and use
5328
+ // permissions, so a token narrowed to only one cannot attach a variable set.
4986
5329
  // agents calling these MCP tools cannot attach a variable set.
4987
5330
  // Explicit detach (variableSetId: null) is also an attachment change and is
4988
5331
  // blocked the same way.
@@ -4990,12 +5333,23 @@ function requireVariableSetsUseForMcpAttachment(
4990
5333
  grant: AccessGrant,
4991
5334
  variableSetId: string | null | undefined,
4992
5335
  ): void {
4993
- if (variableSetId !== undefined && !hasPermission(grant.permissions, "variable-sets:use")) {
5336
+ if (variableSetId === undefined) return;
5337
+ if (!hasPermission(grant.permissions, "variable-sets:attach")) {
5338
+ throw new Error("missing permission: variable-sets:attach");
5339
+ }
5340
+ if (variableSetId !== null && !hasPermission(grant.permissions, "variable-sets:use")) {
4994
5341
  throw new Error("missing permission: variable-sets:use");
4995
5342
  }
4996
5343
  }
4997
5344
 
4998
- function repositoryWithScheduledTaskResource(
5345
+ /**
5346
+ * Project one allowlisted GitHub App repository into the resource an agent or
5347
+ * scheduled task attaches. Every listed repository is in the workspace
5348
+ * allowlist, public or private, so every resource carries the stable ids
5349
+ * that mint the scoped installation token; a bare URI would clone anonymously
5350
+ * and could never push.
5351
+ */
5352
+ export function repositoryWithScheduledTaskResource(
4999
5353
  repository: GitHubRepository,
5000
5354
  ): GitHubRepository & { resource: ResourceRef } {
5001
5355
  const uri = normalizedRepositoryUri(repository.cloneUrl);
@@ -5007,12 +5361,8 @@ function repositoryWithScheduledTaskResource(
5007
5361
  ref: repository.defaultBranch,
5008
5362
  provider: "github",
5009
5363
  mountPath: defaultRepositoryMountPath(uri, "github"),
5010
- ...(repository.private
5011
- ? {
5012
- githubInstallationId: repository.installationId,
5013
- githubRepositoryId: repository.id,
5014
- }
5015
- : {}),
5364
+ githubInstallationId: repository.installationId,
5365
+ githubRepositoryId: repository.id,
5016
5366
  },
5017
5367
  };
5018
5368
  }