@aitne/shared 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/dist/advisor-models.d.ts +34 -0
  3. package/dist/advisor-models.d.ts.map +1 -0
  4. package/dist/advisor-models.js +39 -0
  5. package/dist/advisor-models.js.map +1 -0
  6. package/dist/agent-identity.d.ts +11 -0
  7. package/dist/agent-identity.d.ts.map +1 -0
  8. package/dist/agent-identity.js +29 -0
  9. package/dist/agent-identity.js.map +1 -0
  10. package/dist/alerts.d.ts +44 -0
  11. package/dist/alerts.d.ts.map +1 -0
  12. package/dist/alerts.js +12 -0
  13. package/dist/alerts.js.map +1 -0
  14. package/dist/backend-api-key-config.d.ts +337 -0
  15. package/dist/backend-api-key-config.d.ts.map +1 -0
  16. package/dist/backend-api-key-config.js +682 -0
  17. package/dist/backend-api-key-config.js.map +1 -0
  18. package/dist/backend.d.ts +93 -0
  19. package/dist/backend.d.ts.map +1 -0
  20. package/dist/backend.js +22 -0
  21. package/dist/backend.js.map +1 -0
  22. package/dist/branding.d.ts +96 -0
  23. package/dist/branding.d.ts.map +1 -0
  24. package/dist/branding.js +102 -0
  25. package/dist/branding.js.map +1 -0
  26. package/dist/chat-session-scope.d.ts +14 -0
  27. package/dist/chat-session-scope.d.ts.map +1 -0
  28. package/dist/chat-session-scope.js +18 -0
  29. package/dist/chat-session-scope.js.map +1 -0
  30. package/dist/date-utils.d.ts +80 -0
  31. package/dist/date-utils.d.ts.map +1 -0
  32. package/dist/date-utils.js +187 -0
  33. package/dist/date-utils.js.map +1 -0
  34. package/dist/docs-frontmatter.d.ts +51 -0
  35. package/dist/docs-frontmatter.d.ts.map +1 -0
  36. package/dist/docs-frontmatter.js +184 -0
  37. package/dist/docs-frontmatter.js.map +1 -0
  38. package/dist/docs-schema.d.ts +79 -0
  39. package/dist/docs-schema.d.ts.map +1 -0
  40. package/dist/docs-schema.js +135 -0
  41. package/dist/docs-schema.js.map +1 -0
  42. package/dist/editable-config-keys.d.ts +14 -0
  43. package/dist/editable-config-keys.d.ts.map +1 -0
  44. package/dist/editable-config-keys.js +157 -0
  45. package/dist/editable-config-keys.js.map +1 -0
  46. package/dist/exec-with-stdin.d.ts +14 -0
  47. package/dist/exec-with-stdin.d.ts.map +1 -0
  48. package/dist/exec-with-stdin.js +35 -0
  49. package/dist/exec-with-stdin.js.map +1 -0
  50. package/dist/index.d.ts +37 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +49 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/integrations-snapshot.d.ts +183 -0
  55. package/dist/integrations-snapshot.d.ts.map +1 -0
  56. package/dist/integrations-snapshot.js +757 -0
  57. package/dist/integrations-snapshot.js.map +1 -0
  58. package/dist/integrations.d.ts +675 -0
  59. package/dist/integrations.d.ts.map +1 -0
  60. package/dist/integrations.js +1656 -0
  61. package/dist/integrations.js.map +1 -0
  62. package/dist/keychain-helper-client.d.ts +31 -0
  63. package/dist/keychain-helper-client.d.ts.map +1 -0
  64. package/dist/keychain-helper-client.js +105 -0
  65. package/dist/keychain-helper-client.js.map +1 -0
  66. package/dist/log-entry.d.ts +14 -0
  67. package/dist/log-entry.d.ts.map +1 -0
  68. package/dist/log-entry.js +2 -0
  69. package/dist/log-entry.js.map +1 -0
  70. package/dist/management-domains.d.ts +369 -0
  71. package/dist/management-domains.d.ts.map +1 -0
  72. package/dist/management-domains.js +499 -0
  73. package/dist/management-domains.js.map +1 -0
  74. package/dist/process-key.d.ts +67 -0
  75. package/dist/process-key.d.ts.map +1 -0
  76. package/dist/process-key.js +366 -0
  77. package/dist/process-key.js.map +1 -0
  78. package/dist/schemas.d.ts +267 -0
  79. package/dist/schemas.d.ts.map +1 -0
  80. package/dist/schemas.js +271 -0
  81. package/dist/schemas.js.map +1 -0
  82. package/dist/secret-client-factory.d.ts +16 -0
  83. package/dist/secret-client-factory.d.ts.map +1 -0
  84. package/dist/secret-client-factory.js +111 -0
  85. package/dist/secret-client-factory.js.map +1 -0
  86. package/dist/secret-client-file.d.ts +51 -0
  87. package/dist/secret-client-file.d.ts.map +1 -0
  88. package/dist/secret-client-file.js +160 -0
  89. package/dist/secret-client-file.js.map +1 -0
  90. package/dist/secret-client-linux.d.ts +26 -0
  91. package/dist/secret-client-linux.d.ts.map +1 -0
  92. package/dist/secret-client-linux.js +63 -0
  93. package/dist/secret-client-linux.js.map +1 -0
  94. package/dist/secret-client-windows.d.ts +37 -0
  95. package/dist/secret-client-windows.d.ts.map +1 -0
  96. package/dist/secret-client-windows.js +82 -0
  97. package/dist/secret-client-windows.js.map +1 -0
  98. package/dist/secret-redaction.d.ts +3 -0
  99. package/dist/secret-redaction.d.ts.map +1 -0
  100. package/dist/secret-redaction.js +31 -0
  101. package/dist/secret-redaction.js.map +1 -0
  102. package/dist/skill-curation/decision-language.d.ts +6 -0
  103. package/dist/skill-curation/decision-language.d.ts.map +1 -0
  104. package/dist/skill-curation/decision-language.js +38 -0
  105. package/dist/skill-curation/decision-language.js.map +1 -0
  106. package/dist/skill-curation/schemas.d.ts +461 -0
  107. package/dist/skill-curation/schemas.d.ts.map +1 -0
  108. package/dist/skill-curation/schemas.js +211 -0
  109. package/dist/skill-curation/schemas.js.map +1 -0
  110. package/dist/types.d.ts +204 -0
  111. package/dist/types.d.ts.map +1 -0
  112. package/dist/types.js +54 -0
  113. package/dist/types.js.map +1 -0
  114. package/package.json +50 -0
