@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.
- package/LICENSE +21 -0
- package/dist/advisor-models.d.ts +34 -0
- package/dist/advisor-models.d.ts.map +1 -0
- package/dist/advisor-models.js +39 -0
- package/dist/advisor-models.js.map +1 -0
- package/dist/agent-identity.d.ts +11 -0
- package/dist/agent-identity.d.ts.map +1 -0
- package/dist/agent-identity.js +29 -0
- package/dist/agent-identity.js.map +1 -0
- package/dist/alerts.d.ts +44 -0
- package/dist/alerts.d.ts.map +1 -0
- package/dist/alerts.js +12 -0
- package/dist/alerts.js.map +1 -0
- package/dist/backend-api-key-config.d.ts +337 -0
- package/dist/backend-api-key-config.d.ts.map +1 -0
- package/dist/backend-api-key-config.js +682 -0
- package/dist/backend-api-key-config.js.map +1 -0
- package/dist/backend.d.ts +93 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +22 -0
- package/dist/backend.js.map +1 -0
- package/dist/branding.d.ts +96 -0
- package/dist/branding.d.ts.map +1 -0
- package/dist/branding.js +102 -0
- package/dist/branding.js.map +1 -0
- package/dist/chat-session-scope.d.ts +14 -0
- package/dist/chat-session-scope.d.ts.map +1 -0
- package/dist/chat-session-scope.js +18 -0
- package/dist/chat-session-scope.js.map +1 -0
- package/dist/date-utils.d.ts +80 -0
- package/dist/date-utils.d.ts.map +1 -0
- package/dist/date-utils.js +187 -0
- package/dist/date-utils.js.map +1 -0
- package/dist/docs-frontmatter.d.ts +51 -0
- package/dist/docs-frontmatter.d.ts.map +1 -0
- package/dist/docs-frontmatter.js +184 -0
- package/dist/docs-frontmatter.js.map +1 -0
- package/dist/docs-schema.d.ts +79 -0
- package/dist/docs-schema.d.ts.map +1 -0
- package/dist/docs-schema.js +135 -0
- package/dist/docs-schema.js.map +1 -0
- package/dist/editable-config-keys.d.ts +14 -0
- package/dist/editable-config-keys.d.ts.map +1 -0
- package/dist/editable-config-keys.js +157 -0
- package/dist/editable-config-keys.js.map +1 -0
- package/dist/exec-with-stdin.d.ts +14 -0
- package/dist/exec-with-stdin.d.ts.map +1 -0
- package/dist/exec-with-stdin.js +35 -0
- package/dist/exec-with-stdin.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations-snapshot.d.ts +183 -0
- package/dist/integrations-snapshot.d.ts.map +1 -0
- package/dist/integrations-snapshot.js +757 -0
- package/dist/integrations-snapshot.js.map +1 -0
- package/dist/integrations.d.ts +675 -0
- package/dist/integrations.d.ts.map +1 -0
- package/dist/integrations.js +1656 -0
- package/dist/integrations.js.map +1 -0
- package/dist/keychain-helper-client.d.ts +31 -0
- package/dist/keychain-helper-client.d.ts.map +1 -0
- package/dist/keychain-helper-client.js +105 -0
- package/dist/keychain-helper-client.js.map +1 -0
- package/dist/log-entry.d.ts +14 -0
- package/dist/log-entry.d.ts.map +1 -0
- package/dist/log-entry.js +2 -0
- package/dist/log-entry.js.map +1 -0
- package/dist/management-domains.d.ts +369 -0
- package/dist/management-domains.d.ts.map +1 -0
- package/dist/management-domains.js +499 -0
- package/dist/management-domains.js.map +1 -0
- package/dist/process-key.d.ts +67 -0
- package/dist/process-key.d.ts.map +1 -0
- package/dist/process-key.js +366 -0
- package/dist/process-key.js.map +1 -0
- package/dist/schemas.d.ts +267 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +271 -0
- package/dist/schemas.js.map +1 -0
- package/dist/secret-client-factory.d.ts +16 -0
- package/dist/secret-client-factory.d.ts.map +1 -0
- package/dist/secret-client-factory.js +111 -0
- package/dist/secret-client-factory.js.map +1 -0
- package/dist/secret-client-file.d.ts +51 -0
- package/dist/secret-client-file.d.ts.map +1 -0
- package/dist/secret-client-file.js +160 -0
- package/dist/secret-client-file.js.map +1 -0
- package/dist/secret-client-linux.d.ts +26 -0
- package/dist/secret-client-linux.d.ts.map +1 -0
- package/dist/secret-client-linux.js +63 -0
- package/dist/secret-client-linux.js.map +1 -0
- package/dist/secret-client-windows.d.ts +37 -0
- package/dist/secret-client-windows.d.ts.map +1 -0
- package/dist/secret-client-windows.js +82 -0
- package/dist/secret-client-windows.js.map +1 -0
- package/dist/secret-redaction.d.ts +3 -0
- package/dist/secret-redaction.d.ts.map +1 -0
- package/dist/secret-redaction.js +31 -0
- package/dist/secret-redaction.js.map +1 -0
- package/dist/skill-curation/decision-language.d.ts +6 -0
- package/dist/skill-curation/decision-language.d.ts.map +1 -0
- package/dist/skill-curation/decision-language.js +38 -0
- package/dist/skill-curation/decision-language.js.map +1 -0
- package/dist/skill-curation/schemas.d.ts +461 -0
- package/dist/skill-curation/schemas.d.ts.map +1 -0
- package/dist/skill-curation/schemas.js +211 -0
- package/dist/skill-curation/schemas.js.map +1 -0
- package/dist/types.d.ts +204 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +54 -0
- package/dist/types.js.map +1 -0
- 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
|