@zackbart/connecta 0.31.0 → 0.33.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 (169) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +30 -20
  3. package/bin/connecta.mjs +25 -6
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.d.ts +167 -23
  6. package/dist/access-tokens.js +676 -140
  7. package/dist/agent-identity.js +47 -0
  8. package/dist/auth/cloudflare-access.js +13 -5
  9. package/dist/auth-elicitation.js +1 -1
  10. package/dist/call-admission-context.js +15 -0
  11. package/dist/call-admission.js +30 -43
  12. package/dist/call-recovery.js +6 -2
  13. package/dist/catalog-service.js +7 -2
  14. package/dist/config-value-sources.js +14 -9
  15. package/dist/config.d.ts +58 -78
  16. package/dist/config.js +143 -47
  17. package/dist/connector-access.d.ts +4 -33
  18. package/dist/connector-access.js +15 -99
  19. package/dist/connector-caller.d.ts +7 -1
  20. package/dist/connector-caller.js +3 -2
  21. package/dist/connectors/api-connector.d.ts +27 -4
  22. package/dist/connectors/api-connector.js +2 -0
  23. package/dist/connectors/guarded-fetch.d.ts +9 -0
  24. package/dist/connectors/guarded-fetch.js +7 -0
  25. package/dist/connectors/remote-mcp.js +2 -0
  26. package/dist/describe-config.d.ts +32 -7
  27. package/dist/describe-config.js +9 -18
  28. package/dist/errors.d.ts +1 -1
  29. package/dist/errors.js +1 -0
  30. package/dist/execute.js +16 -14
  31. package/dist/index.d.ts +4 -3
  32. package/dist/index.js +30 -10
  33. package/dist/invocation.js +12 -7
  34. package/dist/membership.d.ts +51 -0
  35. package/dist/membership.js +121 -0
  36. package/dist/meta-tools.js +17 -19
  37. package/dist/module-contracts.d.ts +23 -6
  38. package/dist/operator-record.d.ts +2 -2
  39. package/dist/operator-record.js +2 -2
  40. package/dist/operator-ui/agents.d.ts +74 -0
  41. package/dist/operator-ui/agents.js +104 -0
  42. package/dist/operator-ui/contract.d.ts +151 -29
  43. package/dist/operator-ui/endpoints.js +192 -0
  44. package/dist/operator-ui/fix-prompts.js +4 -4
  45. package/dist/operator-ui/generated.js +5 -4
  46. package/dist/operator-ui/member-page.js +112 -0
  47. package/dist/operator-ui/model.d.ts +15 -7
  48. package/dist/operator-ui/view.d.ts +84 -19
  49. package/dist/operator-ui/view.js +83 -21
  50. package/dist/page-styles.js +1 -1
  51. package/dist/program-writes.js +2 -2
  52. package/dist/provider.d.ts +63 -11
  53. package/dist/provider.js +46 -7
  54. package/dist/providers/_shared/google/workspace.d.ts +53 -16
  55. package/dist/providers/_shared/google/workspace.js +31 -9
  56. package/dist/providers/_shared/rest/tools.js +2 -2
  57. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  58. package/dist/providers/_shared/rest/value-safety.js +699 -7
  59. package/dist/providers/breeze/skill.generated.js +2 -2
  60. package/dist/providers/ccb/index.js +1 -1
  61. package/dist/providers/cloudflare/index.js +3 -3
  62. package/dist/providers/cloudflare/rest.js +25 -3
  63. package/dist/providers/cloudflare/skill.generated.js +2 -2
  64. package/dist/providers/cloudflare/value-safety.js +32 -0
  65. package/dist/providers/docs/index.d.ts +1 -1
  66. package/dist/providers/docs/index.js +5 -5
  67. package/dist/providers/docs/skill.generated.js +2 -2
  68. package/dist/providers/drive/index.d.ts +1 -1
  69. package/dist/providers/drive/index.js +4 -4
  70. package/dist/providers/drive/skill.generated.js +2 -2
  71. package/dist/providers/forms/index.d.ts +1 -1
  72. package/dist/providers/forms/index.js +5 -5
  73. package/dist/providers/forms/skill.generated.js +2 -2
  74. package/dist/providers/github/skill.generated.js +2 -2
  75. package/dist/providers/gmail/index.d.ts +1 -1
  76. package/dist/providers/gmail/index.js +279 -26
  77. package/dist/providers/gmail/skill.generated.js +2 -2
  78. package/dist/providers/linear/index.js +1 -1
  79. package/dist/providers/mixpanel/index.d.ts +27 -12
  80. package/dist/providers/mixpanel/index.js +58 -6
  81. package/dist/providers/mixpanel/operations.js +20 -0
  82. package/dist/providers/mixpanel/rest.js +603 -0
  83. package/dist/providers/mixpanel/skill.generated.js +3 -2
  84. package/dist/providers/mixpanel/value-safety.js +16 -0
  85. package/dist/providers/notion/index.d.ts +7 -0
  86. package/dist/providers/notion/index.js +116 -11
  87. package/dist/providers/notion/rest.js +183 -24
  88. package/dist/providers/notion/skill.generated.js +4 -4
  89. package/dist/providers/notion/value-safety.js +30 -5
  90. package/dist/providers/overflow/index.js +1 -1
  91. package/dist/providers/planning-center/index.d.ts +27 -0
  92. package/dist/providers/planning-center/index.js +287 -25
  93. package/dist/providers/planning-center/skill.generated.js +24 -4
  94. package/dist/providers/sheets/index.d.ts +4 -1
  95. package/dist/providers/sheets/index.js +9 -6
  96. package/dist/providers/sheets/skill.generated.js +2 -2
  97. package/dist/providers/slides/index.d.ts +1 -1
  98. package/dist/providers/slides/index.js +6 -6
  99. package/dist/providers/slides/skill.generated.js +2 -2
  100. package/dist/providers/stripe/rest.js +9 -2
  101. package/dist/providers/stripe/skill.generated.js +2 -2
  102. package/dist/providers/stripe/value-safety.js +22 -0
  103. package/dist/providers/vercel/index.js +2 -2
  104. package/dist/providers/vercel/rest.js +20 -2
  105. package/dist/providers/vercel/skill.generated.js +2 -2
  106. package/dist/providers/vercel/value-safety.js +22 -0
  107. package/dist/providers/wispr-flow/index.d.ts +14 -0
  108. package/dist/providers/wispr-flow/index.js +117 -0
  109. package/dist/providers/wispr-flow/skill.generated.js +8 -0
  110. package/dist/registry.d.ts +2 -3
  111. package/dist/registry.js +8 -9
  112. package/dist/routes/access-tokens.js +82 -75
  113. package/dist/routes/activity-disclosure.js +17 -18
  114. package/dist/routes/activity.js +34 -54
  115. package/dist/routes/actor-labels.js +33 -4
  116. package/dist/routes/agent-path.js +43 -0
  117. package/dist/routes/connect.js +16 -12
  118. package/dist/routes/credentials.js +16 -40
  119. package/dist/routes/mcp.js +155 -85
  120. package/dist/routes/oauth-management.js +11 -5
  121. package/dist/routes/oauth.js +3 -9
  122. package/dist/routes/operator.js +25 -17
  123. package/dist/routes/shared.d.ts +60 -20
  124. package/dist/routes/shared.js +187 -96
  125. package/dist/routes/ui-config.js +437 -97
  126. package/dist/routes/ui-data.js +2 -2
  127. package/dist/routes/ui.js +9 -16
  128. package/dist/runtime/call-admission.js +9 -0
  129. package/dist/server.js +8 -0
  130. package/dist/skills.js +5 -4
  131. package/dist/storage/keys.js +9 -1
  132. package/dist/tool-safety.js +13 -5
  133. package/dist/types.d.ts +12 -1
  134. package/dist/ui.d.ts +2 -2
  135. package/dist/ui.js +17 -15
  136. package/dist/usage-guide.js +3 -3
  137. package/dist/version.d.ts +1 -1
  138. package/dist/version.js +1 -1
  139. package/dist/workspace-address.js +9 -0
  140. package/dist/workspace-subject.js +23 -0
  141. package/documentation/README.md +46 -22
  142. package/documentation/architecture.md +378 -58
  143. package/documentation/auth.md +806 -215
  144. package/documentation/code-mode.md +93 -81
  145. package/documentation/deploying.md +128 -26
  146. package/documentation/integrating.md +145 -22
  147. package/documentation/meta-tools.md +58 -46
  148. package/documentation/migration-0.33.md +344 -0
  149. package/documentation/operating.md +14 -9
  150. package/documentation/operator-tests.md +117 -31
  151. package/documentation/operator-ui.md +326 -55
  152. package/documentation/provider-auth-migration.md +42 -27
  153. package/documentation/provider-migration-0.29.md +3 -3
  154. package/examples/worker/AGENTS.md +35 -8
  155. package/examples/worker/README.md +92 -53
  156. package/examples/worker/src/connecta.config.ts +41 -20
  157. package/examples/worker/wrangler.jsonc +3 -0
  158. package/package.json +7 -2
  159. package/templates/node/.env.example +3 -0
  160. package/templates/node/AGENTS.md +33 -10
  161. package/templates/node/Dockerfile +2 -2
  162. package/templates/node/README.md +71 -44
  163. package/templates/node/docker-compose.yml +3 -2
  164. package/templates/node/package.json +4 -3
  165. package/templates/node/src/adopt-token.ts +23 -0
  166. package/templates/node/src/connecta.config.ts +33 -12
  167. package/templates/node/src/rotate-token.ts +17 -0
  168. package/dist/tool-safety.d.ts +0 -9
  169. package/templates/node/src/provision-token.ts +0 -17
