@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
package/dist/config.js CHANGED
@@ -12,10 +12,12 @@
12
12
  // reads, with defaults applied once, so no later layer re-derives a value or
13
13
  // re-plumbs a field.
14
14
  import { CONFIG_DEFAULTS as D } from "./config-defaults.js";
15
- import { array, ConfigError, fn, object, opaque, record, required, seconds, readPlain, text, whole, } from "./config-schema.js";
15
+ import { array, ConfigError, object, opaque, record, required, seconds, readPlain, text, whole, } from "./config-schema.js";
16
+ import { assertAgentNames } from "./agent-identity.js";
16
17
  import { assertExecutor } from "./executor-contract.js";
17
18
  import { memoryStorage } from "./storage/memory.js";
18
19
  import { CONNECTA_VERSION } from "./version.js";
20
+ import { isWorkspaceAddress } from "./workspace-address.js";
19
21
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
20
22
  const STORAGE_METHODS = ["get", "set", "delete", "list", "compareAndSet"];
21
23
  const hasMethods = (value, methods) => isObject(value) && methods.every((method) => typeof value[method] === "function");
@@ -134,7 +136,7 @@ const execute = {
134
136
  */
135
137
  watchdogMs: whole({ min: 1, default: D.execute.watchdogMs }),
136
138
  /**
137
- * Trusted-pool writes one program may send, on top of the host-call
139
+ * Writes one program may send on a writable endpoint, on top of the host-call
138
140
  * budget every call already spends. Default 10.
139
141
  */
140
142
  maxWrites: whole({ min: 1, default: D.execute.maxWrites }),
@@ -175,37 +177,27 @@ const admission = {
175
177
  */
176
178
  code: object(codeAdmission),
177
179
  };
178
- const identity = {
180
+ /** The choice every deployment makes explicitly while `trust` migrates (#820). */
181
+ const READ_ONLY_REQUIRED = (path) => `${path} is required. Choose readOnly: true to refuse every write on /mcp, or readOnly: false to let ` +
182
+ "call_destructive_tool and programs write with MCP client approval. Pools inherit it unless they set their own.";
183
+ function assertFlag(value, path) {
184
+ if (typeof value !== "boolean")
185
+ throw new ConfigError(`${path} must be true or false.`);
186
+ }
187
+ /** Shape only; each reference resolves against auth and agents in createConnecta. */
188
+ function assertMemberList(value, path) {
189
+ if (!Array.isArray(value)) {
190
+ throw new ConfigError(`${path} must be an array of principal ids, { namespace, id }, or "agent:<name>".`);
191
+ }
192
+ }
193
+ const pool = {
179
194
  /**
180
- * What this admitted identity may discover and call: `"all"`, or a list
181
- * whose entries are connector ids (the whole connector) and `connector.tool`
182
- * addresses (that tool only), or `{ tool: "connector.tool",
183
- * requireReadOnly: true }` for an exact tool only while its loaded catalog
184
- * explicitly classifies it read-only. Grants are additive: an unrestricted
185
- * connector or address grant wins over the guarded form. An address naming
186
- * a tool the catalog lacks is unreachable and warned once, never widened.
195
+ * Whether this pool's endpoint refuses every write: write tools are absent
196
+ * from search_tools, tool listings, and describe, and every write call is
197
+ * refused. Inherits the deployment's `readOnly` unless set here, in either
198
+ * direction.
187
199
  */
188
- connectorAccess: fn(),
189
- /** Global payload-free activity reads. Defaults to interactive humans. */
190
- activityAccess: fn(),
191
- /** Shared credential and OAuth administration. Defaults to none. */
192
- credentialAdministration: fn(),
193
- /** Client-token lifecycle management by interactive humans. Defaults to false. */
194
- accessTokenManagement: fn(),
195
- /** Connecting or changing the caller's personal account. Defaults to none. */
196
- personalConnection: fn(),
197
- };
198
- const trust = () => opaque({
199
- check: (value, path) => {
200
- if (value !== "trusted" && value !== "read-only") {
201
- throw new ConfigError(`${path} must be "trusted" or "read-only".`);
202
- }
203
- },
204
- resolve: (value) => value ?? "read-only",
205
- });
206
- const pool = {
207
- /** Programs may write only in trusted pools. Default read-only. */
208
- trust: trust(),
200
+ readOnly: opaque({ check: assertFlag }),
209
201
  /** Connector ids and exact `connector.tool` addresses in this pool. */
210
202
  tools: required(opaque({
211
203
  check: (value, path) => {
@@ -215,11 +207,26 @@ const pool = {
215
207
  },
216
208
  })),
217
209
  /**
218
- * Whether this admitted identity may open the pool. Denied by default:
219
- * a pool with no grant serves nobody. A false return, a throw, and an
220
- * undeclared pool name are the same 404.
210
+ * Who may open `/mcp/<name>`: principal ids in the namespace of the one
211
+ * interactive auth provider, `{ namespace, id }`, or `"agent:<name>"` for a
212
+ * declared agent. Required and non-empty; the pool serves exactly its
213
+ * `tools` to these members and the same 404 to everyone else.
214
+ */
215
+ members: required(opaque({ check: assertMemberList }), (path) => `${path} is required: list who may open this pool, as principal ids, { namespace, id }, or "agent:<name>".`),
216
+ /**
217
+ * The Google Workspace address agents in this pool act as, on a Workspace
218
+ * connector whose `subject` is a mapping function. Humans always act as
219
+ * themselves through that mapping, and a fixed `subject` address applies to
220
+ * everyone. Without an owner, an agent's Workspace calls fail
221
+ * `auth_required`.
221
222
  */
222
- grant: fn(),
223
+ owner: opaque({
224
+ check: (value, path) => {
225
+ if (typeof value !== "string" || !isWorkspaceAddress(value.trim())) {
226
+ throw new ConfigError(`${path} must be a Google Workspace email address.`);
227
+ }
228
+ },
229
+ }),
223
230
  };
224
231
  const icon = {
225
232
  src: required(text()),
@@ -240,7 +247,7 @@ const connectaConfig = {
240
247
  connectors: required(array(opaque({
241
248
  check: (value, path) => {
242
249
  if (isObject(value) && Object.hasOwn(value, "approval")) {
243
- throw new ConfigError(`${path}.approval was removed; configure pool trust instead.`);
250
+ throw new ConfigError(`${path}.approval was removed; the MCP client owns approval. Set readOnly: true to refuse writes.`);
244
251
  }
245
252
  if (!isObject(value) ||
246
253
  typeof value.id !== "string" ||
@@ -272,12 +279,27 @@ const connectaConfig = {
272
279
  },
273
280
  resolve: (value) => normalizeAuth(value === undefined ? [] : Array.isArray(value) ? value : [value]),
274
281
  }),
275
- /** Code-derived connection visibility and independent management permissions. */
276
- identity: object(identity),
277
- /** Named tool pools, each served at `/mcp/<name>` to identities its grant admits. */
282
+ /**
283
+ * Agents, declared by name: lowercase letters, digits, `_` and `-`, each
284
+ * unique. Each has one `cta_` token slot that operators issue, rotate, and
285
+ * revoke; nothing outside code adds, renames, or rebinds an agent. Removing
286
+ * a name stops its token on the next request. Requires `accessTokens`.
287
+ * An admitted agent carries `identity.agent` and has no principal, so
288
+ * personal connectors are absent for it. Defaults to none.
289
+ */
290
+ agents: opaque({
291
+ check: (value, path) => assertAgentNames(value, path),
292
+ resolve: (value) => Object.freeze([...(value ?? [])]),
293
+ }),
294
+ /**
295
+ * The people who may use plain `/mcp`, which serves the whole catalog with
296
+ * the deployment's `readOnly`, and who administer the deployment. Same
297
+ * reference forms as pool `members`, people only. Required with inbound
298
+ * auth; a maintainer opens no pool unless listed among its members.
299
+ */
300
+ maintainers: opaque({ check: assertMemberList }),
301
+ /** Named tool pools, each served at `/mcp/<name>` to its members only. */
278
302
  pools: record(object(pool)),
279
- /** Trust of the default /mcp endpoint. Default read-only. */
280
- trust: trust(),
281
303
  /** Exact connector id -> tool name -> verdict. Unknown names fail at catalog publication. */
282
304
  classification: record(record(opaque({
283
305
  check: (value, path) => {
@@ -356,11 +378,11 @@ const connectaConfig = {
356
378
  }
357
379
  },
358
380
  }),
359
- /** Optional managed client tokens, created by accessTokens() from /auth/access-tokens. */
381
+ /** Optional agent tokens for the declared `agents`, created by accessTokens() from /auth/access-tokens. */
360
382
  accessTokens: opaque({
361
383
  check: (value) => {
362
384
  const auth = isObject(value) ? value.auth : undefined;
363
- if (!hasMethods(value, ["handle"]) ||
385
+ if (!hasMethods(value, ["handle", "slots", "forAgents"]) ||
364
386
  !isObject(auth) ||
365
387
  typeof auth.authorize !== "function" ||
366
388
  auth.interactiveOperator) {
@@ -415,10 +437,59 @@ const connectaConfig = {
415
437
  '"@zackbart/connecta/quickjs" on Node, or ' +
416
438
  "workerExecutor({ loader: env.LOADER }) from " +
417
439
  '"@zackbart/connecta/worker" around DynamicWorkerExecutor on Workers.'),
440
+ /**
441
+ * Required. Whether the default /mcp endpoint refuses every write. `true`:
442
+ * write tools are absent from search_tools, tool listings, and describe,
443
+ * and call_tool, call_destructive_tool, and program writes are refused.
444
+ * `false`: call_destructive_tool and programs may write, and the MCP
445
+ * client owns approval. Pools inherit it unless they set their own. It has
446
+ * no default while deployments migrate from `trust` (#820).
447
+ */
448
+ readOnly: required(opaque({ check: assertFlag }), (path) => READ_ONLY_REQUIRED(path)),
418
449
  };
419
450
  const CONFIG = object(connectaConfig);
420
451
  /** Pause-only options #672 removed with `resume_execution`. */
421
452
  const RETIRED_PAUSE_OPTIONS = ["ConnectaConfig.execute.resumableWrites", "ConnectaConfig.execute.pausedRunTtlSeconds"];
453
+ /** Access options pools replaced (#837, decisions/0006). */
454
+ const isRetiredPoolGrant = (path) => /^ConnectaConfig\.pools\.[^.]+\.grant$/.test(path);
455
+ /** Management resolvers maintainers and pool membership replaced (#837, decisions/0006). */
456
+ const RETIRED_IDENTITY = [
457
+ ["credentialAdministration", "maintainers administer shared credentials and OAuth grants. List them in maintainers."],
458
+ [
459
+ "personalConnection",
460
+ "pool members connect and disconnect their own personal connectors in their pools, and maintainers their own. Add people to a pool's members.",
461
+ ],
462
+ [
463
+ "accessTokenManagement",
464
+ "maintainers issue, rotate, and revoke agent tokens from an interactive session. List them in maintainers.",
465
+ ],
466
+ ["activityAccess", "maintainers read activity; activity.readGate can still narrow it. List them in maintainers."],
467
+ ];
468
+ /** `trust`, on the deployment or a pool, was replaced by `readOnly` (#820). */
469
+ const isRetiredTrust = (path) => path === "ConnectaConfig.trust" || (path.startsWith("ConnectaConfig.pools.") && path.endsWith(".trust"));
470
+ /**
471
+ * `identity` was removed whole (#837), so the walk reports only the section.
472
+ * Name each key it still holds, read by descriptor without running a getter
473
+ * or echoing a value, so a retired resolver's message names its replacement.
474
+ */
475
+ function expandRetiredIdentity(config, paths) {
476
+ if (!paths.includes("ConnectaConfig.identity"))
477
+ return paths;
478
+ let keys = [];
479
+ try {
480
+ const descriptor = Reflect.getOwnPropertyDescriptor(config, "identity");
481
+ const value = descriptor && "value" in descriptor ? descriptor.value : undefined;
482
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
483
+ keys = Reflect.ownKeys(value).filter((key) => typeof key === "string");
484
+ }
485
+ }
486
+ catch {
487
+ keys = [];
488
+ }
489
+ if (keys.length === 0)
490
+ return paths;
491
+ return paths.flatMap((path) => path === "ConnectaConfig.identity" ? keys.map((key) => `ConnectaConfig.identity.${key}`) : [path]);
492
+ }
422
493
  function rejectUnknownOptions(paths) {
423
494
  if (paths.length === 0)
424
495
  return;
@@ -431,10 +502,28 @@ function rejectUnknownOptions(paths) {
431
502
  ? "\nMove branding into ui: operatorUi({ branding }) from @zackbart/connecta/ui."
432
503
  : "") +
433
504
  (paths.includes("ConnectaConfig.execute.approval")
434
- ? '\nexecute.approval was removed. Set trust: "trusted" on a pool to allow program writes; the default is read-only.'
505
+ ? "\nexecute.approval was removed. The MCP client owns approval; set readOnly: true on the deployment or a pool to refuse writes."
435
506
  : "") +
436
507
  (paths.some((path) => RETIRED_PAUSE_OPTIONS.includes(path))
437
- ? "\nPrograms no longer pause at writes, so there is nothing to configure: read-only pools route writes through call_destructive_tool; trusted pools allow program writes (issue #672). Delete the option."
508
+ ? "\nPrograms no longer pause at writes, so there is nothing to configure: writable endpoints let programs and call_destructive_tool write; readOnly endpoints refuse every write (issue #672). Delete the option."
509
+ : "") +
510
+ (paths.includes("ConnectaConfig.identity.connectorAccess")
511
+ ? "\nidentity.connectorAccess was removed (#837): pools are the access model. List who may use plain /mcp in maintainers, and give each pool its tools and members. A guarded { tool, requireReadOnly: true } grant becomes a pool with readOnly: true."
512
+ : "") +
513
+ RETIRED_IDENTITY.filter(([key]) => paths.includes(`ConnectaConfig.identity.${key}`))
514
+ .map(([key, replacement]) => `\nidentity.${key} was removed (#837): ${replacement}`)
515
+ .join("") +
516
+ (paths.some((path) => path === "ConnectaConfig.identity" || path.startsWith("ConnectaConfig.identity."))
517
+ ? "\nThe identity section was removed (#837): maintainers and pool members decide access and management. Delete it."
518
+ : "") +
519
+ (paths.includes("ConnectaConfig.grant")
520
+ ? "\nThe top-level grant was removed (#837); list the people who may use plain /mcp in maintainers."
521
+ : "") +
522
+ (paths.some(isRetiredPoolGrant)
523
+ ? "\nPool grant was removed (#837); list who may open the pool in its members."
524
+ : "") +
525
+ (paths.some(isRetiredTrust)
526
+ ? '\ntrust was removed (#820); set readOnly, which every deployment now chooses explicitly. trust: "read-only" becomes readOnly: true, which now also refuses call_destructive_tool. trust: "trusted" becomes readOnly: false. A pool inherits the deployment readOnly unless it sets its own.'
438
527
  : ""));
439
528
  }
440
529
  /** Checks that span fields; each value is already individually valid. */
@@ -443,6 +532,10 @@ function assertCoherent(config) {
443
532
  config.discovery.catalogMaxTtlSeconds > 86_400) {
444
533
  throw new ConfigError("ConnectaConfig.discovery requires catalogMinTtlSeconds <= catalogMaxTtlSeconds <= 86400.");
445
534
  }
535
+ if (config.agents.length > 0 && !config.accessTokens) {
536
+ throw new ConfigError("ConnectaConfig.agents needs accessTokens: accessTokens(storage) from @zackbart/connecta/auth/access-tokens " +
537
+ "holds each agent's token slot.");
538
+ }
446
539
  }
447
540
  /**
448
541
  * Validate a configuration and apply every default. Unknown keys are refused
@@ -461,7 +554,7 @@ export function resolveConfig(config) {
461
554
  */
462
555
  export function readConfig(config) {
463
556
  const read = readPlain(config, "ConnectaConfig", CONFIG);
464
- rejectUnknownOptions(read.unknown);
557
+ rejectUnknownOptions(expandRetiredIdentity(config, read.unknown));
465
558
  const input = read.value;
466
559
  const parsed = CONFIG.parse(input, "ConnectaConfig");
467
560
  assertCoherent(parsed);
@@ -470,10 +563,13 @@ export function readConfig(config) {
470
563
  name: parsed.serverInfo.name ?? "connecta",
471
564
  version: parsed.serverInfo.version ?? CONNECTA_VERSION,
472
565
  };
566
+ // The token module admits exactly the agents this configuration declares.
567
+ const accessTokens = parsed.accessTokens?.forAgents(parsed.agents);
473
568
  const resolved = Object.freeze({
474
569
  ...parsed,
570
+ accessTokens,
475
571
  // A managed-token module contributes one more provider, ordered with the rest.
476
- auth: parsed.accessTokens ? normalizeAuth([parsed.accessTokens.auth, ...parsed.auth]) : parsed.auth,
572
+ auth: accessTokens ? normalizeAuth([accessTokens.auth, ...parsed.auth]) : parsed.auth,
477
573
  serverInfo: Object.freeze(info),
478
574
  });
479
575
  return { input, resolved };
@@ -1,47 +1,18 @@
1
1
  import type { ToolAccess } from "./registry.js";
2
- import type { AuthenticatedIdentity } from "./types.js";
3
- /** A declared pool after construction-time validation. */
4
- export interface ResolvedPool {
5
- trust: import("./tool-safety.js").PoolTrust;
6
- access: ConnectorAccess;
7
- grant(identity: Readonly<AuthenticatedIdentity>): boolean | Promise<boolean>;
8
- }
9
2
  export declare const POOL_NAME_RE: RegExp;
10
3
  /**
11
4
  * One derived view: which connectors, and for connectors granted by address
12
5
  * only, which tools. A connector absent from `toolAccess` is visible whole.
13
- * `guardedToolAccess` is a subset of `toolAccess` checked against the loaded
14
- * catalog each time tools are discovered or invoked.
15
6
  */
16
7
  export interface ConnectorAccess {
17
8
  connectorIds: "all" | readonly string[];
18
9
  toolAccess?: ToolAccess;
19
- /** Exact tool grants that also require a current explicit read-only annotation. */
20
- guardedToolAccess?: ToolAccess;
21
- }
22
- interface ReadOnlyToolGrant {
23
- tool: string;
24
- requireReadOnly: true;
25
10
  }
26
- export type ConnectorGrant = string | ReadOnlyToolGrant;
27
11
  /**
28
- * Normalize a grant list. A bare connector id grants every tool on that
12
+ * Normalize a pool's `tools`. A bare connector id grants every tool on that
29
13
  * connector; a `connector.tool` address grants one tool. Grants are additive,
30
14
  * so a bare id beside addresses for the same connector means the whole
31
- * connector. Identity resolution can opt into `{ tool, requireReadOnly: true }`
32
- * for a fixed address that also needs a current explicit read-only annotation;
33
- * pool declarations remain strings. Anything else throws, and the caller
34
- * decides whether that is a construction failure or a 403: a grant that
35
- * cannot be parsed must never fail open.
36
- */
37
- export declare function parseConnectorAccess(value: unknown, options?: {
38
- allowReadOnly?: boolean;
39
- }): ConnectorAccess;
40
- /**
41
- * The view a pool endpoint serves: the pool's grants, never wider than the
42
- * identity's own. A connector or tool outside either side is gone; a
43
- * connector whose tool intersection is empty is gone too, so the pool can
44
- * only narrow what the identity resolver already allowed.
15
+ * connector. Anything else throws: a grant that cannot be parsed must never
16
+ * fail open.
45
17
  */
46
- export declare function intersectAccess(ceiling: ConnectorAccess, pool: ConnectorAccess): ConnectorAccess;
47
- export {};
18
+ export declare function parseConnectorAccess(value: unknown): ConnectorAccess;
@@ -2,121 +2,37 @@ import { hasControlCharacters } from "./tool-name.js";
2
2
  export const POOL_NAME_RE = /^[a-z0-9_-]+$/;
3
3
  const CONNECTOR_ID_RE = /^[a-z0-9_-]+$/;
4
4
  // MCP does not restrict tool names, and remote servers ship spaced and
5
- // non-ASCII ones. Only control characters are refused, so a grant for a
6
- // legitimately named tool cannot 403 the whole identity at request time.
5
+ // non-ASCII ones. Only control characters are refused, so a pool naming a
6
+ // legitimately named tool still constructs.
7
7
  const TOOL_ADDRESS_RE = /^[a-z0-9_-]+\..{1,256}$/su;
8
8
  /**
9
- * Normalize a grant list. A bare connector id grants every tool on that
9
+ * Normalize a pool's `tools`. A bare connector id grants every tool on that
10
10
  * connector; a `connector.tool` address grants one tool. Grants are additive,
11
11
  * so a bare id beside addresses for the same connector means the whole
12
- * connector. Identity resolution can opt into `{ tool, requireReadOnly: true }`
13
- * for a fixed address that also needs a current explicit read-only annotation;
14
- * pool declarations remain strings. Anything else throws, and the caller
15
- * decides whether that is a construction failure or a 403: a grant that
16
- * cannot be parsed must never fail open.
12
+ * connector. Anything else throws: a grant that cannot be parsed must never
13
+ * fail open.
17
14
  */
18
- export function parseConnectorAccess(value, options = {}) {
19
- if (value === "all")
20
- return { connectorIds: "all" };
15
+ export function parseConnectorAccess(value) {
21
16
  if (!Array.isArray(value))
22
17
  throw new Error("invalid connector permission");
23
18
  const whole = new Set();
24
- const plain = new Map();
25
- const guarded = new Map();
26
- for (const entry of value) {
27
- if (typeof entry === "string" && CONNECTOR_ID_RE.test(entry)) {
28
- whole.add(entry);
19
+ const partial = new Map();
20
+ for (const address of value) {
21
+ if (typeof address === "string" && CONNECTOR_ID_RE.test(address)) {
22
+ whole.add(address);
29
23
  continue;
30
24
  }
31
- const isGuarded = typeof entry !== "string";
32
- if (isGuarded &&
33
- (!options.allowReadOnly ||
34
- entry === null ||
35
- typeof entry !== "object" ||
36
- Array.isArray(entry) ||
37
- !Object.hasOwn(entry, "tool") ||
38
- !Object.hasOwn(entry, "requireReadOnly") ||
39
- Object.keys(entry).length !== 2 ||
40
- entry.requireReadOnly !== true)) {
41
- throw new Error("invalid connector permission");
42
- }
43
- const address = isGuarded ? entry.tool : entry;
44
25
  if (typeof address !== "string" || !TOOL_ADDRESS_RE.test(address) || hasControlCharacters(address)) {
45
26
  throw new Error("invalid connector permission");
46
27
  }
47
28
  const dot = address.indexOf(".");
48
29
  const connectorId = address.slice(0, dot);
49
- const target = isGuarded ? guarded : plain;
50
- const tools = target.get(connectorId) ?? new Set();
30
+ const tools = partial.get(connectorId) ?? new Set();
51
31
  tools.add(address.slice(dot + 1));
52
- target.set(connectorId, tools);
53
- }
54
- for (const id of whole) {
55
- plain.delete(id);
56
- guarded.delete(id);
57
- }
58
- for (const [id, names] of plain) {
59
- const restricted = guarded.get(id);
60
- if (!restricted)
61
- continue;
62
- for (const name of names)
63
- restricted.delete(name);
64
- if (restricted.size === 0)
65
- guarded.delete(id);
66
- }
67
- const partial = new Map(plain);
68
- for (const [id, names] of guarded) {
69
- const combined = partial.get(id) ?? new Set();
70
- for (const name of names)
71
- combined.add(name);
72
- partial.set(id, combined);
32
+ partial.set(connectorId, tools);
73
33
  }
34
+ for (const id of whole)
35
+ partial.delete(id);
74
36
  const connectorIds = [...new Set([...whole, ...partial.keys()])];
75
- return {
76
- connectorIds,
77
- ...(partial.size > 0 ? { toolAccess: partial } : {}),
78
- ...(guarded.size > 0 ? { guardedToolAccess: guarded } : {}),
79
- };
80
- }
81
- /**
82
- * The view a pool endpoint serves: the pool's grants, never wider than the
83
- * identity's own. A connector or tool outside either side is gone; a
84
- * connector whose tool intersection is empty is gone too, so the pool can
85
- * only narrow what the identity resolver already allowed.
86
- */
87
- export function intersectAccess(ceiling, pool) {
88
- if (pool.connectorIds === "all")
89
- return ceiling;
90
- const allowedIds = ceiling.connectorIds === "all" ? null : new Set(ceiling.connectorIds);
91
- const connectorIds = [];
92
- const toolAccess = new Map();
93
- const guardedToolAccess = new Map();
94
- for (const id of pool.connectorIds) {
95
- if (allowedIds && !allowedIds.has(id))
96
- continue;
97
- const fromPool = pool.toolAccess?.get(id);
98
- const fromCeiling = ceiling.toolAccess?.get(id);
99
- if (fromPool && fromCeiling) {
100
- const both = new Set([...fromPool].filter((name) => fromCeiling.has(name)));
101
- if (both.size === 0)
102
- continue;
103
- toolAccess.set(id, both);
104
- }
105
- else if (fromPool ?? fromCeiling) {
106
- toolAccess.set(id, (fromPool ?? fromCeiling));
107
- }
108
- const guarded = ceiling.guardedToolAccess?.get(id);
109
- if (guarded) {
110
- const allowed = toolAccess.get(id);
111
- const retained = allowed ? new Set([...guarded].filter((name) => allowed.has(name))) : guarded;
112
- if (retained.size > 0)
113
- guardedToolAccess.set(id, retained);
114
- }
115
- connectorIds.push(id);
116
- }
117
- return {
118
- connectorIds,
119
- ...(toolAccess.size > 0 ? { toolAccess } : {}),
120
- ...(guardedToolAccess.size > 0 ? { guardedToolAccess } : {}),
121
- };
37
+ return { connectorIds, ...(partial.size > 0 ? { toolAccess: partial } : {}) };
122
38
  }
@@ -9,8 +9,14 @@ export interface ConnectorCaller {
9
9
  * with or without a subject, a Connecta-issued access token.
10
10
  */
11
11
  authenticated: boolean;
12
- /** The `/mcp/<pool>` the call arrived on, when it was a pool. */
12
+ /** The pool the call arrived on, at `/mcp/<pool>` or `/agent/<pool>`. */
13
13
  pool?: string;
14
+ /**
15
+ * That pool's configured `owner`, the Workspace address an agent acts as
16
+ * (decisions/0006). Core copies it from the admitted pool's config; no
17
+ * request can set it, and humans never act as it.
18
+ */
19
+ poolOwner?: string;
14
20
  }
15
21
  export declare function attachCaller(ctx: ConnectorContext, caller: ConnectorCaller | undefined): ConnectorContext;
16
22
  export declare function callerOf(ctx: ConnectorContext): ConnectorCaller | undefined;
@@ -8,8 +8,9 @@
8
8
  // - maintained providers that act *as* the caller downstream — Google
9
9
  // Workspace through domain-wide delegation (`src/providers/_shared/google/`) — which
10
10
  // hand the identity to a deployment-config function that names the
11
- // downstream subject (PRINCIPLES.md INV-3, #678). The
12
- // provider never decides whose account to open; config does, from this.
11
+ // downstream subject (PRINCIPLES.md INV-3, #678), or, for an agent, use the
12
+ // admitted pool's configured owner (decisions/0006). The provider never
13
+ // decides whose account to open; config does, from this.
13
14
  //
14
15
  // Core sets it from the authorization that admitted the request, on the scoped
15
16
  // registry view that request receives, and `api()` hands handlers that same
@@ -146,10 +146,33 @@ export interface ApiOptions {
146
146
  /** The closed options api() accepts; see `assertKnownOptions`. */
147
147
  export declare const API_OPTIONS: import("../config-schema.js").Field<unknown, unknown, true> & {
148
148
  readonly shape: {
149
- callAdmission: import("../config-schema.js").Field<unknown, unknown, true> & {
150
- readonly shape: {
151
- rules: import("../config-schema.js").Field<unknown[], readonly unknown[] | undefined, true>;
152
- maxPartitions: import("../config-schema.js").Field<unknown, unknown, true>;
149
+ callAdmission: Omit<import("../config-schema.js").Field<unknown, ConnectorCallAdmissionPolicy | undefined, true>, "parse"> & {
150
+ parse(value: unknown, path: string): ConnectorCallAdmissionPolicy;
151
+ readonly shape: import("../config-schema.js").Shape & {
152
+ maxPartitions: import("../config-schema.js").Field<number, number | undefined, true>;
153
+ rules: import("../config-schema.js").Field<{
154
+ maxConcurrency?: number | undefined;
155
+ maxQueueSize?: number | undefined;
156
+ queueTimeoutMs?: number | undefined;
157
+ retryAfterMs?: number | undefined;
158
+ partitionKey?: ((input: Readonly<import("../types.js").ConnectorCallAdmissionInput>) => string) | undefined;
159
+ budget?: {
160
+ kind: "rolling-window";
161
+ maxCalls: number;
162
+ windowMs: number;
163
+ } | undefined;
164
+ }[], readonly {
165
+ readonly maxConcurrency: number | undefined;
166
+ readonly maxQueueSize: number | undefined;
167
+ readonly queueTimeoutMs: number | undefined;
168
+ readonly retryAfterMs: number | undefined;
169
+ readonly partitionKey: ((input: Readonly<import("../types.js").ConnectorCallAdmissionInput>) => string) | undefined;
170
+ readonly budget: {
171
+ readonly kind: "rolling-window";
172
+ readonly maxCalls: number;
173
+ readonly windowMs: number;
174
+ } | undefined;
175
+ }[], false>;
153
176
  };
154
177
  };
155
178
  usageGuide: import("../config-schema.js").Field<unknown, unknown, true> & {
@@ -90,6 +90,8 @@ function describedApiAuth(opts) {
90
90
  */
91
91
  export function apiConnector(id, opts, oauth) {
92
92
  opts = assertKnownOptions(opts, `api(${JSON.stringify(id)})`, API_OPTIONS);
93
+ if (opts.callAdmission !== undefined)
94
+ CALL_ADMISSION.parse(opts.callAdmission, `api(${JSON.stringify(id)}).callAdmission`);
93
95
  assertStaticToolNames(opts.tools, `api(${JSON.stringify(id)}).tools`);
94
96
  if (opts.oauth !== undefined && oauth === undefined) {
95
97
  throw new Error(`api() connector "${id}" declares oauth but was built without its grant; construct it with api().`);
@@ -56,6 +56,8 @@ interface GuardedResponse {
56
56
  readonly status: number;
57
57
  readonly ok: boolean;
58
58
  readonly headers: Headers;
59
+ /** Cancel an unread body without waiting for the vendor to emit a chunk. */
60
+ cancel(): Promise<void>;
59
61
  /** The body as bytes. */
60
62
  bytes(): Promise<Uint8Array>;
61
63
  /** The body decoded as UTF-8. */
@@ -124,6 +126,13 @@ export interface GuardedFetchOptions {
124
126
  * absurd response is a fact about the API, not about HTTP.
125
127
  */
126
128
  maxResponseBytes: number;
129
+ /**
130
+ * Inspect the exact URL about to be fetched — confined, normalized, with its
131
+ * query encoded — and throw to refuse it. A policy that reads this URL reads
132
+ * what the downstream server will route, not a spelling of it. Runs before
133
+ * authentication, so a refused request never resolves a credential.
134
+ */
135
+ admit?: (url: URL, request: GuardedRequest) => void;
127
136
  }
128
137
  /** Sends one already-guarded request on behalf of one call's context. */
129
138
  type GuardedFetcher = (url: string, init: RequestInit, ctx: ConnectorContext) => Promise<Response>;
@@ -181,6 +181,9 @@ function boundedResponse(provider, response, limit) {
181
181
  status: response.status,
182
182
  ok: response.ok,
183
183
  headers: response.headers,
184
+ cancel: async () => {
185
+ await response.body?.cancel();
186
+ },
184
187
  bytes: async () => new Uint8Array(),
185
188
  text: async () => "",
186
189
  json: emptyJson,
@@ -228,6 +231,9 @@ function boundedResponse(provider, response, limit) {
228
231
  status: response.status,
229
232
  ok: response.ok,
230
233
  headers: response.headers,
234
+ cancel: async () => {
235
+ await response.body?.cancel();
236
+ },
231
237
  bytes,
232
238
  text,
233
239
  json,
@@ -307,6 +313,7 @@ export function guardedFetch(options) {
307
313
  throw new Error(`A ${provider} request cannot carry both a JSON body and a raw body.`);
308
314
  }
309
315
  const url = confinedUrl(provider, base, basePath, request);
316
+ options.admit?.(url, request);
310
317
  const headers = { ...constantHeaders };
311
318
  for (const [name, value] of Object.entries(request.headers ?? {})) {
312
319
  if (value !== undefined)
@@ -1024,6 +1024,8 @@ function registrationErrorCode(body) {
1024
1024
  */
1025
1025
  export function remoteMcp(id, opts) {
1026
1026
  opts = assertKnownOptions(opts, `remoteMcp(${JSON.stringify(id)})`, REMOTE_MCP_OPTIONS);
1027
+ if (opts.callAdmission !== undefined)
1028
+ CALL_ADMISSION.parse(opts.callAdmission, `remoteMcp(${JSON.stringify(id)}).callAdmission`);
1027
1029
  const skillsEnabled = opts.skills;
1028
1030
  if (skillsEnabled !== undefined && typeof skillsEnabled !== "boolean") {
1029
1031
  throw new Error(`[connecta] connector "${id}" skills must be a boolean.`);