@@ -0,0 +1,1656 @@
1
+ import { z } from "zod";
2
+ import { BACKEND_IDS } from "./backend.js";
3
+ /**
4
+ * Integration Delegation Framework — shared types (Phase 1–3).
5
+ *
6
+ * See `GOOGLE_AUTH_DELEGATION_DESIGN.md` v3. Phase 1 ships the registry +
7
+ * per-integration mode config with Gmail + Calendar as the first two keys.
8
+ * Phase 3 adds skill / task-flow variant selection helpers consumed by
9
+ * SkillsCompiler and prompts.ts. Git lifecycle Phase 4 retroactively registers
10
+ * the local Git / GitHub observers under the same mode framework.
11
+ */
12
+ /**
13
+ * Mode-aware integrations — the ones that can flip between `direct` and
14
+ * `delegated` and route through the per-backend probe / connector / skill
15
+ * filter framework. Other surfaces (lifestyle services like
16
+ * receipts/books/travel-bookings, Obsidian) integrate via dedicated routes
17
+ * and observers without participating in this registry.
18
+ */
19
+ export const INTEGRATION_KEYS = [
20
+ "gmail",
21
+ "google_calendar",
22
+ "notion",
23
+ "git",
24
+ "github",
25
+ "outlook_mail",
26
+ "outlook_calendar",
27
+ ];
28
+ const integrationKeySet = new Set(INTEGRATION_KEYS);
29
+ export function isIntegrationKey(value) {
30
+ return integrationKeySet.has(value);
31
+ }
32
+ export const INTEGRATION_MODES = ["direct", "delegated", "disabled"];
33
+ const integrationModeSet = new Set(INTEGRATION_MODES);
34
+ export function isIntegrationMode(value) {
35
+ return integrationModeSet.has(value);
36
+ }
37
+ function readOnlyCliConnector(toolNamespace) {
38
+ return {
39
+ // Git/GitHub delegation runs through the selected backend's shell/CLI
40
+ // context (`git` / `gh`) rather than a hosted MCP connector. Keeping the
41
+ // capability lists empty makes DelegatedProbeObserver a liveness/audit
42
+ // heartbeat for these integrations without fabricating MCP tools the
43
+ // backend cannot actually list.
44
+ toolNamespace,
45
+ requiredCapabilities: [],
46
+ optionalCapabilities: [],
47
+ capabilityTools: {},
48
+ destructiveTools: [],
49
+ };
50
+ }
51
+ /**
52
+ * The registry. Single source of truth for which integrations exist, what
53
+ * backends can delegate for them, and which parts of the daemon they touch.
54
+ *
55
+ * `backendConnectors` is a `Partial` record — omitting a backend means
56
+ * delegation through that backend is unsupported.
57
+ *
58
+ * Gemini namespace convention differs from Claude / Codex. Gemini CLI's
59
+ * MCP_TOOL_PREFIX is `mcp_` (single underscore) and the per-server prefix
60
+ * is `mcp_<serverName>_`, so a tool registered as `gmail.search` on the
61
+ * `google-workspace` extension surfaces as `mcp_google-workspace_gmail.search`.
62
+ * Hyphens and dots in the registered name are preserved. The tool-name
63
+ * format was confirmed by stream-event probe (2026-04-26 — see
64
+ * `gemini -p ... --output-format stream-json` `tool_use` events) and is
65
+ * the source of truth for `toolNamespace` and `capabilityTools` below.
66
+ *
67
+ * Gemini connectors require host-side MCP setup the daemon does not
68
+ * manage:
69
+ * - Gmail + Calendar: `~/.gemini/extensions/google-workspace/`
70
+ * (install via `gemini extensions install <url>`).
71
+ * - Notion: register Notion's official MCP server under the server name
72
+ * `notion` (e.g. `gemini mcp add notion <url>`); changing the server
73
+ * name breaks the namespace assumption — see Gemini Notion descriptor
74
+ * notes below.
75
+ */
76
+ export const INTEGRATION_DESCRIPTORS = {
77
+ gmail: {
78
+ key: "gmail",
79
+ displayName: "Gmail",
80
+ supportedModes: ["direct", "delegated", "disabled"],
81
+ // DELEGATED-MODE-V2-DESIGN.md §3.4 / §5.1 — delegated-mode calls flow
82
+ // through the generic `POST /api/integrations/gmail/invoke` chokepoint.
83
+ // The legacy per-route `routeMap` proxy was removed in Phase 3.5; the
84
+ // per-mode skill variant model (`SKILL.delegated.<sessionBackend>.md`)
85
+ // restored in Phase 3 covers cross-backend prose, and same-backend
86
+ // sessions use native MCP without a skill body.
87
+ directSetup: {
88
+ credentialKeys: ["googleCredentialsJson", "googleTokenJson"],
89
+ // Google Workspace's end-to-end OAuth setup walkthrough — covers project
90
+ // creation, API enablement, consent screen, and OAuth client credentials.
91
+ // The setup wizard's `GCP_LINKS` deep-links each step; this URL is the
92
+ // overview / fallback for users who want the official prose.
93
+ helpUrl: "https://developers.google.com/workspace/guides/create-credentials",
94
+ },
95
+ backendConnectors: {
96
+ claude: {
97
+ toolNamespace: "mcp__claude_ai_Gmail__",
98
+ // Claude's hosted Gmail connector is draft-only. Send/forward/delete/
99
+ // attachment capabilities are deliberately absent — dashboard + skill
100
+ // variants branch on this gap.
101
+ requiredCapabilities: ["search", "read", "draft", "label"],
102
+ optionalCapabilities: ["draft", "label", "create_label"],
103
+ capabilityTools: {
104
+ search: ["search_threads"],
105
+ read: ["get_thread"],
106
+ draft: ["create_draft", "list_drafts"],
107
+ label: ["label_message", "label_thread", "unlabel_message", "unlabel_thread", "list_labels"],
108
+ create_label: ["create_label"],
109
+ },
110
+ // Claude's hosted Gmail connector exposes no send/delete/forward —
111
+ // label mutations are the only state-changing tools. The four
112
+ // label_* tools mutate thread/message labels; `list_labels` is a
113
+ // read and is intentionally excluded. `create_label` modifies the
114
+ // user's label taxonomy and is included.
115
+ destructiveTools: [
116
+ "label_message",
117
+ "label_thread",
118
+ "unlabel_message",
119
+ "unlabel_thread",
120
+ "create_label",
121
+ ],
122
+ },
123
+ codex: {
124
+ toolNamespace: "mcp__codex_apps__gmail._",
125
+ requiredCapabilities: ["search", "read", "draft", "label", "send"],
126
+ optionalCapabilities: [
127
+ "draft",
128
+ "label",
129
+ "create_label",
130
+ "update_draft",
131
+ "send",
132
+ "forward",
133
+ "delete",
134
+ "read_attachment",
135
+ "batch",
136
+ ],
137
+ // The Codex namespace already terminates with `._`, so capability
138
+ // tool names are the rest of the symbol (no leading underscore).
139
+ // E.g. `mcp__codex_apps__gmail._` + `search_emails`
140
+ // = `mcp__codex_apps__gmail._search_emails` (the actual tool).
141
+ capabilityTools: {
142
+ search: ["search_emails", "search_email_ids"],
143
+ read: ["read_email", "read_email_thread"],
144
+ draft: ["create_draft", "list_drafts"],
145
+ update_draft: ["update_draft"],
146
+ send: ["send_email", "send_draft"],
147
+ forward: ["forward_emails"],
148
+ label: ["apply_labels_to_emails", "list_labels", "bulk_label_matching_emails"],
149
+ create_label: ["create_label"],
150
+ delete: ["delete_emails", "archive_emails"],
151
+ read_attachment: ["read_attachment"],
152
+ batch: ["batch_modify_email", "batch_read_email", "batch_read_email_threads"],
153
+ },
154
+ // Send/delete/forward + label mutations + create_label. `list_labels`,
155
+ // `read_*`, `search_*`, `read_attachment`, and the read-side batch
156
+ // tools (`batch_read_*`) are read-only and intentionally excluded.
157
+ // `update_draft` mutates a not-yet-sent draft and is reversible (the
158
+ // user can edit again before sending) — listed as write-class in
159
+ // §7.4, NOT destructive.
160
+ destructiveTools: [
161
+ "send_email",
162
+ "send_draft",
163
+ "forward_emails",
164
+ "delete_emails",
165
+ "archive_emails",
166
+ "apply_labels_to_emails",
167
+ "bulk_label_matching_emails",
168
+ "create_label",
169
+ "batch_modify_email",
170
+ ],
171
+ },
172
+ gemini: {
173
+ // Gemini CLI's `google-workspace` extension exposes Gmail tools
174
+ // under the `gmail.*` registration. Combined with Gemini's MCP
175
+ // namespace convention (`mcp_<server>_<tool>`, single underscore),
176
+ // a search call surfaces as `mcp_google-workspace_gmail.search` in
177
+ // `tool_use` stream events. `capabilityTools` entries below are
178
+ // the bare suffix (the part after `gmail.`), matching how Codex's
179
+ // namespace also terminates with a separator.
180
+ toolNamespace: "mcp_google-workspace_gmail.",
181
+ // Required capabilities mirror Codex's full-auto floor; the
182
+ // google-workspace extension covers all of search/read/draft/
183
+ // label/send.
184
+ requiredCapabilities: ["search", "read", "draft", "label", "send"],
185
+ // `delete` and `forward` are not surfaced as optional capabilities:
186
+ // the google-workspace extension has no dedicated tool for either
187
+ // (delete = `modify` + add TRASH label; forward = `send` with
188
+ // re-quoted body). Listing them would imply parity the connector
189
+ // doesn't have. Agents that need either compose them from the
190
+ // primitives.
191
+ optionalCapabilities: [
192
+ "draft",
193
+ "label",
194
+ "create_label",
195
+ "send",
196
+ "read_attachment",
197
+ "batch",
198
+ ],
199
+ capabilityTools: {
200
+ search: ["search"],
201
+ read: ["get"],
202
+ draft: ["createDraft"],
203
+ // sendDraft = dispatch a previously-created draft. send =
204
+ // compose-and-send in one call (the irreversible path; default-
205
+ // denied via RECOMMENDED_STARTER_DENIED_TOOLS).
206
+ send: ["send", "sendDraft"],
207
+ // `modify` / `modifyThread` apply or remove labels (including
208
+ // the system TRASH label). `listLabels` is a read; included
209
+ // here for the same reason Codex includes `list_labels` —
210
+ // labelling typically requires enumerating existing labels first.
211
+ label: ["modify", "modifyThread", "listLabels"],
212
+ create_label: ["createLabel"],
213
+ read_attachment: ["downloadAttachment"],
214
+ batch: ["batchModify"],
215
+ },
216
+ // `send`/`sendDraft` (irreversible dispatch), `modify`/`modifyThread`
217
+ // (mutate labels including TRASH), `createLabel` (taxonomy edit), and
218
+ // `batchModify` (mass mutation). `listLabels`, `search`, `get`,
219
+ // `downloadAttachment`, and `createDraft` (reversible — the user
220
+ // can edit before sending) stay write-class only. This list is the
221
+ // tool-name version of the design's `destructiveCapabilities` set
222
+ // for the Gemini connector, with the read-mixed `label` capability
223
+ // split apart to keep `listLabels` allowed.
224
+ destructiveTools: [
225
+ "send",
226
+ "sendDraft",
227
+ "modify",
228
+ "modifyThread",
229
+ "createLabel",
230
+ "batchModify",
231
+ ],
232
+ // The google-workspace extension authenticates the user's signed-in
233
+ // Google account on first tool call (no separate subscription).
234
+ },
235
+ },
236
+ // DELEGATED-MODE-V2-DESIGN.md §3.4 / §5.1 — restored in Phase 3 so
237
+ // `selectSkillVariantFile` engages on the `mail` skill.
238
+ // `SKILL.delegated.<sessionBackend>.md` is materialized for
239
+ // cross-backend pairs; same-backend resolves to `null` (native MCP,
240
+ // no skill body).
241
+ skillsTouched: ["mail"],
242
+ // `routine.hourly_check` retains a delegated variant: when Gmail is
243
+ // delegated, MailPoller's per-account filter (mail-poller.ts:173-181)
244
+ // stops Gmail-account polling, so `mail:lifecycle` observations
245
+ // disappear. The variant's Step 0a fetches the equivalent window via
246
+ // the connector. Listing the task-flow here is what triggers
247
+ // `selectTaskFlowVariantSuffix` to pick the variant.
248
+ taskFlowsTouched: ["routine.hourly_check"],
249
+ observersTouched: [],
250
+ // Multi-provider routes (`/api/mail/*`) are intentionally not gated
251
+ // here — prefix matching would also block iCloud / Outlook / IMAP
252
+ // accounts. Per-account 410 inside the mail handler covers Gmail
253
+ // accounts when delegated (DELEGATED-MODE-V2-DESIGN.md §6.3
254
+ // defense-in-depth).
255
+ apiRoutesTouched: [],
256
+ deniedToolsAppliesToSkills: ["mail"],
257
+ },
258
+ google_calendar: {
259
+ key: "google_calendar",
260
+ displayName: "Google Calendar",
261
+ supportedModes: ["direct", "delegated", "disabled"],
262
+ // DELEGATED-MODE-V2-DESIGN.md §3.4 / §5.1 — delegated-mode calls flow
263
+ // through `POST /api/integrations/google_calendar/invoke`. Per-mode
264
+ // skill variants restored in Phase 3.
265
+ directSetup: {
266
+ credentialKeys: ["googleCredentialsJson", "googleTokenJson"],
267
+ // Same OAuth flow as Gmail — Google Workspace's official setup guide.
268
+ helpUrl: "https://developers.google.com/workspace/guides/create-credentials",
269
+ },
270
+ backendConnectors: {
271
+ claude: {
272
+ toolNamespace: "mcp__claude_ai_Google_Calendar__",
273
+ requiredCapabilities: ["list_events", "get_event", "create_event"],
274
+ optionalCapabilities: [
275
+ "list_events",
276
+ "get_event",
277
+ "create_event",
278
+ "update_event",
279
+ "delete_event",
280
+ "respond_to_event",
281
+ "suggest_time",
282
+ "list_calendars",
283
+ ],
284
+ capabilityTools: {
285
+ list_events: ["list_events"],
286
+ get_event: ["get_event"],
287
+ create_event: ["create_event"],
288
+ update_event: ["update_event"],
289
+ delete_event: ["delete_event"],
290
+ respond_to_event: ["respond_to_event"],
291
+ suggest_time: ["suggest_time"],
292
+ list_calendars: ["list_calendars"],
293
+ },
294
+ // Mutating calendar ops. `respond_to_event` is destructive because
295
+ // the response is fired off to the organizer; `suggest_time` is
296
+ // pure compute with no calendar side effects.
297
+ destructiveTools: [
298
+ "create_event",
299
+ "update_event",
300
+ "delete_event",
301
+ "respond_to_event",
302
+ ],
303
+ },
304
+ codex: {
305
+ toolNamespace: "mcp__codex_apps__google_calendar._",
306
+ requiredCapabilities: ["search", "read", "create_event"],
307
+ optionalCapabilities: [
308
+ "search",
309
+ "read",
310
+ "create_event",
311
+ "update_event",
312
+ "delete_event",
313
+ "respond_event",
314
+ "get_availability",
315
+ "batch_read",
316
+ ],
317
+ capabilityTools: {
318
+ search: ["search", "search_events"],
319
+ read: ["read_event", "fetch"],
320
+ create_event: ["create_event"],
321
+ update_event: ["update_event"],
322
+ delete_event: ["delete_event"],
323
+ respond_event: ["respond_event"],
324
+ get_availability: ["get_availability"],
325
+ batch_read: ["batch_read_event"],
326
+ },
327
+ destructiveTools: [
328
+ "create_event",
329
+ "update_event",
330
+ "delete_event",
331
+ "respond_event",
332
+ ],
333
+ },
334
+ gemini: {
335
+ // google-workspace extension's Calendar tools: registered as
336
+ // `calendar.*`, surfaced as `mcp_google-workspace_calendar.*`.
337
+ toolNamespace: "mcp_google-workspace_calendar.",
338
+ requiredCapabilities: ["list_events", "get_event", "create_event"],
339
+ optionalCapabilities: [
340
+ "list_events",
341
+ "get_event",
342
+ "create_event",
343
+ "update_event",
344
+ "delete_event",
345
+ "respond_to_event",
346
+ "find_free_time",
347
+ "list_calendars",
348
+ ],
349
+ capabilityTools: {
350
+ list_events: ["listEvents"],
351
+ get_event: ["getEvent"],
352
+ create_event: ["createEvent"],
353
+ update_event: ["updateEvent"],
354
+ delete_event: ["deleteEvent"],
355
+ respond_to_event: ["respondToEvent"],
356
+ find_free_time: ["findFreeTime"],
357
+ list_calendars: ["list"],
358
+ },
359
+ destructiveTools: [
360
+ "createEvent",
361
+ "updateEvent",
362
+ "deleteEvent",
363
+ "respondToEvent",
364
+ ],
365
+ },
366
+ },
367
+ // DELEGATED-MODE-V2-DESIGN.md §3.4 / §5.1 — restored in Phase 3 so
368
+ // `selectSkillVariantFile` engages on the `external-services` skill.
369
+ skillsTouched: ["external-services"],
370
+ // `routine.hourly_check` retains a delegated variant: when Calendar is
371
+ // delegated, the CalendarPoller stops (see `observersTouched` below)
372
+ // so `calendar:*` observations and `schedule.approaching` events are
373
+ // lost. The variant's Step 0b restores both via two connector fetches
374
+ // (imminent-window + 24h change-detection).
375
+ taskFlowsTouched: ["routine.hourly_check"],
376
+ // CalendarPoller still stops on a delegated flip — it polls via direct
377
+ // OAuth credentials that the user has not necessarily set when running
378
+ // delegated. The variant compensates for the observation surface.
379
+ observersTouched: ["calendar"],
380
+ // Single-provider surface — the `/api/calendar` prefix can be 410-gated
381
+ // safely (DELEGATED-MODE-V2-DESIGN.md §6.3 defense-in-depth). Cross-
382
+ // backend skill prose directs the agent at the invoke endpoint, but
383
+ // hallucinated `/api/calendar/*` calls now interdict at the gate.
384
+ apiRoutesTouched: ["/api/calendar"],
385
+ deniedToolsAppliesToSkills: ["external-services"],
386
+ },
387
+ notion: {
388
+ key: "notion",
389
+ displayName: "Notion",
390
+ supportedModes: ["direct", "delegated", "disabled"],
391
+ directSetup: {
392
+ credentialKeys: ["notionApiKey"],
393
+ // Notion's official guide for creating an internal integration and
394
+ // obtaining the API key required by direct mode.
395
+ helpUrl: "https://developers.notion.com/docs/create-a-notion-integration",
396
+ },
397
+ backendConnectors: {
398
+ claude: {
399
+ toolNamespace: "mcp__claude_ai_Notion__",
400
+ // Minimum set that makes delegated mode worth the user's time:
401
+ // search + read + create + property update + content patch +
402
+ // archive. Schema-admin, comments, and the rest are bonuses —
403
+ // declared in optionalCapabilities. The framework's tool-deny
404
+ // policy (NOTION_DELEGATION_DESIGN.md §7.7) lets the user carve
405
+ // those out per-tool from the dashboard.
406
+ //
407
+ // Archive caveat (v0.4): `notion-update-page` does NOT trash
408
+ // pages — `in_trash` is rejected as a property and there is no
409
+ // dedicated trash tool. The `archive` capability stays in the
410
+ // required set because the property-update workaround
411
+ // (Status="Archived" + status-property write) does route through
412
+ // `notion-update-page`, but the skill body labels it as
413
+ // workaround-only. See NOTION_DELEGATION_DESIGN.md §3 + §7.4.
414
+ requiredCapabilities: [
415
+ "search",
416
+ "read",
417
+ "create_page",
418
+ "update_properties",
419
+ "patch_content",
420
+ "archive",
421
+ ],
422
+ optionalCapabilities: [
423
+ "search",
424
+ "read",
425
+ "create_page",
426
+ "update_properties",
427
+ "patch_content",
428
+ "replace_content",
429
+ "archive",
430
+ "comments",
431
+ "duplicate_page",
432
+ "move_page",
433
+ "apply_template",
434
+ "schema_admin",
435
+ "users",
436
+ "teams",
437
+ ],
438
+ // Several capabilities (`update_properties`, `patch_content`,
439
+ // `replace_content`, `archive`, `apply_template`) all map to the
440
+ // single `notion-update-page` tool. The probe is tool-name based,
441
+ // so they flip to "true" together — accurate at the tool level
442
+ // even though it doesn't verify each sub-command. Per
443
+ // NOTION_DELEGATION_DESIGN.md §5 (capability-tool overlap note).
444
+ capabilityTools: {
445
+ search: ["notion-search"],
446
+ read: ["notion-fetch"],
447
+ create_page: ["notion-create-pages"],
448
+ update_properties: ["notion-update-page"],
449
+ patch_content: ["notion-update-page"],
450
+ replace_content: ["notion-update-page"],
451
+ archive: ["notion-update-page"],
452
+ comments: ["notion-create-comment", "notion-get-comments"],
453
+ duplicate_page: ["notion-duplicate-page"],
454
+ move_page: ["notion-move-pages"],
455
+ apply_template: ["notion-update-page"],
456
+ schema_admin: [
457
+ "notion-create-database",
458
+ "notion-update-data-source",
459
+ "notion-create-view",
460
+ "notion-update-view",
461
+ ],
462
+ users: ["notion-get-users"],
463
+ teams: ["notion-get-teams"],
464
+ },
465
+ // Page mutations + comment writes + schema admin. The read-only
466
+ // tools (`notion-search`, `notion-fetch`, `notion-get-comments`,
467
+ // `notion-get-users`, `notion-get-teams`) are intentionally
468
+ // excluded. `notion-update-page` covers update_properties,
469
+ // patch_content, replace_content, apply_template, AND archive
470
+ // (the property-update workaround for trash) — listing it once
471
+ // here gates all five.
472
+ destructiveTools: [
473
+ "notion-create-pages",
474
+ "notion-update-page",
475
+ "notion-duplicate-page",
476
+ "notion-move-pages",
477
+ "notion-create-comment",
478
+ "notion-create-database",
479
+ "notion-update-data-source",
480
+ "notion-create-view",
481
+ "notion-update-view",
482
+ ],
483
+ },
484
+ codex: {
485
+ toolNamespace: "mcp__codex_apps__notion._",
486
+ // Same required set as Claude — these are framework requirements
487
+ // (search/read/create/property update/content patch/archive) that
488
+ // make delegated mode worth the user's time. Codex carries strict
489
+ // parity on each: `_search` and `_fetch` for read, `_notion_create_pages`,
490
+ // and `_notion_update_page` (same 5 commands as Claude:
491
+ // update_properties, update_content, replace_content,
492
+ // apply_template, update_verification). The page-archive gap is
493
+ // identical to Claude — no `in_trash` on pages — so the skill
494
+ // body uses the same property-update / move-to-trash workarounds.
495
+ // The `archive` capability stays in `requiredCapabilities` for
496
+ // the same reason as the Claude block above (workaround routes
497
+ // through `_notion_update_page`). NOTION_DELEGATION_DESIGN.md
498
+ // §3 + §7.4.
499
+ requiredCapabilities: [
500
+ "search",
501
+ "read",
502
+ "create_page",
503
+ "update_properties",
504
+ "patch_content",
505
+ "archive",
506
+ ],
507
+ // Codex exposes two capabilities Claude's connector lacks:
508
+ // - `query_data_sources` — `_notion_query_data_sources` runs
509
+ // SQLite queries against a data source, including parameterized
510
+ // structured-property filters (`WHERE Status = ? AND Priority = ?`).
511
+ // This natively closes the gap §3.2 / Q2 accepted for Claude
512
+ // delegation; the delegated skill body documents the SQL pattern.
513
+ // - `query_meeting_notes` — `_notion_query_meeting_notes` runs a
514
+ // structured filter against the user's meeting-notes data source.
515
+ // Niche but free, surfaced as a capability.
516
+ optionalCapabilities: [
517
+ "search",
518
+ "read",
519
+ "create_page",
520
+ "update_properties",
521
+ "patch_content",
522
+ "replace_content",
523
+ "archive",
524
+ "comments",
525
+ "duplicate_page",
526
+ "move_page",
527
+ "apply_template",
528
+ "schema_admin",
529
+ "users",
530
+ "teams",
531
+ "query_data_sources",
532
+ "query_meeting_notes",
533
+ ],
534
+ // Tool names are unsuffixed (the Codex namespace already terminates
535
+ // with `._`, so `mcp__codex_apps__notion._` + `notion_create_pages`
536
+ // = `mcp__codex_apps__notion._notion_create_pages`). Note `_search`
537
+ // and `_fetch` are bare — they're not Notion-prefixed in Codex's
538
+ // namespace, only the rest are.
539
+ capabilityTools: {
540
+ search: ["search"],
541
+ read: ["fetch"],
542
+ create_page: ["notion_create_pages"],
543
+ update_properties: ["notion_update_page"],
544
+ patch_content: ["notion_update_page"],
545
+ replace_content: ["notion_update_page"],
546
+ archive: ["notion_update_page"],
547
+ comments: ["notion_create_comment", "notion_get_comments"],
548
+ duplicate_page: ["notion_duplicate_page"],
549
+ move_page: ["notion_move_pages"],
550
+ apply_template: ["notion_update_page"],
551
+ schema_admin: [
552
+ "notion_create_database",
553
+ "notion_update_data_source",
554
+ "notion_create_view",
555
+ "notion_update_view",
556
+ ],
557
+ users: ["notion_get_users"],
558
+ teams: ["notion_get_teams"],
559
+ query_data_sources: ["notion_query_data_sources"],
560
+ query_meeting_notes: ["notion_query_meeting_notes"],
561
+ },
562
+ // Same shape as the Claude namespace but underscore-separated. The
563
+ // two query_* tools (`_notion_query_data_sources`,
564
+ // `_notion_query_meeting_notes`) are read-only structured queries
565
+ // and intentionally excluded.
566
+ destructiveTools: [
567
+ "notion_create_pages",
568
+ "notion_update_page",
569
+ "notion_duplicate_page",
570
+ "notion_move_pages",
571
+ "notion_create_comment",
572
+ "notion_create_database",
573
+ "notion_update_data_source",
574
+ "notion_create_view",
575
+ "notion_update_view",
576
+ ],
577
+ },
578
+ gemini: {
579
+ // Notion's official MCP server is added to Gemini CLI by the
580
+ // user (`gemini mcp add notion <url>` or via the dashboard's MCP
581
+ // page). This descriptor assumes the server is registered under
582
+ // the literal name `notion` — Gemini surfaces tools as
583
+ // `mcp_notion_<tool>`. If the user picks a different name the
584
+ // probe will report every required capability missing; the
585
+ // dashboard surfaces this as an actionable "wrong server name"
586
+ // hint.
587
+ //
588
+ // Tool names match Notion's hosted MCP wire format (same names
589
+ // Anthropic's hosted Notion connector uses, with hyphenated
590
+ // identifiers). Same required-capability floor as Claude / Codex.
591
+ toolNamespace: "mcp_notion_",
592
+ requiredCapabilities: [
593
+ "search",
594
+ "read",
595
+ "create_page",
596
+ "update_properties",
597
+ "patch_content",
598
+ "archive",
599
+ ],
600
+ optionalCapabilities: [
601
+ "search",
602
+ "read",
603
+ "create_page",
604
+ "update_properties",
605
+ "patch_content",
606
+ "replace_content",
607
+ "archive",
608
+ "comments",
609
+ "duplicate_page",
610
+ "move_page",
611
+ "apply_template",
612
+ "schema_admin",
613
+ "users",
614
+ "teams",
615
+ ],
616
+ capabilityTools: {
617
+ search: ["notion-search"],
618
+ read: ["notion-fetch"],
619
+ create_page: ["notion-create-pages"],
620
+ update_properties: ["notion-update-page"],
621
+ patch_content: ["notion-update-page"],
622
+ replace_content: ["notion-update-page"],
623
+ archive: ["notion-update-page"],
624
+ comments: ["notion-create-comment", "notion-get-comments"],
625
+ duplicate_page: ["notion-duplicate-page"],
626
+ move_page: ["notion-move-pages"],
627
+ apply_template: ["notion-update-page"],
628
+ schema_admin: [
629
+ "notion-create-database",
630
+ "notion-update-data-source",
631
+ "notion-create-view",
632
+ "notion-update-view",
633
+ ],
634
+ users: ["notion-get-users"],
635
+ teams: ["notion-get-teams"],
636
+ },
637
+ destructiveTools: [
638
+ "notion-create-pages",
639
+ "notion-update-page",
640
+ "notion-duplicate-page",
641
+ "notion-move-pages",
642
+ "notion-create-comment",
643
+ "notion-create-database",
644
+ "notion-update-data-source",
645
+ "notion-create-view",
646
+ "notion-update-view",
647
+ ],
648
+ },
649
+ },
650
+ skillsTouched: ["notion"],
651
+ // `routine.hourly_check` is the only routine that consumes Notion
652
+ // observations today (NotionPoller → observations table → routine via
653
+ // the `observations` skill). When Notion is delegated the poller
654
+ // stops; the hourly_check delegated variant compensates by pulling
655
+ // recent edits via `notion-search` inline. See §7.5 of the design.
656
+ taskFlowsTouched: ["routine.hourly_check"],
657
+ observersTouched: ["notion-poller"],
658
+ // Fine-grained gating: leave `/api/notion/databases` ungated (it's a
659
+ // config dump with no Notion API call). Gate the routes that hit the
660
+ // API. The route-gate middleware uses longest-prefix matching with
661
+ // strict boundary semantics (`pathname === prefix ||
662
+ // startsWith(prefix + "/")`), so `/api/notion/databases` never
663
+ // matches any of these. See §5 + §7.2 of the design.
664
+ apiRoutesTouched: [
665
+ "/api/notion/query",
666
+ "/api/notion/search",
667
+ "/api/notion/pages",
668
+ ],
669
+ // The `notion` skill body is purely a wrapper over `/api/notion/*`
670
+ // (no IMAP/Obsidian/etc. side-content). When notion is delegated
671
+ // same-backend the connector's own tool descriptions cover the
672
+ // surface, so dropping the skill body avoids redundant prose.
673
+ sameBackendDropsSkillBody: ["notion"],
674
+ },
675
+ git: {
676
+ key: "git",
677
+ displayName: "Git",
678
+ supportedModes: ["direct", "delegated", "disabled"],
679
+ backendConnectors: {
680
+ claude: readOnlyCliConnector("cli:git:claude:"),
681
+ codex: readOnlyCliConnector("cli:git:codex:"),
682
+ gemini: readOnlyCliConnector("cli:git:gemini:"),
683
+ },
684
+ // Git event task-flows are backend-neutral observer flows, not MCP
685
+ // connector wrappers. Delegated Git uses a dedicated cron task-flow,
686
+ // so no SKILL.delegated.* variants are required here.
687
+ skillsTouched: [],
688
+ taskFlowsTouched: [],
689
+ observersTouched: ["git"],
690
+ apiRoutesTouched: [],
691
+ },
692
+ github: {
693
+ key: "github",
694
+ displayName: "GitHub",
695
+ supportedModes: ["direct", "delegated", "disabled"],
696
+ backendConnectors: {
697
+ claude: readOnlyCliConnector("cli:github:claude:"),
698
+ codex: readOnlyCliConnector("cli:github:codex:"),
699
+ gemini: readOnlyCliConnector("cli:github:gemini:"),
700
+ },
701
+ // GitHub delegated mode also relies on the chosen backend's read-only
702
+ // `gh` CLI access, not daemon-proxied MCP tools.
703
+ skillsTouched: [],
704
+ taskFlowsTouched: [],
705
+ observersTouched: ["github"],
706
+ apiRoutesTouched: [],
707
+ },
708
+ // SETUP-FLOW-REDESIGN-PLAN §6.1 — Outlook Mail. Microsoft does not
709
+ // ship a hosted Outlook MCP connector for Claude / Codex / Gemini, so
710
+ // `backendConnectors` stays empty and delegated mode runs as
711
+ // "user-managed connector": the user installs an Outlook / Microsoft
712
+ // Graph MCP server on the agent backend they pick (Claude Code
713
+ // Connector / Codex MCP / Gemini extension) and the daemon trusts that
714
+ // wiring. The `userManagedConnector` flag relaxes the PATCH /
715
+ // probe / variant gates that otherwise require a descriptor-supplied
716
+ // connector. The dashboard surfaces a "user must configure MCP on the
717
+ // selected backend" notice when delegated is selected.
718
+ outlook_mail: {
719
+ key: "outlook_mail",
720
+ displayName: "Outlook Mail",
721
+ supportedModes: ["direct", "delegated", "disabled"],
722
+ userManagedConnector: true,
723
+ directSetup: {
724
+ // MSAL token cache + per-account BYOA client config. Per §6.1 of
725
+ // the redesign plan, the token cache key is `mail:outlook:<accountId>`
726
+ // and the client-config blob is the canonical
727
+ // `mail:outlook:client-config` (see services/mail/outlook/client-config.ts).
728
+ credentialKeys: ["mail:outlook:client-config"],
729
+ // Microsoft Identity platform OAuth onboarding for personal +
730
+ // organizational accounts (BYOA pattern, public client).
731
+ helpUrl: "https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app",
732
+ },
733
+ backendConnectors: {},
734
+ // The unified `mail` skill already routes per-account on
735
+ // MailProviderKind ("outlook") for direct mode. Delegated mode for
736
+ // outlook is user-managed (no daemon-side variant materialization),
737
+ // so the registry intentionally does NOT declare `skillsTouched`
738
+ // here — `selectSkillVariantFile` would otherwise resolve to a
739
+ // Gmail-specific delegated variant when only outlook is delegated.
740
+ //
741
+ // Trade-off accepted: when ONLY outlook_mail is delegated and
742
+ // gmail/etc. stay direct, the agent reads the standard `mail`
743
+ // SKILL.md prose describing `/api/mail/*` for all kinds. Calls
744
+ // against outlook accounts then 410 with the user-managed message
745
+ // (see `delegatedMailIntegrationMessage` in api/routes/mail.ts),
746
+ // which redirects the agent to its own MCP. One round-trip cost
747
+ // on first attempt vs. authoring + maintaining outlook-specific
748
+ // skill variants — the round-trip wins for Phase 1.
749
+ skillsTouched: [],
750
+ taskFlowsTouched: [],
751
+ // The unified mail poller handles all `MailProviderKind` rows,
752
+ // including `kind="outlook"`; lifecycle stops Outlook polling when
753
+ // outlook_mail flips to delegated.
754
+ observersTouched: ["mail-poller"],
755
+ // Multi-provider routes `/api/mail/*` cannot be safely prefix-gated;
756
+ // per-account 410 inside the handler covers the delegated case.
757
+ // (Same exception Gmail makes — see `gatedIntegrationForKind` in
758
+ // mail.ts.)
759
+ apiRoutesTouched: [],
760
+ },
761
+ // SETUP-FLOW-REDESIGN-PLAN §6.1 — Outlook Calendar. Single-provider
762
+ // surface (mirrors `google_calendar`). v1 ships on-demand Graph
763
+ // fetches; no `OutlookCalendarPoller`. `observersTouched: []`
764
+ // reflects the deferral honestly. Delegated mode is user-managed —
765
+ // see the `outlook_mail` block for the contract.
766
+ outlook_calendar: {
767
+ key: "outlook_calendar",
768
+ displayName: "Outlook Calendar",
769
+ supportedModes: ["direct", "delegated", "disabled"],
770
+ userManagedConnector: true,
771
+ directSetup: {
772
+ // Token shared with `outlook_mail` via the MSAL cache plugin's
773
+ // per-account row. The same `mail:outlook:client-config` BYOA blob
774
+ // is reused — calendar does not register a separate client.
775
+ credentialKeys: ["mail:outlook:client-config"],
776
+ helpUrl: "https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app",
777
+ },
778
+ backendConnectors: {},
779
+ // No daemon-side skill variant for delegated outlook calendar —
780
+ // user-managed MCP exposes itself on the backend natively.
781
+ skillsTouched: [],
782
+ taskFlowsTouched: [],
783
+ observersTouched: [],
784
+ // Single-provider surface — `/api/calendar/outlook` is safely
785
+ // prefix-gated to 410 when delegated. The route gate's user-managed
786
+ // message points the agent at its backend's MCP rather than
787
+ // `/api/integrations/.../invoke` (no daemon-side proxy exists).
788
+ apiRoutesTouched: ["/api/calendar/outlook"],
789
+ },
790
+ };
791
+ export function getIntegrationDescriptor(key) {
792
+ return INTEGRATION_DESCRIPTORS[key];
793
+ }
794
+ export function listIntegrationDescriptors() {
795
+ return INTEGRATION_KEYS.map((k) => INTEGRATION_DESCRIPTORS[k]);
796
+ }
797
+ /**
798
+ * DELEGATED-MODE-V2-DESIGN.md §3 — integrations whose delegated task-flow
799
+ * variant uses the daemon's generic `/api/integrations/:key/invoke` proxy
800
+ * rather than native MCP. The router's
801
+ * {@link delegatedIntegrationsForProcessKey} skips these when computing
802
+ * fallback-refusal candidates: the daemon proxies the connector via
803
+ * `delegatedBackend`'s spawned subprocess regardless of which agent
804
+ * backend handles the process key, so the agent backend's identity does
805
+ * not need to constrain fallback.
806
+ *
807
+ * Replaces the v1 `proxiedAtDaemon` descriptor flag (removed in
808
+ * Phase 3.5). New v2 integrations that adopt the proxy task-flow add
809
+ * themselves here. Native-MCP-driven integrations (today: notion) are
810
+ * intentionally absent so their fallback-refusal semantics remain.
811
+ */
812
+ const PROXY_DRIVEN_INTEGRATIONS = new Set([
813
+ "gmail",
814
+ "google_calendar",
815
+ ]);
816
+ /**
817
+ * Per-integration runtime state. Persisted as a single JSON blob in the
818
+ * `settings` table under key `"integrations"`; rendered into
819
+ * `~/.personal-agent/integrations.md` so users can edit by hand.
820
+ */
821
+ export const integrationStateSchema = z
822
+ .object({
823
+ mode: z.enum(INTEGRATION_MODES),
824
+ delegatedBackend: z
825
+ .enum(BACKEND_IDS)
826
+ .optional()
827
+ // zod 4 emits `null` defaults as `undefined` when the field is optional;
828
+ // keep it explicit so JSON round-trips don't flip between.
829
+ .nullable()
830
+ .optional(),
831
+ /**
832
+ * DELEGATED-PROXY-API-DESIGN.md §4.2 / §5.1 — user-pinned model used by
833
+ * `DelegatedBackendInvoker` for proxy invocations. Null / undefined
834
+ * means "use the canonical light-tier model for `delegatedBackend`",
835
+ * resolved at call time rather than PATCH time so plan changes
836
+ * automatically retrack the canonical pick.
837
+ *
838
+ * Mode-flip behaviour: preserved across `direct ↔ delegated` and
839
+ * `delegated → disabled`; on a `delegatedBackend` swap a stale value
840
+ * is silently dropped at call time (dashboard surfaces a "Reset to
841
+ * default" affordance). PATCH validation rejects values that don't
842
+ * appear in the registered model list for `delegatedBackend`.
843
+ */
844
+ delegatedModel: z.string().min(1).nullable().optional(),
845
+ /**
846
+ * DELEGATED-PROXY-API-DESIGN.md §4.2 — per-call max-turns override.
847
+ * Sized for the rare connector that needs a tool-list lookup before
848
+ * the actual call. v0.1 ships UI only for `delegatedModel`;
849
+ * `delegatedMaxTurns` is registry-default (DELEGATED_PROXY_DEFAULTS
850
+ * in `delegated-proxy-config.ts`) and lifted later if observation
851
+ * warrants. Field exists in the schema for forward compatibility.
852
+ */
853
+ delegatedMaxTurns: z.number().int().min(1).max(10).nullable().optional(),
854
+ /**
855
+ * INTEGRATION-DRIFT-DETECTION-PLAN Phase 3 — hard kill switch for the
856
+ * daemon-side delegated drift worker. Omitted means enabled; storing
857
+ * only `false` keeps existing settings JSON compact and preserves
858
+ * backward compatibility with rows written before the worker existed.
859
+ */
860
+ delegatedSyncEnabled: z.boolean().optional(),
861
+ /**
862
+ * §7.7 tool-deny policy. Each entry is the unsuffixed tool name as
863
+ * declared in the descriptor's `capabilityTools` (e.g. for Claude
864
+ * Notion: `"notion-create-database"`; for Codex Notion:
865
+ * `"notion_create_database"`). Tools listed here are stripped from the
866
+ * delegated skill body at materialization time (hard enforcement on
867
+ * Claude via `allowed-tools` frontmatter; soft enforcement on Codex /
868
+ * Gemini via a prose "Denied tools" block). Default empty = all
869
+ * allowed. Stale entries (tool name not present in the active
870
+ * backend's capability tools) are silently ignored.
871
+ */
872
+ deniedTools: z.array(z.string()).optional().default([]),
873
+ /** ISO-8601 timestamp the user / daemon last changed this field. */
874
+ lastChangedAt: z.string(),
875
+ })
876
+ .superRefine((value, ctx) => {
877
+ if (value.mode === "delegated") {
878
+ if (!value.delegatedBackend) {
879
+ ctx.addIssue({
880
+ code: z.ZodIssueCode.custom,
881
+ path: ["delegatedBackend"],
882
+ message: "delegatedBackend is required when mode is 'delegated'",
883
+ });
884
+ }
885
+ }
886
+ // `delegatedModel` is allowed in any mode — it's inert when not
887
+ // delegated. Cross-field validation (model belongs to backend) lives
888
+ // in the PATCH handler so it can read the live model registry / plan
889
+ // preset; doing it here would couple every settings round-trip to
890
+ // that lookup.
891
+ });
892
+ /**
893
+ * The full integrations map stored in the `settings` table. Every registered
894
+ * integration key has an entry; missing keys are filled with the per-key
895
+ * default before persistence. Gmail / Calendar / Notion stay disabled on
896
+ * fresh installs, while Git / GitHub default to direct mode to preserve the
897
+ * pre-registry observer behaviour and match the Git lifecycle design.
898
+ */
899
+ export const integrationsMapSchema = z
900
+ .object(Object.fromEntries(INTEGRATION_KEYS.map((key) => [key, integrationStateSchema.optional()])))
901
+ .strict();
902
+ function defaultModeForIntegration(key) {
903
+ return key === "git" || key === "github" ? "direct" : "disabled";
904
+ }
905
+ /** Build the default integration map used for fresh installs. */
906
+ export function defaultIntegrationsMap(now = new Date().toISOString()) {
907
+ const out = {};
908
+ for (const key of INTEGRATION_KEYS) {
909
+ out[key] = {
910
+ mode: defaultModeForIntegration(key),
911
+ deniedTools: [],
912
+ lastChangedAt: now,
913
+ };
914
+ }
915
+ return out;
916
+ }
917
+ /**
918
+ * Narrow user input (from integrations.md or PATCH /api/integrations/:key) to a
919
+ * valid state. Used by the parser and the API route so both enforce identical
920
+ * rules.
921
+ */
922
+ export const integrationPatchSchema = z
923
+ .object({
924
+ mode: z.enum(INTEGRATION_MODES),
925
+ delegatedBackend: z.enum(BACKEND_IDS).optional().nullable(),
926
+ /**
927
+ * DELEGATED-PROXY-API-DESIGN.md §6.1 — user-pinned proxy model. Empty
928
+ * string is rejected (use `null` to clear). Cross-field validation
929
+ * (the value must appear in the registered model list for the
930
+ * effective backend) lives in the PATCH route handler so it can
931
+ * consult the live model registry / plan preset; here we only narrow
932
+ * the shape.
933
+ *
934
+ * Omitting the field preserves the previously stored value across
935
+ * `direct ↔ delegated` flips. Passing `null` explicitly clears the
936
+ * pin — useful when the dashboard "Reset to default" affordance is
937
+ * triggered after a backend swap.
938
+ */
939
+ delegatedModel: z.string().min(1).nullable().optional(),
940
+ /**
941
+ * DELEGATED-PROXY-API-DESIGN.md §4.2 — forward-compat field for
942
+ * per-integration max-turns overrides. v0.1 surfaces no UI for this;
943
+ * the schema accepts it so a future dashboard release can land
944
+ * without a migration. PATCH-time validation matches the state
945
+ * schema's int(1..10) bound.
946
+ */
947
+ delegatedMaxTurns: z.number().int().min(1).max(10).nullable().optional(),
948
+ /**
949
+ * Optional hard kill switch for DelegatedSyncWorker. Omitted = enabled.
950
+ * Accepted in any mode so the user can pre-stage the setting before
951
+ * flipping an integration to delegated.
952
+ */
953
+ delegatedSyncEnabled: z.boolean().optional(),
954
+ /**
955
+ * §7.7 — optional tool-deny list. Validation against
956
+ * descriptor.capabilityTools and required-capability coverage runs in
957
+ * the API route via `validateDeniedTools`; here we only narrow the
958
+ * shape. Omitting the field on PATCH preserves the previously stored
959
+ * list (mode-independent — direct ↔ delegated does not clear it).
960
+ */
961
+ deniedTools: z.array(z.string()).optional(),
962
+ })
963
+ .superRefine((value, ctx) => {
964
+ if (value.mode === "delegated" && !value.delegatedBackend) {
965
+ ctx.addIssue({
966
+ code: z.ZodIssueCode.custom,
967
+ path: ["delegatedBackend"],
968
+ message: "delegatedBackend is required when mode is 'delegated'",
969
+ });
970
+ }
971
+ if (value.mode !== "delegated" && value.delegatedBackend) {
972
+ ctx.addIssue({
973
+ code: z.ZodIssueCode.custom,
974
+ path: ["delegatedBackend"],
975
+ message: "delegatedBackend must be omitted unless mode is 'delegated'",
976
+ });
977
+ }
978
+ });
979
+ // ── Phase 3: Skill / task-flow variant selection (§4.7) ──────────────────────
980
+ /**
981
+ * DELEGATED-MODE-V2-DESIGN.md §4.1.1 — pick the SKILL.md variant for a given
982
+ * skill, **session backend**, and integration state. Three outcomes:
983
+ *
984
+ * - `"SKILL.md"` — direct/default body. Used when no touched
985
+ * integration is delegated, when the skill is
986
+ * integration-agnostic, OR when every touched
987
+ * integration is same-backend delegated but the
988
+ * skill body covers more than the connector
989
+ * exposes (see `sameBackendDropsSkillBody`).
990
+ * - `null` — do **not** materialize the skill at all. Reached
991
+ * when every touched integration is delegated AND
992
+ * `delegatedBackend === sessionBackend` AND every
993
+ * touching integration declares the skill in its
994
+ * `sameBackendDropsSkillBody` (i.e. the connector
995
+ * covers the entire skill surface). The agent
996
+ * already has the connector's tools natively, so
997
+ * a redundant skill body is omitted.
998
+ * - `"SKILL.delegated.<sessionBackend>.md"` — cross-backend variant. The
999
+ * DM session runs on `sessionBackend` but the
1000
+ * connector for at least one touched integration
1001
+ * lives on a *different* backend, so the agent
1002
+ * must call the daemon's generic invoke endpoint
1003
+ * which spawns the delegatedBackend subprocess.
1004
+ *
1005
+ * Combination rule for skills that touch multiple integrations: cross-backend
1006
+ * wins over same-backend (a single skill body cannot reliably span both
1007
+ * worlds), and same-backend → null wins over direct only when *every*
1008
+ * touched integration resolves to same-backend AND every touched integration
1009
+ * declares the skill in `sameBackendDropsSkillBody`. Any other configuration
1010
+ * falls back to `"SKILL.md"`.
1011
+ *
1012
+ * Callers are responsible for existence-checking: if the returned variant
1013
+ * file does not exist on disk, fall back to `"SKILL.md"`.
1014
+ */
1015
+ export function selectSkillVariantFile(skillSlug, sessionBackend, integrations) {
1016
+ const touchingKeys = INTEGRATION_KEYS.filter((k) => INTEGRATION_DESCRIPTORS[k].skillsTouched.includes(skillSlug));
1017
+ if (touchingKeys.length === 0)
1018
+ return "SKILL.md";
1019
+ const verdicts = touchingKeys.map((k) => resolveOneVariant(integrations[k], sessionBackend));
1020
+ if (verdicts.some((v) => v === "cross-backend")) {
1021
+ return `SKILL.delegated.${sessionBackend}.md`;
1022
+ }
1023
+ if (verdicts.every((v) => v === "same-backend")) {
1024
+ // The skill body is dropped only when every touching integration's
1025
+ // descriptor confirms its connector covers the skill end-to-end.
1026
+ // Multi-purpose skills (`mail` covers IMAP/Outlook; `external-services`
1027
+ // covers Obsidian/GitHub/scheduling) keep the direct body so the
1028
+ // non-delegated functionality survives.
1029
+ const allDrop = touchingKeys.every((k) => INTEGRATION_DESCRIPTORS[k].sameBackendDropsSkillBody?.includes(skillSlug)
1030
+ ?? false);
1031
+ return allDrop ? null : "SKILL.md";
1032
+ }
1033
+ return "SKILL.md";
1034
+ }
1035
+ function resolveOneVariant(state, sessionBackend) {
1036
+ // Skill not bound to this integration, or integration not yet seeded into
1037
+ // state (defaults to disabled) — direct.
1038
+ if (!state)
1039
+ return "direct";
1040
+ if (state.mode !== "delegated")
1041
+ return "direct";
1042
+ if (state.delegatedBackend === sessionBackend)
1043
+ return "same-backend";
1044
+ return "cross-backend";
1045
+ }
1046
+ /**
1047
+ * Return the task-flow variant suffix for a given event type, backend, and
1048
+ * integration state. Returns `"direct"` when no touched integration is
1049
+ * delegated. Returns `"delegated.<backendId>"` otherwise.
1050
+ *
1051
+ * The caller constructs the filename as `<eventType>.<suffix>.md` and checks
1052
+ * for existence before falling back to `<eventType>.md`.
1053
+ */
1054
+ export function selectTaskFlowVariantSuffix(taskFlowKey, backendId, integrations) {
1055
+ const touchingKeys = INTEGRATION_KEYS.filter((k) => INTEGRATION_DESCRIPTORS[k].taskFlowsTouched.includes(taskFlowKey));
1056
+ if (touchingKeys.every((k) => (integrations[k]?.mode ?? "disabled") !== "delegated")) {
1057
+ return "direct";
1058
+ }
1059
+ return `delegated.${backendId}`;
1060
+ }
1061
+ // ── Phase 4: Backend-router delegated-integration gating (§4 Phase 4) ────────
1062
+ /**
1063
+ * Return the delegated integrations whose `taskFlowsTouched` declares a
1064
+ * dependency on this process key. Skills are intentionally not consulted —
1065
+ * skills load on demand mid-session, so the router cannot predict them from
1066
+ * a process key alone.
1067
+ *
1068
+ * **Excludes proxy-driven integrations** (see {@link PROXY_DRIVEN_INTEGRATIONS}).
1069
+ * The router uses this helper only to decide whether to refuse a fallback
1070
+ * because the fallback backend lacks the integration's connector. For
1071
+ * proxy-driven integrations the connector lives on `delegatedBackend` and
1072
+ * is invoked by the daemon — the agent backend never touches it, so the
1073
+ * fallback gate is irrelevant. (Gmail/Calendar still appear in
1074
+ * `taskFlowsTouched` because their delegated variant is what
1075
+ * `selectTaskFlowVariantSuffix` reads to compensate for stopped pollers;
1076
+ * the asymmetry is intentional.)
1077
+ *
1078
+ * Returns an empty array when no native-MCP delegated integration touches
1079
+ * the key.
1080
+ */
1081
+ export function delegatedIntegrationsForProcessKey(processKey, integrations) {
1082
+ return INTEGRATION_KEYS.filter((k) => {
1083
+ const state = integrations[k];
1084
+ if (!state || state.mode !== "delegated")
1085
+ return false;
1086
+ if (PROXY_DRIVEN_INTEGRATIONS.has(k))
1087
+ return false;
1088
+ return INTEGRATION_DESCRIPTORS[k].taskFlowsTouched.includes(processKey);
1089
+ });
1090
+ }
1091
+ /**
1092
+ * True when the registry declares a connector for `(integrationKey, backendId)`.
1093
+ * Descriptor presence is the contract the BackendRouter consults on the
1094
+ * fallback path — a missing entry means the backend has no connector for
1095
+ * this integration, so routing a delegated-integration process key through
1096
+ * it would silently execute with the wrong tool surface.
1097
+ *
1098
+ * This does NOT consult capability lists or live probe state; `PATCH
1099
+ * /api/integrations/:key` already enforces `requiredCapabilities` against
1100
+ * the live probe before a delegated flip is accepted, and the router does
1101
+ * not re-run that check at dispatch time.
1102
+ */
1103
+ export function backendHasIntegrationConnector(integrationKey, backendId) {
1104
+ return INTEGRATION_DESCRIPTORS[integrationKey].backendConnectors[backendId] !== undefined;
1105
+ }
1106
+ // ── §7.7: Tool-deny policy validation + helpers ──────────────────────────────
1107
+ /**
1108
+ * DELEGATED-MODE-V2-DESIGN.md §4.3.5 — match a deny pattern against a single
1109
+ * tool name. Patterns are bare unsuffixed tool names — no `mcp__*` prefix,
1110
+ * no leading underscore (the connector's `toolNamespace` already terminates
1111
+ * as needed; e.g. Codex Gmail's namespace is `mcp__codex_apps__gmail._`,
1112
+ * so the bare match key is `send_email`, not `_send_email`). Optionally
1113
+ * suffixed with `*`:
1114
+ *
1115
+ * `send_email` — exact match
1116
+ * `send_*` — prefix match, anything starting with `send_`
1117
+ * `*` — matches anything (deny everything; rare)
1118
+ *
1119
+ * Anchors are implicit; `*` is only honored as a suffix to keep the pattern
1120
+ * language single-purpose. `*` mid-string is treated as literal text.
1121
+ *
1122
+ * Used by `validateDeniedTools` (typo defense at PATCH time),
1123
+ * `filterDeniedToolsForBackend` (active/stale partition during
1124
+ * materialization), and the `/api/integrations/:key/invoke` chokepoint
1125
+ * (per-call deny enforcement).
1126
+ */
1127
+ export function matchToolPattern(pattern, tool) {
1128
+ if (pattern === tool)
1129
+ return true;
1130
+ if (pattern.endsWith("*")) {
1131
+ const prefix = pattern.slice(0, -1);
1132
+ return tool.startsWith(prefix);
1133
+ }
1134
+ return false;
1135
+ }
1136
+ /**
1137
+ * Expand a deny pattern against a connector's known-tool universe. Exact
1138
+ * patterns return `[pattern]` if known, `[]` if not. Glob patterns return
1139
+ * the subset of known tools whose name starts with the prefix.
1140
+ *
1141
+ * Pure data — both inputs come from the integration registry, no I/O.
1142
+ */
1143
+ function expandDenyPattern(pattern, knownTools) {
1144
+ if (pattern.endsWith("*")) {
1145
+ const prefix = pattern.slice(0, -1);
1146
+ const out = [];
1147
+ for (const tool of knownTools) {
1148
+ if (tool.startsWith(prefix))
1149
+ out.push(tool);
1150
+ }
1151
+ return out;
1152
+ }
1153
+ return knownTools.has(pattern) ? [pattern] : [];
1154
+ }
1155
+ /**
1156
+ * Validate a proposed `deniedTools` list against an integration's
1157
+ * descriptor for a given `delegatedBackend`. Returns the first failure;
1158
+ * callers map this to the documented 400 shapes.
1159
+ *
1160
+ * Patterns: each entry is either an exact tool name (e.g. `_send_email`)
1161
+ * or a `*`-suffixed glob (e.g. `_delete_*`, `*`). See {@link matchToolPattern}.
1162
+ *
1163
+ * - **`unknown_tool`**: an exact entry isn't in any of the connector's
1164
+ * `capabilityTools` arrays, OR a glob entry matches no known tool
1165
+ * (typo defense for both forms). Helps users catch typos and stale
1166
+ * Claude / Codex names after a backend swap. (Note: the *materializer*
1167
+ * tolerates stale entries silently per §7.7 — but PATCH is strict so
1168
+ * the user sees the typo before saving. The dashboard's stale-entries
1169
+ * UI handles the soft case after a backend flip.)
1170
+ * - **`denial_breaks_required_capability`**: collectively, the proposal
1171
+ * drops every tool that satisfies at least one `requiredCapability`.
1172
+ * Multi-cap overlap matters — Notion's `notion-update-page` covers
1173
+ * `update_properties`, `patch_content`, `archive`, and `apply_template`,
1174
+ * so denying it breaks all four required caps at once. The error names
1175
+ * the first failing capability and lists what's still in the
1176
+ * `capabilityTools[capability]` set after the deny — so the dashboard
1177
+ * can show a "you'd need to keep X, Y, or Z" hint without a second call.
1178
+ * Globs are expanded against the connector's known tools before the
1179
+ * capability-coverage check runs.
1180
+ */
1181
+ export function validateDeniedTools(integrationKey, backendId, deniedTools) {
1182
+ const descriptor = INTEGRATION_DESCRIPTORS[integrationKey];
1183
+ const connector = descriptor.backendConnectors[backendId];
1184
+ // Forward-compat: today every (integrationKey, BackendId) pair has a
1185
+ // connector — this branch is reserved for a future integration that
1186
+ // omits one. See integrations.test.ts for the assertion that BackendId
1187
+ // membership is exhaustive.
1188
+ /* c8 ignore next 3 */
1189
+ if (!connector) {
1190
+ return { ok: false, error: "no_connector", backendId };
1191
+ }
1192
+ // Compute the known-tool universe once: union of every tool name appearing
1193
+ // in any capabilityTools entry. Used for both unknown-tool detection and
1194
+ // for the remaining-tools error payload.
1195
+ const knownToolSet = new Set();
1196
+ for (const tools of Object.values(connector.capabilityTools)) {
1197
+ for (const t of tools)
1198
+ knownToolSet.add(t);
1199
+ }
1200
+ // Validate each pattern, then expand globs into concrete tool names so the
1201
+ // capability-coverage check below can see the full impact.
1202
+ const expandedDenied = new Set();
1203
+ for (const tool of deniedTools) {
1204
+ const matches = expandDenyPattern(tool, knownToolSet);
1205
+ if (matches.length === 0) {
1206
+ // Exact-name typo or a glob that matches nothing in the connector.
1207
+ return {
1208
+ ok: false,
1209
+ error: "unknown_tool",
1210
+ tool,
1211
+ knownTools: [...knownToolSet].sort(),
1212
+ };
1213
+ }
1214
+ for (const m of matches)
1215
+ expandedDenied.add(m);
1216
+ }
1217
+ for (const capability of connector.requiredCapabilities) {
1218
+ // every requiredCapability is keyed in capabilityTools via the
1219
+ // descriptor self-consistency test; the `?? []` is defensive
1220
+ // against future descriptor edits that drop a capability mapping.
1221
+ /* c8 ignore next */
1222
+ const tools = connector.capabilityTools[capability] ?? [];
1223
+ const remaining = tools.filter((t) => !expandedDenied.has(t));
1224
+ if (remaining.length === 0) {
1225
+ // Surface the capability + what would still be available if the user
1226
+ // un-denied at least one of these. Lets the dashboard render an
1227
+ // actionable hint without a second round-trip.
1228
+ return {
1229
+ ok: false,
1230
+ error: "denial_breaks_required_capability",
1231
+ capability,
1232
+ remainingTools: tools,
1233
+ };
1234
+ }
1235
+ }
1236
+ return { ok: true };
1237
+ }
1238
+ /**
1239
+ * Filter a `deniedTools` list down to entries that exist in the active
1240
+ * backend's capability tools, expanding glob patterns. Used by the
1241
+ * materializer to silently drop stale names after a `delegatedBackend`
1242
+ * swap (§7.7 mode-flip behavior) and by `collectSessionDeniedTools` to
1243
+ * produce concrete namespaced tool names for backend `disallowedTools`.
1244
+ *
1245
+ * The output `active` list is the **expanded** concrete tool name set
1246
+ * (globs replaced by their matches) so callers can feed it directly into
1247
+ * an SDK `disallowedTools` array or `applyDeniedTools`. `stale` carries
1248
+ * the original user-entered patterns that matched nothing on this
1249
+ * backend's tool universe (typo or post-swap leftover).
1250
+ *
1251
+ * Distinct from `validateDeniedTools` — the API rejects unknown tools at
1252
+ * PATCH time, but a user who flips claude → codex carries Claude-namespaced
1253
+ * entries that the API didn't see at PATCH time. The materializer ignores
1254
+ * them; the dashboard surfaces them as "stale" so the user can clean up.
1255
+ */
1256
+ export function filterDeniedToolsForBackend(integrationKey, backendId, deniedTools) {
1257
+ const descriptor = INTEGRATION_DESCRIPTORS[integrationKey];
1258
+ const connector = descriptor.backendConnectors[backendId];
1259
+ // Forward-compat: see validateDeniedTools comment above.
1260
+ /* c8 ignore next */
1261
+ if (!connector)
1262
+ return { active: [], stale: [...deniedTools] };
1263
+ const known = new Set();
1264
+ for (const tools of Object.values(connector.capabilityTools)) {
1265
+ for (const t of tools)
1266
+ known.add(t);
1267
+ }
1268
+ const activeSet = new Set();
1269
+ const stale = [];
1270
+ for (const pattern of deniedTools) {
1271
+ const matches = expandDenyPattern(pattern, known);
1272
+ if (matches.length === 0) {
1273
+ stale.push(pattern);
1274
+ continue;
1275
+ }
1276
+ for (const m of matches)
1277
+ activeSet.add(m);
1278
+ }
1279
+ return { active: [...activeSet], stale };
1280
+ }
1281
+ /**
1282
+ * DELEGATED-MODE-V2-DESIGN.md §4.5.4 — recommended starter denylist used
1283
+ * by the setup wizard / PATCH route when the user first picks delegated
1284
+ * mode for an integration. The list errs conservative: strictly-destructive
1285
+ * ops (send / trash / delete) are always denied; reversible-but-easy-to-
1286
+ * lose-track-of ops (archive / mass-relabel / event-update) are denied so
1287
+ * the agent can't silently churn through cleanup work. The user opts out
1288
+ * explicitly per §4.5.4.
1289
+ *
1290
+ * Keyed on `(integrationKey, backendId)` because the same logical
1291
+ * destructive op uses different tool names per connector — Codex's Gmail
1292
+ * uses `_send_email`, Claude's Gmail has no send tool at all (the
1293
+ * connector is draft-only). Backends without a starter list resolve to
1294
+ * `[]` (no floor — caller can still PATCH explicit denies).
1295
+ *
1296
+ * The list contains only tool names already declared in the connector's
1297
+ * `capabilityTools`, so `validateDeniedTools` accepts them without the
1298
+ * `*`-glob extension.
1299
+ */
1300
+ const RECOMMENDED_STARTER_DENIED_TOOLS = {
1301
+ gmail: {
1302
+ // Codex's Gmail descriptor lists `send` in `requiredCapabilities` with
1303
+ // `["send_email", "send_draft"]`. Denying both would remove every path
1304
+ // to satisfy `send`, which `validateDeniedTools` rejects. The starter
1305
+ // list therefore denies `send_email` only — the irreversible "compose
1306
+ // and send right now" path — and leaves `send_draft` (send a previously
1307
+ // drafted message, which the user already vetted in the UI) available.
1308
+ // `delete_emails` and `archive_emails` together empty the optional
1309
+ // `delete` capability, which the validator allows. `apply_labels_to_emails`
1310
+ // is one of three tools in `label`; the floor is conservative without
1311
+ // breaking the capability.
1312
+ codex: [
1313
+ "send_email",
1314
+ "delete_emails",
1315
+ "archive_emails",
1316
+ "apply_labels_to_emails",
1317
+ ],
1318
+ // Claude's hosted Gmail connector is draft-only (no send/delete);
1319
+ // label mutations are the only mutating ops. `label_message` and
1320
+ // `label_thread` deny 2 of 5 tools in `label`, keeping the capability
1321
+ // satisfiable.
1322
+ claude: ["label_message", "label_thread"],
1323
+ // Gemini's google-workspace connector. `send` covers
1324
+ // compose-and-send-now (irreversible); `sendDraft` stays available
1325
+ // so the agent can dispatch a draft the user already vetted in the
1326
+ // UI. `batchModify` is the mass-mutation path; default-deny it the
1327
+ // way Codex's `apply_labels_to_emails` is denied. `modify` /
1328
+ // `modifyThread` are kept available — they're the only label-write
1329
+ // path the connector exposes, so denying them empties the required
1330
+ // `label` capability.
1331
+ gemini: ["send", "batchModify"],
1332
+ },
1333
+ google_calendar: {
1334
+ codex: ["delete_event", "update_event"],
1335
+ claude: ["delete_event", "update_event"],
1336
+ gemini: ["deleteEvent", "updateEvent"],
1337
+ },
1338
+ };
1339
+ /**
1340
+ * Lookup the recommended starter denylist for `(integrationKey,
1341
+ * backendId)`. Returns a fresh array each call so the caller can store it
1342
+ * directly without aliasing the registry constant.
1343
+ */
1344
+ export function recommendedStarterDeniedTools(integrationKey, backendId) {
1345
+ const list = RECOMMENDED_STARTER_DENIED_TOOLS[integrationKey]?.[backendId] ?? [];
1346
+ return [...list];
1347
+ }
1348
+ /**
1349
+ * DELEGATED-TASK-MODE-DESIGN.md §7.3 — destructive tool list for
1350
+ * `(integrationKey, backendId)`, returned as **fully-qualified names**
1351
+ * (`toolNamespace + bareName`). Used by `runDelegatedTask` to feed into
1352
+ * SDK `disallowedTools` (Claude) or admin-policy deny rules (Gemini)
1353
+ * when `allowDestructive: false`.
1354
+ *
1355
+ * Returns `[]` when the backend has no connector for the integration
1356
+ * (forward-compat: this never happens today; every backend has a
1357
+ * connector for every registered integration).
1358
+ */
1359
+ export function destructiveTaskTools(integrationKey, backendId) {
1360
+ const descriptor = INTEGRATION_DESCRIPTORS[integrationKey];
1361
+ const connector = descriptor.backendConnectors[backendId];
1362
+ /* c8 ignore next */
1363
+ if (!connector)
1364
+ return [];
1365
+ return connector.destructiveTools.map((t) => `${connector.toolNamespace}${t}`);
1366
+ }
1367
+ /**
1368
+ * DELEGATED-TASK-MODE-DESIGN.md §7.3 — bare-name version of
1369
+ * {@link destructiveTaskTools}, returned WITHOUT the `toolNamespace`
1370
+ * prefix. Used by anti-prompt-injection assertions and by callers that
1371
+ * already namespace separately.
1372
+ */
1373
+ export function destructiveTaskToolsBare(integrationKey, backendId) {
1374
+ const descriptor = INTEGRATION_DESCRIPTORS[integrationKey];
1375
+ const connector = descriptor.backendConnectors[backendId];
1376
+ /* c8 ignore next */
1377
+ if (!connector)
1378
+ return [];
1379
+ return [...connector.destructiveTools];
1380
+ }
1381
+ /**
1382
+ * DELEGATED-MODE-V2-DESIGN.md §4.3.3 — collect every `deniedTools` entry
1383
+ * that applies to the **session backend's own native MCP** (the
1384
+ * "same-backend" delegated state). For each integration whose
1385
+ * `delegatedBackend === sessionBackend`, expand the user's deny patterns
1386
+ * against the connector's known tools and return the namespaced tool names
1387
+ * (`mcp__<connector>__<tool>`) ready to drop into:
1388
+ *
1389
+ * - Claude Code SDK `disallowedTools` array (hard enforcement)
1390
+ * - Gemini admin policy `denied_tools` rules (hard enforcement)
1391
+ * - Codex agent profile prose block (soft enforcement — accepted gap,
1392
+ * §4.3.4 outcome γ)
1393
+ *
1394
+ * Cross-backend (delegatedBackend !== sessionBackend) integrations are
1395
+ * excluded — those calls go through `/api/integrations/:key/invoke` which
1396
+ * enforces deny at the daemon chokepoint (§4.3.2). Disabled / direct
1397
+ * integrations are excluded — deny only applies to delegated mode.
1398
+ *
1399
+ * Returns a `Map` keyed by integration so callers can attribute denies
1400
+ * per-integration in logs / prose. Empty entries are omitted.
1401
+ */
1402
+ export function collectSessionDeniedTools(integrations, sessionBackend) {
1403
+ const out = new Map();
1404
+ for (const key of INTEGRATION_KEYS) {
1405
+ const state = integrations[key];
1406
+ if (!state || state.mode !== "delegated")
1407
+ continue;
1408
+ if (state.delegatedBackend !== sessionBackend)
1409
+ continue;
1410
+ // Zod schema defaults deniedTools to `[]`; the `?? []` is defensive
1411
+ // against legacy state objects predating the default.
1412
+ /* c8 ignore next */
1413
+ const denied = state.deniedTools ?? [];
1414
+ if (denied.length === 0)
1415
+ continue;
1416
+ const descriptor = INTEGRATION_DESCRIPTORS[key];
1417
+ const connector = descriptor.backendConnectors[sessionBackend];
1418
+ // Forward-compat: every (integration, backend) pair currently has a
1419
+ // connector. This branch survives in code as registry drift insurance.
1420
+ /* c8 ignore next */
1421
+ if (!connector)
1422
+ continue;
1423
+ const { active } = filterDeniedToolsForBackend(key, sessionBackend, denied);
1424
+ if (active.length === 0)
1425
+ continue;
1426
+ const namespaced = active.map((t) => `${connector.toolNamespace}${t}`);
1427
+ out.set(key, namespaced);
1428
+ }
1429
+ return out;
1430
+ }
1431
+ // ── Mode-conditional section filter ──────────────────────────────────────────
1432
+ /**
1433
+ * Predicates accepted by `applyIntegrationModeFilter`. Each one keeps the
1434
+ * wrapped section when its condition holds for the given integration key
1435
+ * and session backend.
1436
+ */
1437
+ export const INTEGRATION_MODE_PREDICATES = [
1438
+ "direct",
1439
+ "delegated",
1440
+ "delegated-same",
1441
+ "delegated-cross",
1442
+ "disabled",
1443
+ ];
1444
+ const integrationModePredicateSet = new Set(INTEGRATION_MODE_PREDICATES);
1445
+ /**
1446
+ * Strip mode-conditional sections from a task-flow or skill body based on
1447
+ * current integration state and session backend. Mirrors the
1448
+ * `<!-- service:* -->` family used by `stripUnconfiguredServices` in
1449
+ * skills-compiler.ts.
1450
+ *
1451
+ * Section syntax (HTML comment delimiters, kept literal so authoring stays
1452
+ * markdown-friendly):
1453
+ *
1454
+ * <!-- mode:<predicate>:<key> -->
1455
+ * ...content shown only when the predicate holds...
1456
+ * <!-- /mode:<predicate>:<key> -->
1457
+ *
1458
+ * Predicates and the state they keep content for:
1459
+ *
1460
+ * direct — `integrations[key].mode === "direct"`
1461
+ * delegated — `mode === "delegated"` (regardless of which backend)
1462
+ * delegated-same — delegated AND `delegatedBackend === sessionBackend`
1463
+ * (the connector is signed in to this same session's
1464
+ * backend; the agent uses native MCP tools and skill
1465
+ * bodies are typically not materialized)
1466
+ * delegated-cross — delegated AND `delegatedBackend !== sessionBackend`
1467
+ * (the agent reaches the connector via the daemon
1468
+ * proxy at `POST /api/integrations/:key/invoke`)
1469
+ * disabled — `mode === "disabled"` OR no state row (treated as
1470
+ * disabled by `defaultIntegrationsMap`)
1471
+ *
1472
+ * Behaviour notes:
1473
+ *
1474
+ * - **Unknown predicate**: section preserved verbatim. Surfaces the typo in
1475
+ * the rendered prompt rather than silently losing or duplicating prose.
1476
+ * - **Unknown integration key**: section preserved verbatim. Same rationale —
1477
+ * a misspelled key (or registry drift mid-deploy) should remain visible.
1478
+ * - **Sections do not nest**: the regex is non-greedy and matches the
1479
+ * first close tag. Two sibling blocks at the same level work; nesting
1480
+ * a same-key block inside another doesn't and is unsupported.
1481
+ * - **Idempotent**: running the filter twice on the same content with the
1482
+ * same state produces identical output.
1483
+ *
1484
+ * Apply this AFTER any whole-file variant selection (e.g. `SKILL.delegated.
1485
+ * <backend>.md`) so the variant author can use mode markers freely without
1486
+ * worrying about pre-filtering interactions.
1487
+ */
1488
+ export function applyIntegrationModeFilter(content, integrations, sessionBackend) {
1489
+ return content.replace(/<!-- mode:([a-z][a-z-]*):([a-z_][a-z0-9_]*) -->\n?([\s\S]*?)<!-- \/mode:\1:\2 -->\n?/g, (match, predicate, key, body) => {
1490
+ if (!integrationModePredicateSet.has(predicate))
1491
+ return match;
1492
+ if (!isIntegrationKey(key))
1493
+ return match;
1494
+ const keep = evaluateIntegrationModePredicate(predicate, integrations[key], sessionBackend);
1495
+ return keep ? body : "";
1496
+ });
1497
+ }
1498
+ function evaluateIntegrationModePredicate(predicate, state, sessionBackend) {
1499
+ // Treat missing state as disabled (matches `defaultIntegrationsMap`).
1500
+ const mode = state?.mode ?? "disabled";
1501
+ const delegatedBackend = state?.delegatedBackend ?? null;
1502
+ switch (predicate) {
1503
+ case "direct":
1504
+ return mode === "direct";
1505
+ case "delegated":
1506
+ return mode === "delegated";
1507
+ case "delegated-same":
1508
+ return mode === "delegated" && delegatedBackend === sessionBackend;
1509
+ case "delegated-cross":
1510
+ return (mode === "delegated"
1511
+ && delegatedBackend !== null
1512
+ && delegatedBackend !== sessionBackend);
1513
+ case "disabled":
1514
+ return mode === "disabled";
1515
+ }
1516
+ }
1517
+ // ── DELEGATED-TASK-MODE-DESIGN.md §4.2 — `/api/delegated/run` allowedTools ───
1518
+ /**
1519
+ * Validation regex for an `allowedTools` entry on `POST /api/delegated/run`
1520
+ * (Phase 2 generic task mode for unregistered MCPs).
1521
+ *
1522
+ * Spec §4.2 reads: "Specifically rejected: bare `*`, patterns starting with
1523
+ * `*`, prefixes shorter than 4 characters before `*` (e.g. `mcp_*`), and
1524
+ * anything containing shell metacharacters." The literal example `mcp_*`
1525
+ * has a 4-character prefix yet must be rejected — i.e. the spec's prose
1526
+ * intent is "≥5 chars before `*`," and the {4,} quantifier in the literal
1527
+ * regex contradicts the example. We honor the example: prefix-before-`*`
1528
+ * must be ≥5 chars, and bare/leading `*` is rejected outright.
1529
+ *
1530
+ * Allowed shape:
1531
+ * - 5+ chars of `[A-Za-z0-9_-]` to start.
1532
+ * - Optional dotted/underscored continuation segments
1533
+ * (`([._][A-Za-z0-9_-]+)*`).
1534
+ * - Optional trailing `*` for a glob.
1535
+ *
1536
+ * Examples accepted: `mcp_my-server_*`, `mcp_my-server_subtool.action`,
1537
+ * `mcp__custom-srv_doIt`. Examples rejected: `*`, `*foo`, `mcp_*`,
1538
+ * `mcp_my-server_*.action`, `srv;rm -rf /`.
1539
+ *
1540
+ * Shell-metacharacter rejection is doubled-up: even if a pattern slipped
1541
+ * through the regex (it can't — the character class excludes them), the
1542
+ * dedicated check in {@link validateRunAllowedTool} would reject. This
1543
+ * keeps the safety floor obvious in review.
1544
+ */
1545
+ export const MCP_PATTERN_REGEX = /^[A-Za-z0-9_-]{5,}([._][A-Za-z0-9_-]+)*\*?$/;
1546
+ /**
1547
+ * Shell metacharacters not allowed anywhere in an `allowedTools` entry.
1548
+ * The regex above already excludes them from the character class, but a
1549
+ * standalone check is what {@link validateRunAllowedTool} cites in its
1550
+ * `bad_allowed_tools` message so callers see *why* the pattern was rejected
1551
+ * (without re-deriving from the regex).
1552
+ */
1553
+ const SHELL_METACHAR_RE = /[;&|`$()<>'"\\\s\n\r\t{}\[\]?#~^!=,]/;
1554
+ /** Validate a single pattern entry. Exported for unit tests. */
1555
+ export function validateRunAllowedTool(pattern) {
1556
+ if (typeof pattern !== "string" || pattern.length === 0) {
1557
+ return {
1558
+ ok: false,
1559
+ errorClass: "bad_allowed_tools",
1560
+ pattern: typeof pattern === "string" ? pattern : "",
1561
+ reason: "empty",
1562
+ message: "allowedTools entries must be non-empty strings",
1563
+ };
1564
+ }
1565
+ if (pattern === "*") {
1566
+ return {
1567
+ ok: false,
1568
+ errorClass: "bad_allowed_tools",
1569
+ pattern,
1570
+ reason: "bare_star",
1571
+ message: "bare `*` is not an allowed pattern — it would match every tool",
1572
+ };
1573
+ }
1574
+ if (pattern.startsWith("*")) {
1575
+ return {
1576
+ ok: false,
1577
+ errorClass: "bad_allowed_tools",
1578
+ pattern,
1579
+ reason: "leading_star",
1580
+ message: "patterns must not start with `*` — anchor with a literal prefix",
1581
+ };
1582
+ }
1583
+ if (SHELL_METACHAR_RE.test(pattern)) {
1584
+ return {
1585
+ ok: false,
1586
+ errorClass: "bad_allowed_tools",
1587
+ pattern,
1588
+ reason: "shell_metachar",
1589
+ message: "patterns must not contain shell metacharacters or whitespace",
1590
+ };
1591
+ }
1592
+ // Trailing-star prefix length must be ≥5; the regex enforces this, but
1593
+ // we surface a clearer error class for the common typo.
1594
+ if (pattern.endsWith("*")) {
1595
+ const prefix = pattern.slice(0, -1);
1596
+ if (prefix.length < 5) {
1597
+ return {
1598
+ ok: false,
1599
+ errorClass: "bad_allowed_tools",
1600
+ pattern,
1601
+ reason: "prefix_too_short",
1602
+ message: "glob prefix must be at least 5 characters before `*` (e.g. `mcp_my-server_*`)",
1603
+ };
1604
+ }
1605
+ }
1606
+ if (!MCP_PATTERN_REGEX.test(pattern)) {
1607
+ return {
1608
+ ok: false,
1609
+ errorClass: "bad_allowed_tools",
1610
+ pattern,
1611
+ reason: "shape_invalid",
1612
+ message: "pattern must match ^[A-Za-z0-9_-]{5,}([._][A-Za-z0-9_-]+)*\\*?$ — letters/digits/underscores/dashes with optional `._`-separated segments and an optional trailing `*`",
1613
+ };
1614
+ }
1615
+ return { ok: true };
1616
+ }
1617
+ /**
1618
+ * Validate every entry in a `/api/delegated/run` `allowedTools` array.
1619
+ * Returns the first failing entry (HTTP 400 maps to that), or `{ok: true}`
1620
+ * if every pattern is well-formed and the array is non-empty.
1621
+ */
1622
+ export function validateRunAllowedTools(patterns) {
1623
+ if (!Array.isArray(patterns) || patterns.length === 0) {
1624
+ return {
1625
+ ok: false,
1626
+ errorClass: "bad_allowed_tools",
1627
+ pattern: "",
1628
+ reason: "empty",
1629
+ message: "allowedTools must be a non-empty array",
1630
+ };
1631
+ }
1632
+ for (const p of patterns) {
1633
+ const r = validateRunAllowedTool(p);
1634
+ if (!r.ok)
1635
+ return r;
1636
+ }
1637
+ return { ok: true };
1638
+ }
1639
+ /**
1640
+ * Match an `/api/delegated/run` allowedTools entry against a fully
1641
+ * qualified tool name observed in the subprocess stream. Mirrors the
1642
+ * `matchToolPattern` semantics used elsewhere — exact equality or
1643
+ * `*`-suffix prefix match — but the input space is fully-qualified MCP
1644
+ * names, not the bare deny vocabulary, so we expose it as a separate
1645
+ * symbol.
1646
+ */
1647
+ export function matchRunAllowedToolPattern(pattern, tool) {
1648
+ if (pattern === tool)
1649
+ return true;
1650
+ if (pattern.endsWith("*")) {
1651
+ const prefix = pattern.slice(0, -1);
1652
+ return tool.startsWith(prefix);
1653
+ }
1654
+ return false;
1655
+ }
1656
+ //# sourceMappingURL=integrations.js.map