@@ -1,5 +1,11 @@
1
1
  # Architecture
2
2
 
3
+ ## Contributor reference
4
+
5
+ This guide describes Connecta implementation and repository verification.
6
+ It is optional for deployment operators. Start with the
7
+ [operator task index](./README.md#operator-tasks) for package-local procedures.
8
+
3
9
  One Web-standard `fetch(request) => Promise<Response>` handler, a long-lived
4
10
  registry behind it, and a strict rule about what may be imported. Everything
5
11
  else in this repository is a detail of those three things.
@@ -85,20 +91,21 @@ table, security headers. Route _order_ is the contract — several routes would
85
91
  behave differently if they were reachable in another order — so read the table
86
92
  top to bottom.
87
93
 
88
- | Order | Route | Notes |
89
- | ----- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
90
- | 0 | MCP Origin check | A disallowed `Origin` on `/mcp*` is a fixed 403 before redirects, admission, auth, or preflight — costing no permit and no auth lookup. Originless requests are admitted. |
91
- | 0 | HTTPS upgrade | 308 to an HTTPS `publicUrl`, with path and query _assigned_ onto it rather than resolved against it, so a `//host` pathname cannot replace the origin. `/health` is exempt: a loopback probe must not need public DNS. |
92
- | 0 | Cloudflare Access (Worker, when enabled) | Edge admission ahead of this table; an admitted invocation carries trusted identity in `ctx.access`. |
93
- | 1 | `/oauth/client-metadata/<connectorId>` | Public GET-only CIMD for configured self-hosted OAuth connectors; fixed `publicUrl`, no auth or storage. |
94
- | 1 | Mounted UI routes | Before wildcard OPTIONS, so mutation routes refuse preflight rather than inheriting MCP CORS. No UI module, no routes. |
95
- | 1 | `/connect/<connectorId>`, `/oauth/callback/<connectorId>` | GET-only browser OAuth routes, before wildcard OPTIONS. Both verify the initiating user and management permission, independent of the UI. |
96
- | 2 | MCP preflight | Allowed `OPTIONS` on `/mcp*`: 204 without admission or auth. |
97
- | 2 | Other `OPTIONS` | Auth metadata first, otherwise compatibility CORS preflight. |
98
- | 3 | `/.well-known/*` | Auth metadata, or 404. |
99
- | 4 | `/health` | Open and payload-free: health, executor, admission, and deployment metadata. |
100
- | 6 | `/mcp`, `/mcp/<pool>` | Admission, then auth, then a request-local MCP server. Body-confirmed modern listens skip admission and are refused after auth and SDK validation. An undeclared pool, a refusing grant, and a throwing grant are one identical 404; see [pools](./auth.md#pools). |
101
- | 7 | Other paths | 404. Custom HTTP routes belong to the deployment. |
94
+ | Order | Route | Notes |
95
+ | ----- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
96
+ | 0 | MCP Origin check | A disallowed `Origin` on `/mcp*` is a fixed 403 before redirects, admission, auth, or preflight — costing no permit and no auth lookup. Originless requests are admitted. |
97
+ | 0 | HTTPS upgrade | 308 to an HTTPS `publicUrl`, with path and query _assigned_ onto it rather than resolved against it, so a `//host` pathname cannot replace the origin. `/health` is exempt: a loopback probe must not need public DNS. |
98
+ | 0 | Cloudflare Access (Worker, when enabled) | Edge admission ahead of this table; an admitted invocation carries trusted identity in `ctx.access`. |
99
+ | 1 | Agent namespace, any path starting `/agent` once decoded | First, so no path Access bypasses reaches a human route. Exactly `/agent/<pool>`: admission, then the `cta_` adapter alone, then pool membership; every other spelling is the pool 404 before admission or auth. See [agent endpoints](./auth.md#agent-endpoints). |
100
+ | 1 | `/oauth/client-metadata/<connectorId>` | Public GET-only CIMD for configured self-hosted OAuth connectors; fixed `publicUrl`, no auth or storage. |
101
+ | 1 | Mounted UI routes | Before wildcard OPTIONS, so mutation routes refuse preflight rather than inheriting MCP CORS. No UI module, no routes. |
102
+ | 1 | `/connect/<connectorId>`, `/oauth/callback/<connectorId>` | GET-only browser OAuth routes, before wildcard OPTIONS. Both verify the initiating user and management permission, independent of the UI. |
103
+ | 2 | MCP preflight | Allowed `OPTIONS` on `/mcp*`: 204 without admission or auth. |
104
+ | 2 | Other `OPTIONS` | Auth metadata first, otherwise compatibility CORS preflight. |
105
+ | 3 | `/.well-known/*` | Public auth metadata, or 404. Protected-resource discovery does not authenticate callers or evaluate endpoint grants. |
106
+ | 4 | `/health` | Open and payload-free: health, executor, admission, and deployment metadata. |
107
+ | 6 | `/mcp`, `/mcp/<pool>` | Admission, then auth, then a request-local MCP server. No body is read before auth and endpoint admission. A request naming `subscriptions/listen` takes its permit only after a bounded classification read (413 past 64 KiB); a body-confirmed modern listen takes none and is refused after SDK validation. A non-maintainer at `/mcp`, a non-member, and an undeclared pool get one identical 404; see [pools](./auth.md#pools). |
108
+ | 7 | Other paths | 404. Custom HTTP routes belong to the deployment. |
102
109
 
103
110
  Every response leaves through `withSecurityHeaders`. HTML uses one common
104
111
  security-header helper, including status and error pages. Operator shells
@@ -112,31 +119,46 @@ reordering reads like a harmless refactor.
112
119
  An admitted non-preflight `/mcp` request then takes five steps in
113
120
  `src/routes/mcp.ts`:
114
121
 
115
- 1. **Admit.** Modern `subscriptions/listen` requests take no permit: their
116
- mirrored method and parsed body must both name the unsupported method.
117
- They still pass auth, pool grants, and SDK validation, then receive HTTP
118
- 404 with JSON-RPC `-32601`. Their body classification and auth share the
119
- configured request lifetime, starting before classification. Other requests
120
- take one permit from the deployment-wide pool, taken before auth so an
121
- unauthenticated flood costs a permit rather than a Clerk lookup, and held
122
- until the response _body_ completes, the caller leaves, or its configured
123
- lifetime ends, not until the handler returns.
124
- 2. **Authorize.** Machine tokens precede interactive providers. A provider's
122
+ 1. **Admit.** No request body is read before auth on any endpoint. A request
123
+ whose `Mcp-Method` header names `subscriptions/listen` takes no permit
124
+ before auth. After auth and endpoint admission connecta reads at most
125
+ 64 KiB (`MAX_LISTEN_CLASSIFICATION_BYTES`) of it: past that it answers
126
+ 413 `-33006` and stops reading. A body that confirms a modern listen stays
127
+ permit-free, passes SDK validation, and receives HTTP 404 with JSON-RPC
128
+ `-32601`; anything else takes a permit then, so the header alone bypasses
129
+ nothing. Auth and classification share the configured request lifetime.
130
+ Other requests take one permit from the deployment-wide pool, taken before
131
+ auth so an unauthenticated flood costs a permit rather than a Clerk lookup,
132
+ and held until the response _body_ completes, the caller leaves, or its
133
+ configured lifetime ends, not until the handler returns.
134
+ 2. **Authorize.** `/agent/<pool>` consults only the built-in `cta_` adapter
135
+ (`authorizeAgent`), never with the runtime context that carries
136
+ `ctx.access`. Human endpoints refuse a `cta_` token before recognition or
137
+ storage, and never ask the `cta_` adapter. A provider's
125
138
  synchronous credential recognition selects the sole verifier for an explicit
126
- credential; a refusal never falls back to another identity. Human routes
127
- reject machine credentials without verification. With no recognized
139
+ credential; a refusal never falls back to another identity. With no recognized
128
140
  credential, configured providers are tried in order. A 401 takes its
129
141
  challenge from the actual protected-resource metadata owner. No providers
130
142
  means open development and warns at construction.
131
- 3. **Narrow to the pool.** On `/mcp/<pool>`, look the name up, run its grant
132
- against the identity, then `intersectAccess` the pool with the identity's own
133
- access. A pool can never widen a view; anything else is a 404 naming no pool.
143
+ 3. **Admit to the endpoint.** Plain `/mcp` admits a principal listed in
144
+ `maintainers` (or, with no inbound auth, the anonymous caller) and serves the
145
+ whole catalog. `/mcp/<pool>` admits the pool's human `members`, matched by
146
+ principal, and `/agent/<pool>` its agent members, matched by
147
+ `identity.agent`; both serve exactly the pool's tools
148
+ (`src/membership.ts`, `admitsMaintainer`, `memberPool`, and `agentPool` in
149
+ `src/routes/shared.ts`). Nothing intersects or widens either view; every
150
+ refusal is one 404 naming no pool.
151
+ The endpoint's `readOnly` is the pool's own value, else the deployment's;
152
+ plain `/mcp` uses the deployment's. It travels to the meta-tools and the
153
+ program as one boolean that drops writes from `CatalogService` search and
154
+ describe and makes `writeRefusal` (`src/tool-safety.ts`) refuse every write
155
+ call. Guides and skills are not filtered.
134
156
  4. **Derive the registry view.** One `registry.scoped(...)` call with those
135
- connector ids, exact `connector.tool` addresses when the identity declares a
157
+ connector ids, exact `connector.tool` addresses when the pool declares a
136
158
  narrower slice, and the subject and principal keys. Personal connectors use
137
159
  the principal partition and result paging the subject partition with principal,
138
- endpoint/pool, origin, connector, and tool bindings. Paging rechecks current
139
- grants, pool membership, and trust; no caller parameter selects a partition (`test/identity-scope.test.ts`).
160
+ endpoint/pool, origin, connector, and tool bindings. Paging re-admits the
161
+ caller at its endpoint and rechecks the endpoint's `readOnly`; no caller parameter selects a partition (`test/identity-scope.test.ts`).
140
162
  5. **Serve.** Refuse `?toolkit=` with a 404 — the toolkits are gone
141
163
  ([#178](https://github.com/zackbart/connecta/issues/178)) but their URLs were
142
164
  handed out, and retiring a scoping boundary into fail-open is worse than any
@@ -186,13 +208,14 @@ request-scoped sent-credential set and pass through `redactAgentOutput` before
186
208
  serialization. The SDK's `server/discover` response and registered meta-tool
187
209
  results use that same set; connector contexts retain only an opaque scope.
188
210
 
189
- | Refusal | HTTP status | Application code | Previous code |
190
- | ----------------------------------- | ----------- | ---------------- | ------------- |
191
- | Request capacity exhausted | 503 | `-33001` | `-31001` |
192
- | Server shutting down | 503 | `-33002` | `-31002` |
193
- | Request lifetime exceeded | 504 | `-33003` | none |
194
- | Pool missing or refused | 404 | `-33004` | none |
195
- | Origin or admitted access forbidden | 403 | `-33005` | none |
211
+ | Refusal | HTTP status | Application code | Previous code |
212
+ | ------------------------------------ | ----------- | ---------------- | ------------- |
213
+ | Request capacity exhausted | 503 | `-33001` | `-31001` |
214
+ | Server shutting down | 503 | `-33002` | `-31002` |
215
+ | Request lifetime exceeded | 504 | `-33003` | none |
216
+ | Pool missing or refused | 404 | `-33004` | none |
217
+ | Origin or admitted access forbidden | 403 | `-33005` | none |
218
+ | Listen classification body too large | 413 | `-33006` | none |
196
219
 
197
220
  These allocations are outside JSON-RPC's reserved `-32768..-32000` range and
198
221
  MCP's `-32020..-32099` range. The prior admission codes were also outside those
@@ -217,13 +240,15 @@ or hands out, and a change usually belongs in exactly one of them:
217
240
  | `src/invocation.ts` | One tool call: argument validation, call admission, one-attempt timeout, provider retry hints, result unwrapping, size capping, and the activity record. |
218
241
  | `src/catalog.ts` | Ranking, description summarizing, and the compact and TypeScript schema renderers discovery shows. |
219
242
  | `src/result-shapes.ts` | Bounded runtime-only inference and merging for output shapes learned from successful read-only calls whose providers declared none. |
220
- | `src/program-writes.ts` | Trusted-pool write accounting: write budgets, dispatch draining, and known or unknown outcomes. |
243
+ | `src/program-writes.ts` | Program write accounting on writable endpoints: write budgets, dispatch draining, and known or unknown outcomes. |
221
244
 
222
245
  `src/meta-tools.ts` and `src/execute.ts` are two front doors onto the same two
223
246
  services, `CatalogService` and `InvocationService`. That is the point: a
224
247
  program's `connecta.call` and a top-level `call_tool` reach
225
248
  `InvocationService.invoke` by different routes and get the same admission, the
226
- same credential resolution, and the same fail-closed read-only check.
249
+ same credential resolution, and the same fail-closed read-only check: one
250
+ `writeRefusal` decision per call, from the stored classification, the calling
251
+ surface, and the endpoint's `readOnly`.
227
252
  `test/execute.test.ts` asserts the parity directly, because a sandbox path that
228
253
  quietly diverges is how generated code would mint a capability.
229
254
 
@@ -266,6 +291,40 @@ cannot be faked as sequential leases: consuming a rolling token before a later
266
291
  rule refuses would charge a call that never reached the provider, the exact
267
292
  accounting error a budget exists to prevent. Both layers are pinned by
268
293
  `test/request-admission.test.ts` and `test/call-admission.test.ts`.
294
+ Every defined budget must be an object with `kind: "rolling-window"` and positive
295
+ whole `maxCalls` and `windowMs` values. Malformed budgets, including `false`,
296
+ `null`, `0`, and `""`, throw during construction, even when the rule also sets
297
+ `maxConcurrency`.
298
+
299
+ An admitted tool call can charge sequential auxiliary downstream requests with the internal
300
+ `chargeConnectorCall(ctx)` hook in `src/call-admission-context.ts`. Invocation
301
+ binds the context to its existing permit; the hook returns a Promise over an
302
+ internal Effect program. The charge uses the enclosing call's partition
303
+ and concurrency slot, so a connector with `maxConcurrency: 1` can perform a
304
+ lookup without queuing behind itself. Exhaustion returns the same
305
+ `CallAdmissionError` and retry delay as ordinary call admission, before the
306
+ auxiliary request is sent. Charges against a configured policy also refuse
307
+ after cancellation, permit release, or controller shutdown. The hook adds no
308
+ public connector option or context member. Contexts outside registry invocation
309
+ have no bound permit, so the hook does nothing there. Providers await the hook
310
+ before sending and propagate its refusal; it does not acquire concurrency for
311
+ parallel auxiliary requests.
312
+
313
+ Cloudflare's REST connector charges each uncached zone-ownership lookup used
314
+ to enforce an account pin, in addition to the enclosing tool-call attempt.
315
+ Explicitly pinned zones and cached ownership need no extra request or charge.
316
+ Its limits of three ownership lookups per call and 60 per five minutes per
317
+ connector remain: they bound pin checks separately from the connector's larger
318
+ shared call budget. The Cloudflare provider tests cover these checks through
319
+ registry invocation as well as the existing pin refusals.
320
+
321
+ Only an omitted or `undefined` `callAdmission` selects a connector's default.
322
+ The shared schema rejects malformed defined policies at factory construction,
323
+ and the registry validates custom connector policies before registration.
324
+ Falsy values such as `false`, `null`, `0`, and `""` are construction errors
325
+ (INV-11), never a way to disable admission. The generated provider fixtures
326
+ exercise these refusals and unchanged defaults in
327
+ `test/call-admission-options.test.ts`.
269
328
 
270
329
  ### Production Worker disconnects
271
330
 
@@ -328,6 +387,173 @@ and deletes the Worker in `finally`. It uses no `waitUntil`, which would change
328
387
  the lifetime being measured. The production runtime build number was not
329
388
  exposed by the deployment; these findings are tied to the date and flags above.
330
389
 
390
+ ### Production Access bypass for agent endpoints
391
+
392
+ P0 of [#837](https://github.com/zackbart/connecta/issues/837) asked whether
393
+ `/agent/*` can skip a Worker-level Cloudflare Access application, so an agent
394
+ sends only its `cta_` token, while every other path keeps Access and
395
+ `ctx.access`. It can. The probe ran on 2026-10-15 against a disposable Worker on
396
+ its `workers.dev` hostname, with compatibility date `2026-09-01`, `cf`
397
+ 1.0.0-beta.14, Wrangler 4.114.0, and curl 8.7.1 with `--path-as-is`, so raw
398
+ paths reached the edge unnormalized. The Worker-level application allowed only
399
+ a probe service token. Its unauthenticated refusal was therefore Access's 403
400
+ page rather than a login redirect. The [observations](https://github.com/zackbart/connecta/blob/main/scripts/probes/access-agent-bypass-2026-10-15.json)
401
+ record three identical rounds after each configuration change had produced
402
+ three consecutive matching responses.
403
+
404
+ - **Precedence.** A self-hosted application whose destination is
405
+ `{ "type": "public", "uri": "<host>/agent/*" }`, with one `bypass` policy
406
+ including `everyone`, overrides the Worker-level application on matching
407
+ paths only. Unauthenticated requests reach the Worker there. `/mcp`, `/`,
408
+ and every unmatched path still get the Access refusal. A service token is
409
+ still admitted on them with `ctx.access` from the Worker-level application.
410
+ This confirms Cloudflare's documented hierarchy, and that Worker-level
411
+ protection still covers the `workers.dev` hostname.
412
+ - **`ctx.access` on a bypassed request is present, not absent.** Its `aud` is
413
+ nonempty and is the _path_ application's AUD, not the Worker-level one.
414
+ `getIdentity()` resolved to `undefined` for every credential tested: none, a
415
+ valid service token, a `cta_`-style bearer token, and a forged
416
+ `Cf-Access-Jwt-Assertion`. A service token on a protected path also gave
417
+ `undefined` identity, carrying the Worker-level AUD. A human
418
+ `CF_Authorization` cookie on a bypassed path was not tested, so what
419
+ `getIdentity()` returns there is unknown. That is one more reason the agent
420
+ route ignores Access context rather than relying on its contents.
421
+ - **API shape.** A Worker destination is exclusive to one application. A
422
+ second application with `{ "type": "worker", "worker_id" }` plus `uri` or
423
+ `path` was refused with 409 `destination belongs to another application`. The
424
+ bypass must be hostname-and-path, stored with domain `<host>/agent/*`, and it
425
+ covers only that exact hostname.
426
+ - **Propagation.** Creating or deleting each application took 15–19 seconds to
427
+ settle across runs. While a deployment or application change was propagating,
428
+ the edge returned transient 500 (`error code: 1104`) and 404 (`error code:
429
+ 1042` or `1043`) responses. The recorded run has one such 404, on the
430
+ Worker-level-only service-token control for `/agent/x`. Deleting the path
431
+ application restored the 403 on `/agent/x`.
432
+
433
+ | Raw request path | Access outcome | Worker pathname (+ search) |
434
+ | -------------------------- | -------------- | -------------------------- |
435
+ | `/agent/x` | bypassed | `/agent/x` |
436
+ | `/agent/assistant-alice` | bypassed | `/agent/assistant-alice` |
437
+ | `/agent/` | bypassed | `/agent/` |
438
+ | `/agent/x/` | bypassed | `/agent/x/` |
439
+ | `/agent/x?y` | bypassed | `/agent/x?y` |
440
+ | `/AGENT/x` | bypassed | `/AGENT/x` |
441
+ | `/%61gent/x` | bypassed | `/%61gent/x` |
442
+ | `//agent/x` | bypassed | `//agent/x` |
443
+ | `/agent/%2f/mcp` | bypassed | `/agent/%2f/mcp` |
444
+ | `/agent/x%2f..%2f..%2fmcp` | bypassed | `/agent/x%2f..%2f..%2fmcp` |
445
+ | `/mcp/../agent/x` | bypassed | `/agent/x` |
446
+ | `/mcp` | protected | not reached |
447
+ | `/` | protected | not reached |
448
+ | `/agent` | protected | not reached |
449
+ | `/agentx` | protected | not reached |
450
+ | `/mcp/agent/x` | protected | not reached |
451
+ | `/agent/../mcp` | protected | not reached |
452
+ | `/agent/x/../../mcp` | protected | not reached |
453
+ | `/agent/%2e%2e/mcp` | protected | not reached |
454
+ | `/agent/%2E%2E/mcp` | protected | not reached |
455
+ | `/agent%2f..%2fmcp` | protected | not reached |
456
+
457
+ Access matches after resolving dot segments, including `%2e` forms. It matches
458
+ case-insensitively, decodes unreserved escapes such as `%61`, collapses a
459
+ leading `//`, and treats `%2f` as data, not a separator. The effective Worker
460
+ pathname, `new URL(request.url).pathname`, is the raw request path with case,
461
+ escapes, and slashes untouched, except that dot segments arrive resolved:
462
+ `/mcp/../agent/x` arrives as `/agent/x`. Every path that
463
+ resolves outside `/agent/*`, including every `/mcp` form, stayed protected. The
464
+ inverse is the risk: Access bypasses paths
465
+ that connecta must not treat as an agent pool. Connecta's router compares raw
466
+ pathnames without decoding, case folding, or slash collapsing, so an `/agent/`
467
+ route must keep that property. It should accept exactly
468
+ `/agent/<pool>`, with `<pool>` byte-equal to a declared pool name. It should
469
+ refuse an empty segment, a trailing slash, any extra segment, `%`, uppercase
470
+ variants, and a leading `//`, all with the same 404. It must ignore
471
+ `ctx.access` entirely, since a bypassed request still carries an AUD.
472
+ [Agent endpoints](./auth.md#agent-endpoints) implement exactly this in
473
+ `src/routes/agent-path.ts`, and `test/agent-endpoints.test.ts` replays every
474
+ row of the table above. The hyphenated row stands for any two-part pool name
475
+ such as `<agent>-<user>`. The pool names in the recorded paths were replaced
476
+ with placeholders after the run; the edge behavior for that path shape was
477
+ unaffected.
478
+
479
+ To configure the bypass, keep the Worker-level application described in
480
+ [Cloudflare Access on Workers](./auth.md#cloudflare-access-on-workers). Then,
481
+ for each hostname agents use (the `workers.dev` hostname, each Custom Domain,
482
+ or route host), add a self-hosted Access application. In the dashboard, set
483
+ domain to that hostname and path to `agent/*`, with one policy whose action is
484
+ **Bypass** and whose include rule is **Everyone**. Through the API, that is:
485
+
486
+ ```json
487
+ {
488
+ "type": "self_hosted",
489
+ "name": "connecta agents",
490
+ "destinations": [{ "type": "public", "uri": "connecta.example.com/agent/*" }],
491
+ "policies": [{ "name": "agents bypass", "decision": "bypass", "include": [{ "everyone": {} }] }]
492
+ }
493
+ ```
494
+
495
+ Only the `workers.dev` hostname was probed. A Custom Domain application is the
496
+ same documented hostname-and-path form, but it was not observed here. Do not
497
+ rely on a fixed wait after a change. Changes settled in 15–19 seconds, yet one
498
+ earlier round still saw a 403 on a bypassed path more than 30 seconds after
499
+ the change. Test only after the positive and negative controls agree together
500
+ for several consecutive rounds. The positive controls are bypassed paths
501
+ reaching the Worker with the path AUD and a service token reaching a protected
502
+ path with the Worker-level AUD; the negative controls are `/mcp` and `/`
503
+ returning the Access refusal. The current script requires five such rounds
504
+ after creating the bypass and three after other changes. The recorded run
505
+ predates this: after the bypass it converged only the positive `/agent/x`
506
+ control, then waited a fixed 90 seconds, and its negative controls were checked
507
+ in the recorded rounds rather than before them.
508
+
509
+ To repeat from a repository checkout, run
510
+ `node scripts/probes/access-agent-bypass.mjs --profile <name> [output.json]`.
511
+ `--profile` is required and has no default; every `cf` call passes it
512
+ explicitly. Name the profile for the account that should host the probe; it
513
+ needs
514
+ Access application, policy, and service-token writes, plus Workers script
515
+ writes. The script deploys and deletes the Worker with `cf`. `cf deploy` builds
516
+ through Wrangler 4.136.0 or newer, so the probe installs Wrangler 4.145.0 into
517
+ its temporary project. Wrangler needs no login. The script records only the
518
+ count of existing Access applications, then names everything it creates
519
+ `connecta-probe-<run>`. It force-deletes and verifies each resource in
520
+ `finally`, then checks that the non-probe application ids are unchanged.
521
+ Observations record statuses, outcome classes, pathnames, and which
522
+ application's AUD matched, compared by digest inside the Worker. They contain no
523
+ hostnames, account ids, tokens, cookies, or JWTs. `cf` delete commands prompt
524
+ for confirmation and exit 0 without deleting when stdin is not a terminal, so
525
+ the script passes `--force`. `cf deploy` printed `Deploy complete` and then
526
+ kept running, so the script stops it after that line. The recorded run deployed
527
+ with Wrangler 4.114.0 and used `cf` for Access. A standalone deploy and delete
528
+ verified the `cf` deploy and delete path. The full probe has not been rerun on
529
+ that path.
530
+
531
+ Every subprocess (`cf`, `curl`, `npm`) runs through one runner. It enforces a
532
+ deadline by killing the process group, keeps stdout and stderr in memory, and
533
+ returns only facts: success, exit code, timeout, HTTP status, numeric API
534
+ code, and an error slug from a fixed set (anything else is `unknown`). Commands
535
+ that return data are parsed inside the runner against schemas that copy only
536
+ whitelisted fields: ids, names, counts, booleans, and the Worker's `ctx.access`
537
+ summary. Credentials stay in a holder that prints as `[secret]`. An unexpected
538
+ shape, including `null` where a list belongs, is a failure, never an empty
539
+ result. Errors are fixed diagnostics plus those facts. Teardown deletes every
540
+ tracked id on its own. Then Access applications, service tokens, and Workers
541
+ each get their own bounded discovery-and-delete pass by name, for resources
542
+ whose create response was lost, so a failure in one family never skips
543
+ another. Each discovered resource is deleted independently. Access policies are
544
+ inline on their applications and go with them. A resource counts as gone only
545
+ when a successful lookup omits it, and the final inventory verifies each family
546
+ separately. Run
547
+ `node scripts/probes/access-agent-bypass.mjs --self-test` to check the runner
548
+ offline. It feeds canary secrets, suffixed slugs, malformed JSON, and a stalled
549
+ process tree through it. It also runs teardown against a fake `cf` whose
550
+ application listing always fails, after a lost token-create response, and
551
+ checks that the token is still found and deleted. It also checks that a Worker
552
+ that refuses deletion does not block another Worker's deletion. It runs no real
553
+ `cf`, `curl`, or network command. This
554
+ hardening and the joint convergence are verified offline only and have not run
555
+ against Cloudflare.
556
+
331
557
  ## Storage, credentials, and connectors
332
558
 
333
559
  A deployment has one store. On Workers it is one D1 database
@@ -367,8 +593,8 @@ storage clock. Supply at most one option. Custom adapters must implement both;
367
593
  a delayed absolute-expiry write must not restart a relative TTL at commit time. Each SQL claim
368
594
  is one statement, which SQLite executes atomically and D1 serializes on its
369
595
  primary. Everything that must claim a key exactly once relies on it with no
370
- read-then-write fallback: access-token issuance and
371
- capacity, OAuth handoff ownership, single-use connect links, every downstream
596
+ read-then-write fallback: agent-token slot swaps and
597
+ adoption, OAuth handoff ownership, single-use connect links, every downstream
372
598
  OAuth grant write and consent claim, and the result stash.
373
599
 
374
600
  Every key is built in `src/storage/keys.ts`, which lists each family with its
@@ -537,9 +763,9 @@ configuration, never runtime registration. `authScope: "shared" | "personal"`
537
763
  partitions connecta-owned context — state, credentials, OAuth, catalogs, observed
538
764
  shapes — by principal, and _only_ connecta-owned context: a secret a custom
539
765
  handler closes over is shared JavaScript state, and `remoteMcp()` refuses the
540
- literal-headers-plus-personal version of that mistake. Visibility
541
- (`identity.connectorAccess`) is a separate rule; hiding a connector does not
542
- change who owns its auth.
766
+ literal-headers-plus-personal version of that mistake. Visibility (maintainers
767
+ and pool `tools`) is a separate rule; hiding a connector does not change who
768
+ owns its auth.
543
769
 
544
770
  ### Errors and records
545
771
 
@@ -723,18 +949,26 @@ connection context `create` supplies, the maintained text, then deployment
723
949
  instructions, which append and never replace. `src/provider.ts` imports neither
724
950
  transport, so an `api()` provider gains no MCP client or Effect graph from it
725
951
  (`test/purity.node.test.ts`). The factory carries its `definition`, which build and
726
- check tools read instead of keeping provider lists. All twenty providers
727
- live in their own folders. The eight hosted implementations, including the
952
+ check tools read instead of keeping provider lists. Providers
953
+ live in their own folders. Hosted implementations, including the
728
954
  MCP branches of Notion, Vercel, and Cloudflare, use reviewed presets over
729
- `remoteMcp({ classify })`. The eleven API-only factories retain an internal
955
+ `remoteMcp({ classify })`. The migrated API-only factories retain an internal
730
956
  adapter to the same definition and description-stamping path. GitHub composes hosted tools with a scope-enforced REST complement.
731
- A `"dual"` provider lets `auth` select the implementation
957
+ A `"dual"` provider selects one implementation at construction, usually through `auth`
732
958
  ([decision 0005](https://github.com/zackbart/connecta/blob/main/decisions/0005-auth-selects-implementation.md)):
733
959
  `variants(["auth", "type"], …)` closes each case's options, and
734
960
  `byAuth()` in `src/providers/_shared/rest/dispatch.ts` sends OAuth to
735
961
  `hostedOAuth()` (`remoteMcp()`, OAuth only, the reviewed classification) and a
736
- key to Connecta's REST connector. Stripe, Notion, Cloudflare, and Vercel are dual; `auth` is required.
737
- The REST connector (`src/providers/_shared/rest/`) serves
962
+ key to Connecta's REST connector. Stripe, Notion, Cloudflare, and Vercel require `auth`. Mixpanel is also dual,
963
+ with explicit `via: "mcp" | "api"` and MCP as the unchanged default: service accounts
964
+ reach both implementations, so auth cannot distinguish them. API mode requires
965
+ operator-managed service-account credentials and never falls back to MCP.
966
+ Mixpanel uses the same REST transport, operation matching and value-safety engine
967
+ for a narrow reviewed subset. Its official contracts are YAML and app identity
968
+ has no published response schema, so its guide and drift record carry explicit
969
+ manual review evidence instead of claiming a machine-checkable JSON pin.
970
+
971
+ The generic REST connector (`src/providers/_shared/rest/`) serves
738
972
  `<vendor>_api_search`, `_api_details`, `_api_read`, and `_api_write` over an
739
973
  `OperationIndex` that `npm run providers:spec` compiles offline from the
740
974
  provider's pinned `openapi.source.json` into `openapi.generated.ts`.
@@ -812,8 +1046,90 @@ every vendor ships one reviewed table through one mechanism
812
1046
  labelled or typed secret records lose their value, environment containers
813
1047
  lose their values, header, cookie, and query rules lose theirs, external
814
1048
  destinations keep their origin, and every URL loses userinfo and
815
- credential-named query parameters or fragments. Exemptions are never
816
- shared; each vendor states its own.
1049
+ credential-named query parameters or fragments under its URL policy.
1050
+ Exemptions are never shared; each vendor states its own. Every REST vendor
1051
+ reviews the pinned spec's free-text fields for embedded vendor-minted links
1052
+ and records its verdict and source limitations beside its value-safety table.
1053
+ Stripe, Vercel, and Cloudflare use **reviewed paths plus global signing
1054
+ parameters**. Literal AWS SigV4/Google V4 `X-Amz-`/`X-Goog-` Signature,
1055
+ Credential and Security-Token values are withheld across all decoded string
1056
+ values. Generic credential names (`code`, `key`, `token`, `signature`, `sig`,
1057
+ etc.) and credential-looking fragments apply only to reviewed diagnostic
1058
+ paths and vendor error text. Matching destinations lose their entire query
1059
+ and fragment. The tables list exact operations/resource paths with evidence:
1060
+ Stripe charge failure/outcome messages, Vercel deployment failures and
1061
+ build/runtime log text, Cloudflare Workers/Pages build logs and telemetry
1062
+ message/error and raw string/object source payloads in both events and
1063
+ invocation maps. The shared review harness rejects embedded-URL operation
1064
+ keys absent from each vendor's pinned index and invalid path syntax.
1065
+ Vercel's unavailable full pin remains a review limitation.
1066
+ Generic parameters in descriptions, metadata, nicknames, billing footers,
1067
+ notes, DNS TXT, KV values and operator HTML retain their original bytes,
1068
+ including whole URL strings. Ordinary code/key URLs can be false positives
1069
+ in reviewed diagnostics. Existing structured-field/resource rules, reviewed
1070
+ `url:` per-parameter rules, and payer-facing exceptions still apply.
1071
+ Scans read the original escaped text before URL parsing or serialization;
1072
+ adjacent HTML or Markdown is text even without whitespace. Relative Markdown
1073
+ destinations are checked at their own query/fragment, without re-anchoring
1074
+ a nested proxy URL. Ordinary proxy parameters retain their bytes.
1075
+ Scans precede success/select/named projections and error mapping. Named
1076
+ Vercel streams use the same paths before clamping. Binary responses encoded
1077
+ as base64, including Cloudflare Worker JavaScript served as
1078
+ `application/javascript`, are returned unchanged, without decoding/scanning.
1079
+ Cloudflare omits an envelope cursor that URL sanitizing would change;
1080
+ ordinary cursors retain their bytes. No vendor-wide host/bucket policy is
1081
+ inferred. Each string is scanned with bounded linear passes over disjoint
1082
+ destinations, including large inputs.
1083
+ These three vendors enforce write admission with the shared
1084
+ `carriesWithheldLink(value)` scanner and `refuseWithheldLinks`/
1085
+ `refuseWithheldUpload` helpers, plus `refuseWithheldBase64` for documented
1086
+ base64 JSON fields. Every supported write transport (JSON,
1087
+ form-encoded, raw/base64 bytes, multipart parts and named writes) refuses
1088
+ with `invalid_args` and zero dispatch when a URL destination's own query
1089
+ or fragment contains the exact `[redacted]` placeholder, plain or up to four
1090
+ layers encoded. Upload admission reads decoded bytes and each multipart
1091
+ string/blob. Vercel deployment `files[].data` with `encoding: "base64"`
1092
+ and Cloudflare KV bulk `value` with `base64: true` pass through the same
1093
+ byte inspection with one decode, bounded by the field's own encoded length
1094
+ (decoded bytes are at most three quarters of encoded characters). Cloudflare
1095
+ KV additionally enforces its documented 25 MiB decoded value limit;
1096
+ malformed base64 and values beyond that limit refuse with `invalid_args`.
1097
+ The host's inbound request cap still applies (Node `maxBodyBytes`, or the
1098
+ Workers platform); response and raw-upload limits do not bound JSON fields.
1099
+ JSON admission iteratively checks every key and value, with no separate
1100
+ depth or node-count ceiling below the accepted request's size. Cyclic
1101
+ direct arguments refuse with `invalid_args`; repeated non-cyclic references
1102
+ are inspected. The scans remain linear in the supplied and decoded bytes.
1103
+ Withheld values must not be written back; obtain the original
1104
+ link or omit the field. A placeholder outside a destination is ordinary
1105
+ text. Notion keeps its stricter reviewed-host admission and proxy policy.
1106
+ Vendors whose responses embed the file links they mint can
1107
+ use `withholdSignedLinks(text, hosts)` or
1108
+ `withholdSignedLinksIn(value, hosts)` with a reviewed `FileHosts` policy
1109
+ (`domains` and S3 `buckets`). Notion applies the latter in its `result`
1110
+ hook and before mapping errors, so Markdown, async task results, paging,
1111
+ named-tool results, and `select` all pass through it. The linear scan
1112
+ matches only the current URL/destination's actual authority: a reviewed
1113
+ domain or subdomain, or a reviewed S3 bucket, virtual-hosted or exactly
1114
+ the first path segment on an S3 endpoint. A secondary scheme or `//` in
1115
+ another URL's path or query cannot re-anchor it; those proxy links keep
1116
+ their bytes. Schemes and slashes may be escaped. File links lose their
1117
+ whole query and fragment from a literal, percent-, entity-, or
1118
+ `\u`-encoded opener through the next whitespace, quote, or angle bracket;
1119
+ trailing closing punctuation comes back. AWS SigV4 and Google V4
1120
+ `Signature`, `Credential`, and `Security-Token` parameter values are
1121
+ withheld wherever their literal signing names occur, at any host, with
1122
+ plain or encoded equals signs. `carriesWithheldLink(value, hosts)` is
1123
+ the shared write-admission helper: Notion refuses a write before dispatch
1124
+ when a file link's own query/fragment carries the exact `[redacted]`
1125
+ placeholder, plain or up to four layers encoded. Admission resumes at
1126
+ each actual Markdown destination's close, including adjacent links and
1127
+ images and balanced/escaped parentheses; leading spaces, tabs, and one
1128
+ line ending before the destination are recognized. A marker after the close is
1129
+ ordinary text. This boundary is independent of redaction's fail-closed
1130
+ span. Each string is scanned alone; split rich-text runs, encoded hosts
1131
+ or signing names, bare hosts without `//`, and userinfo before a reviewed
1132
+ host are outside this scan's threat model.
817
1133
  4. **Errors.** A reviewed operation, or an unreviewed one whose request
818
1134
  accepts a credential-named field, answers failures with the vendor's codes
819
1135
  and status but not its text, unless its verdict is wrapped in
@@ -893,7 +1209,7 @@ regardless of wrapping.
893
1209
 
894
1210
  `createConnecta` takes closed typed `ui`, `vault`, and `activity`
895
1211
  slots, with factories at `/ui`, `/credentials`, and `/activity`
896
- and machine tokens at `/auth/access-tokens`. Root exports the contracts, never the implementations, and there
1212
+ and agent tokens at `/auth/access-tokens`. Root exports the contracts, never the implementations, and there
897
1213
  is no module array, runtime registration, or plugin lifecycle. Core keeps
898
1214
  discovery, the executor contract, invocation, permissions, and OAuth callback
899
1215
  verification; an omitted module contributes no runtime work at all.
@@ -942,8 +1258,11 @@ admitted identity to a deployment-config `subject` function and mints a
942
1258
  service-account token as whatever Workspace address it returns. The caller
943
1259
  carries whether an inbound provider _authenticated_ it: an open deployment
944
1260
  admits everyone as the anonymous actor, and the function is never asked about
945
- them. No caller, an unauthenticated one, or no address fails `auth_required`
946
- before any request leaves. The provider never chooses the account and a
1261
+ them. It also carries the admitted pool's `owner`, which core copies from
1262
+ config: an agent acts as it, before and instead of the function, and an agent
1263
+ in a pool without one fails closed. A fixed `subject` address is every
1264
+ caller's. No caller, an unauthenticated one, or no address fails
1265
+ `auth_required` before any request leaves. The provider never chooses the account and a
947
1266
  deployment's own connector still cannot read the caller (PRINCIPLES.md, INV-3; `test/google-workspace-delegation.test.ts`). Its tokens live in a
948
1267
  bounded module-level map, in memory only, keyed by the key's own digest as well
949
1268
  as the account, subject, and scopes. A mint in flight is shared across requests
@@ -1217,7 +1536,8 @@ compiling and configuring the real thing.
1217
1536
  SIGTERM/SIGINT.
1218
1537
  - **Structural mistakes throw at construction.** A duplicate connector id, an
1219
1538
  invalid admission rule or limit, an unknown option at any depth, the old
1220
- boolean `accessTokens` option, a missing executor, or an executor without a
1539
+ boolean `accessTokens` option, malformed or repeated `agents` or agents
1540
+ without `accessTokens`, a missing executor, or an executor without a
1221
1541
  lifecycle brand:
1222
1542
  all refuse to boot (`test/config.test.ts`, `test/registry.test.ts`). Starting
1223
1543
  in the wrong shape is worse than not starting.