@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,6 +1,7 @@
1
1
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
2
2
  import type { ConnectaConfig, ResolvedConfig } from "./config.js";
3
- import type { ConnectorAuthDescription, ConnectorDescription, ConnectorToolDescription, DescribedEndpoint } from "./types.js";
3
+ import type { ResolvedPool } from "./membership.js";
4
+ import type { ConnectorAuthDescription, ConnectorDescription, ConnectorToolDescription, DescribedEndpoint, IdentityReference } from "./types.js";
4
5
  /** Where a limit's value came from. */
5
6
  export type ConfigValueSource = "default" | "config";
6
7
  /** One limit and where its value came from. `null` means unset (no bound). */
@@ -8,6 +9,15 @@ export interface DescribedLimit<T = number> {
8
9
  value: T | null;
9
10
  source: ConfigValueSource;
10
11
  }
12
+ /** A resolved pool member: a principal, or a declared agent by name. */
13
+ export type DescribedMember = {
14
+ kind: "human";
15
+ namespace: string;
16
+ id: string;
17
+ } | {
18
+ kind: "agent";
19
+ name: string;
20
+ };
11
21
  /** One connector as `describeConfig()` reports it. */
12
22
  export interface DescribedConnector {
13
23
  id: string;
@@ -86,8 +96,13 @@ export interface ConnectaConfigDescription {
86
96
  execute: Record<keyof typeof CONFIG_DEFAULTS.execute, DescribedLimit>;
87
97
  requests: Record<keyof typeof CONFIG_DEFAULTS.admission.requests, DescribedLimit>;
88
98
  };
89
- /** Trust and exact classification overrides from deployment code. */
90
- trust: "trusted" | "read-only";
99
+ /** Whether the default /mcp endpoint refuses every write, and exact classification overrides from deployment code. */
100
+ readOnly: boolean;
101
+ /** The principals plain `/mcp` admits, resolved, in declaration order. Empty on an open deployment. */
102
+ maintainers: Array<{
103
+ namespace: string;
104
+ id: string;
105
+ }>;
91
106
  classification: Record<string, Record<string, "read" | "write">>;
92
107
  /** Inbound auth providers in the order they are tried. */
93
108
  auth: Array<{
@@ -95,13 +110,20 @@ export interface ConnectaConfigDescription {
95
110
  interactive: boolean;
96
111
  ui?: string;
97
112
  }>;
98
- identity: Record<"connectorAccess" | "activityAccess" | "credentialAdministration" | "accessTokenManagement" | "personalConnection", "default" | "custom">;
113
+ /** Agents declared in code, by name, in declaration order. Never token state. */
114
+ agents: string[];
115
+ /**
116
+ * `readOnly` is resolved: the pool's own value, else the deployment's.
117
+ * `members` are resolved references in declaration order. `owner` is the
118
+ * Workspace address agents act as, or null.
119
+ */
99
120
  pools: Array<{
100
121
  name: string;
101
122
  path: string;
102
123
  tools: string[];
103
- hasGrant: boolean;
104
- trust: "trusted" | "read-only";
124
+ members: DescribedMember[];
125
+ readOnly: boolean;
126
+ owner: string | null;
105
127
  }>;
106
128
  modules: {
107
129
  ui: {
@@ -122,7 +144,6 @@ export interface ConnectaConfigDescription {
122
144
  };
123
145
  accessTokens: {
124
146
  enabled: boolean;
125
- maxActive?: number;
126
147
  };
127
148
  };
128
149
  /** Every accepted store implements `list` and `compareAndSet`, so only its kind is told. */
@@ -154,6 +175,10 @@ export interface DescribeConfigInput {
154
175
  registry: import("./registry.js").Registry;
155
176
  raw: ConnectaConfig;
156
177
  config: ResolvedConfig;
178
+ /** Resolved `maintainers`. */
179
+ maintainers: readonly IdentityReference[];
180
+ /** Resolved pools; members are read from here, never from raw references. */
181
+ pools: ReadonlyMap<string, ResolvedPool>;
157
182
  executorName: string | undefined;
158
183
  /** True when the executor owns admission, so `admission.code` is unused. */
159
184
  executorAdmits: boolean;
@@ -183,13 +183,6 @@ function describeConnector(connector, deploymentCap, registry) {
183
183
  ...(connector.staticTools ? { tools: describedTools(registry.describeStaticTools(connector.id) ?? []) } : {}),
184
184
  };
185
185
  }
186
- const IDENTITY_KEYS = [
187
- "connectorAccess",
188
- "activityAccess",
189
- "credentialAdministration",
190
- "accessTokenManagement",
191
- "personalConnection",
192
- ];
193
186
  const STORE_KINDS = new Set(["memory", "d1", "sqlite"]);
194
187
  const storeKind = (described) => typeof described?.kind === "string" && STORE_KINDS.has(described.kind)
195
188
  ? described.kind
@@ -198,7 +191,6 @@ const storeKind = (described) => typeof described?.kind === "string" && STORE_KI
198
191
  export function describeConfig(input) {
199
192
  const { raw, config } = input;
200
193
  const brand = resolveBranding(config.ui?.branding);
201
- const accessTokens = config.accessTokens?.describe?.();
202
194
  const storage = config.storage;
203
195
  const activityStore = config.activity?.store.describe?.();
204
196
  const D = CONFIG_DEFAULTS;
@@ -240,20 +232,24 @@ export function describeConfig(input) {
240
232
  execute: limitGroup(config.execute, ownValue(raw, "execute"), Object.keys(D.execute)),
241
233
  requests: limitGroup(config.admission.requests, ownValue(ownValue(raw, "admission"), "requests"), Object.keys(D.admission.requests)),
242
234
  },
243
- trust: config.trust,
235
+ readOnly: config.readOnly,
236
+ maintainers: input.maintainers.map(({ namespace, id }) => ({ namespace: String(namespace), id: String(id) })),
244
237
  classification: structuredClone(config.classification ?? {}),
245
238
  auth: config.auth.map((provider) => ({
246
239
  kind: String(provider.kind),
247
240
  interactive: provider.interactiveOperator === true,
248
241
  ...(str(provider.uiAuth?.kind) !== undefined ? { ui: provider.uiAuth.kind } : {}),
249
242
  })),
250
- identity: Object.fromEntries(IDENTITY_KEYS.map((key) => [key, typeof config.identity[key] === "function" ? "custom" : "default"])),
243
+ agents: [...config.agents],
251
244
  pools: Object.entries(config.pools ?? {}).map(([name, pool]) => ({
252
- trust: pool.trust,
245
+ readOnly: pool.readOnly ?? config.readOnly,
253
246
  name,
254
247
  path: `/mcp/${name}`,
255
248
  tools: pool.tools.map(String),
256
- hasGrant: typeof pool.grant === "function",
249
+ members: (input.pools.get(name)?.members ?? []).map((member) => member.kind === "agent"
250
+ ? { kind: "agent", name: member.name }
251
+ : { kind: "human", namespace: member.namespace, id: member.id }),
252
+ owner: input.pools.get(name)?.owner ?? null,
257
253
  })),
258
254
  modules: {
259
255
  ui: { enabled: config.ui !== undefined },
@@ -276,12 +272,7 @@ export function describeConfig(input) {
276
272
  },
277
273
  }
278
274
  : { enabled: false },
279
- accessTokens: config.accessTokens
280
- ? {
281
- enabled: true,
282
- ...(num(accessTokens?.maxActive) !== undefined ? { maxActive: accessTokens.maxActive } : {}),
283
- }
284
- : { enabled: false },
275
+ accessTokens: { enabled: config.accessTokens !== undefined },
285
276
  },
286
277
  storage: {
287
278
  configured: raw.storage !== undefined,
package/dist/errors.d.ts CHANGED
@@ -33,7 +33,7 @@ export type ConnectorCallErrorCode = "timeout" | "auth_required" | "downstream_o
33
33
  * Adding a member fails typecheck until {@link CLASSIFICATION_CODE_TABLE}
34
34
  * lists it.
35
35
  */
36
- export type ClassificationCode = ConnectorCallErrorCode | ExecutorAdmissionErrorCode | "cancelled" | "unknown_address" | "unknown_tool" | "ambiguous_tool_alias" | "catalog_lookup_failed" | "result_too_large" | "destructive_tool_requires_approval" | "auth_replay_refused" | "write_outcome_unknown" | "result_processing_failed" | "budget_exceeded" | "executor_failed" | "program_error";
36
+ export type ClassificationCode = ConnectorCallErrorCode | ExecutorAdmissionErrorCode | "cancelled" | "unknown_address" | "unknown_tool" | "ambiguous_tool_alias" | "catalog_lookup_failed" | "result_too_large" | "destructive_tool_requires_approval" | "read_only_endpoint" | "auth_replay_refused" | "write_outcome_unknown" | "result_processing_failed" | "budget_exceeded" | "executor_failed" | "program_error";
37
37
  /** `value` when it is a code connecta assigns, else undefined. */
38
38
  export declare function classificationCode(value: unknown): ClassificationCode | undefined;
39
39
  /** One bounded, payload-free explanation of an input-schema mismatch. */
package/dist/errors.js CHANGED
@@ -28,6 +28,7 @@ const CLASSIFICATION_CODE_TABLE = {
28
28
  catalog_lookup_failed: true,
29
29
  result_too_large: true,
30
30
  destructive_tool_requires_approval: true,
31
+ read_only_endpoint: true,
31
32
  auth_replay_refused: true,
32
33
  write_outcome_unknown: true,
33
34
  result_processing_failed: true,
package/dist/execute.js CHANGED
@@ -412,6 +412,8 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
412
412
  const catalog = new CatalogService(registry, baseUrl, {
413
413
  requestScope,
414
414
  requestSignal: hostAccessSignal,
415
+ // connecta.search and connecta.describe never show a read-only endpoint's writes.
416
+ readOnly: limits.readOnly,
415
417
  // A program that just missed an address cannot call search_tools.
416
418
  searchRoute: "connecta.search",
417
419
  concurrency: limits.discoveryConcurrency,
@@ -455,7 +457,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
455
457
  }
456
458
  return value;
457
459
  };
458
- /** Budget and account for writes admitted by the pool trust decision. */
460
+ /** Budget and account for writes a writable endpoint admits. */
459
461
  const invocationContext = (sending, timeoutMs) => ({
460
462
  source: "execute_code",
461
463
  sentSecrets,
@@ -463,9 +465,9 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
463
465
  ...(signal !== undefined ? { requestSignal: signal } : {}),
464
466
  ...(limits.dispatchController ? { dispatchSignal: limits.dispatchController.signal } : {}),
465
467
  unwrapResult: true,
466
- trust: limits.trust,
468
+ readOnly: limits.readOnly,
467
469
  processResult: async (value, resolved, secrets, format, raw) => {
468
- const page = (data, valueFormat, wireBytes) => pageProgramResult(registry, baseUrl, resolved, data, valueFormat, secrets, limits.trust, requestScope, wireBytes);
470
+ const page = (data, valueFormat, wireBytes) => pageProgramResult(registry, baseUrl, resolved, data, valueFormat, secrets, requestScope, wireBytes);
469
471
  const envelope = resolved.connector.kind === "mcp" && raw !== null && typeof raw === "object"
470
472
  ? raw
471
473
  : undefined;
@@ -605,7 +607,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
605
607
  normalizeTimeoutMs(limits.defaultToolTimeoutMs) ??
606
608
  hostCallTimeoutMs;
607
609
  const timeoutMs = Math.min(requestedMs, Math.max(1, (limits.programDeadlineAt ?? Infinity) - Date.now()));
608
- // A trusted-pool write settles here — unknown if the call never returned an
610
+ // A program write settles here — unknown if the call never returned an
609
611
  // outcome. Credentials are filtered from arguments; submitted private values are not.
610
612
  const sending = {};
611
613
  const outcome = yield* invocation
@@ -656,7 +658,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
656
658
  : Effect.raceAllFirst([reading, fromSignal(hostAccessSignal).pipe(Effect.mapError(cancelled))])).pipe(Effect.onExit((exit) => Effect.sync(() => diagnostics?.recordCatalog(operation, Date.now() - started, Exit.isSuccess(exit), Exit.isSuccess(exit) ? exit.value : undefined))));
657
659
  });
658
660
  const meta = createMetaTools(registry, baseUrl, {
659
- trust: limits.trust,
661
+ readOnly: limits.readOnly,
660
662
  requestScope,
661
663
  onResultRead: (dependencies) => limits.resultDependencies?.push(...dependencies),
662
664
  });
@@ -1077,7 +1079,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
1077
1079
  defaultToolTimeoutMs: config.defaultToolTimeoutMs,
1078
1080
  programDeadlineAt: Date.now() + watchdog.ms,
1079
1081
  defer: config.defer,
1080
- trust: config.trust,
1082
+ readOnly: config.readOnly,
1081
1083
  maxWrites: config.maxWrites,
1082
1084
  programWrites,
1083
1085
  resultDependencies,
@@ -1087,7 +1089,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
1087
1089
  return yield* Effect.fail(new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled during sandbox setup."));
1088
1090
  }
1089
1091
  const admitted = lease;
1090
- // A trusted-pool write may still be on the wire when the program settles:
1092
+ // A program write may still be on the wire when the program settles:
1091
1093
  // close, so a write gated after this is not sent, then let the ones
1092
1094
  // already dispatched finish before the scope aborts them — an
1093
1095
  // abandoned write would be one whose outcome nobody knows.
@@ -1114,7 +1116,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
1114
1116
  emitted,
1115
1117
  diagnostics,
1116
1118
  program,
1117
- guardResult: (value, maxChars) => guardProgramReturn(value, registry, baseUrl, [...new Map(resultDependencies.map((dependency) => [JSON.stringify(dependency), dependency])).values()], sentSecrets, config.trust, logger, maxChars, programWrites.hasSucceeded),
1119
+ guardResult: (value, maxChars) => guardProgramReturn(value, registry, baseUrl, [...new Map(resultDependencies.map((dependency) => [JSON.stringify(dependency), dependency])).values()], sentSecrets, logger, maxChars, programWrites.hasSucceeded),
1118
1120
  };
1119
1121
  const exited = Effect.exit(Effect.scoped(run)).pipe(Effect.flatMap((exit) =>
1120
1122
  // Releasing a QuickJS lease ends its child and rejects execute() with
@@ -1336,7 +1338,7 @@ function connectorInventory(connectors) {
1336
1338
  return `${prefix}${shown.join(", ")}.`;
1337
1339
  return `${prefix}${shown.join(", ")}${shown.length > 0 ? "; " : ""}+${omitted} more.`;
1338
1340
  }
1339
- const executeDescription = (emitBudgets, hostLimits, connectorGuides, connectors, maxWrites, trust) => `One known read: call_tool. One known write: call_destructive_tool. Everything else: execute_code. ${trust === "trusted" ? "Trusted pool: programs may read and write; the host approves the program as a write." : "Read-only pool: programs read; writes use call_destructive_tool."} Limits: ${hostLimits.maxHostCalls} host calls, ${maxWrites} writes, ${hostLimits.hostCallTimeoutMs / 1_000}s/host call.
1341
+ const executeDescription = (emitBudgets, hostLimits, connectorGuides, connectors, maxWrites, readOnly) => `${readOnly ? "One known read: call_tool. Everything else: execute_code. Read-only endpoint: search and describe omit write tools, and every write call is refused." : "One known read: call_tool. One known write: call_destructive_tool. Everything else: execute_code. Writable endpoint: programs may read and write; the client approves."} Limits: ${hostLimits.maxHostCalls} host calls, ${readOnly ? "" : `${maxWrites} writes, `}${hostLimits.hostCallTimeoutMs / 1_000}s/host call.
1340
1342
  ${connectorInventory(connectors)}
1341
1343
 
1342
1344
  Use async () => { ... }, no arguments. No portable ambient capabilities. API:
@@ -1383,7 +1385,7 @@ export function registerExecuteTool(server, registry, ctx) {
1383
1385
  hostCallTimeoutMs: hostLimits.hostCallTimeoutMs,
1384
1386
  watchdogMs: ctx.watchdogMs,
1385
1387
  defer: ctx.defer,
1386
- trust: ctx.trust,
1388
+ readOnly: ctx.readOnly,
1387
1389
  maxWrites,
1388
1390
  requestScope: ctx.requestScope,
1389
1391
  });
@@ -1391,13 +1393,13 @@ export function registerExecuteTool(server, registry, ctx) {
1391
1393
  description: executeDescription(emitBudgets, {
1392
1394
  ...hostLimits,
1393
1395
  hostCallTimeoutMs: normalizeTimeoutMs(ctx.defaultToolTimeoutMs) ?? hostLimits.hostCallTimeoutMs,
1394
- }, hasConnectorGuides(connectors), connectors, maxWrites, ctx.trust ?? "read-only"),
1396
+ }, hasConnectorGuides(connectors), connectors, maxWrites, ctx.readOnly === true),
1395
1397
  inputSchema: EXECUTE_INPUT,
1396
1398
  outputSchema: EXECUTE_OUTPUT,
1397
- // The host sees execute_code as a write only on a trusted endpoint.
1399
+ // The client sees execute_code as a write exactly when the endpoint writes.
1398
1400
  annotations: {
1399
- readOnlyHint: ctx.trust !== "trusted",
1400
- destructiveHint: ctx.trust === "trusted",
1401
+ readOnlyHint: ctx.readOnly === true,
1402
+ destructiveHint: ctx.readOnly !== true,
1401
1403
  openWorldHint: true,
1402
1404
  },
1403
1405
  }, (args, extra) => {
package/dist/index.d.ts CHANGED
@@ -5,8 +5,9 @@ export { customExecutor, type CustomExecutorOptions } from "./executor-contract.
5
5
  export type { AccessTokensModule, ActivityModule, OperatorSurface } from "./module-contracts.js";
6
6
  export type { CredentialVault, CredentialMetadata } from "./credential-contract.js";
7
7
  export { defineConfig } from "./config.js";
8
- export type { AdmissionPoolConfig, ConnectaAdmissionConfig, ConnectaCallsConfig, ConnectaConfig, ConnectaDiscoveryConfig, ConnectaExecuteConfig, ConnectaIdentityConfig, ConnectaPoolConfig, ConnectaResultsConfig, ConnectorPermission, RequestAdmissionConfig, } from "./config.js";
9
- export type { ConfigValueSource, ConnectaConfigDescription, DescribedConnector, DescribedLimit, } from "./describe-config.js";
8
+ export type { AdmissionPoolConfig, ConnectaAdmissionConfig, ConnectaCallsConfig, ConnectaConfig, ConnectaDiscoveryConfig, ConnectaExecuteConfig, ConnectaPoolConfig, ConnectaResultsConfig, RequestAdmissionConfig, } from "./config.js";
9
+ export type { MemberRef } from "./membership.js";
10
+ export type { ConfigValueSource, ConnectaConfigDescription, DescribedConnector, DescribedMember, DescribedLimit, } from "./describe-config.js";
10
11
  export interface Connecta {
11
12
  /** Web-standard fetch handler. Usable as `export default { fetch: connecta.fetch }`. */
12
13
  fetch: (request: Request, env?: unknown, ctx?: unknown) => Promise<Response>;
@@ -14,7 +15,7 @@ export interface Connecta {
14
15
  /**
15
16
  * A secret-free snapshot of the configuration this deployment runs with,
16
17
  * built once at construction: limits and where each came from, auth
17
- * providers, identity rules, pools, modules, and every connector's source,
18
+ * providers, maintainers, agents, pools, modules, and every connector's source,
18
19
  * endpoint, auth mode, and static tools. Header values, keys, client
19
20
  * secrets, credentials, and functions never appear. The same object is
20
21
  * returned on every call.
package/dist/index.js CHANGED
@@ -2,6 +2,9 @@ import { describeConfigSources } from "./config-value-sources.js";
2
2
  import { credentialTestRule, describeCredentialTestMismatch } from "./credential-rules.js";
3
3
  import { Registry } from "./registry.js";
4
4
  import { parseConnectorAccess, POOL_NAME_RE } from "./connector-access.js";
5
+ import { assertsAgents } from "./agent-identity.js";
6
+ import { ConfigError } from "./config-schema.js";
7
+ import { memberContext, resolveMaintainers, resolveMembers, } from "./membership.js";
5
8
  import { createFetchHandler } from "./server.js";
6
9
  import { droppedBrandingUrls, droppedThemeTokens, droppedUiAuthUrls } from "./branding.js";
7
10
  import { readConfig } from "./config.js";
@@ -10,19 +13,21 @@ export { customExecutor } from "./executor-contract.js";
10
13
  import { AdmissionController, executorName, isAdmittingExecutor, withExecutorAdmission } from "./executor-admission.js";
11
14
  export { defineConfig } from "./config.js";
12
15
  /**
13
- * Validate declared pools against the connector set. Everything checkable at
14
- * construction throws here: a malformed name, an unparseable grant, an
15
- * unknown connector id, a tool address on an `api()` connector whose static
16
- * catalog lacks it. Remote catalogs load lazily, so their addresses are
17
- * checked at catalog load instead and stay unreachable until they match.
16
+ * Validate declared pools against the connector set and the members against
17
+ * auth and agents. Everything checkable at construction throws here: a
18
+ * malformed name, an unparseable grant, an unknown connector id, a tool
19
+ * address on an `api()` connector whose static catalog lacks it, and an empty,
20
+ * ambiguous, unknown, or repeated member. Remote catalogs load lazily, so
21
+ * their addresses are checked at catalog load instead and stay unreachable
22
+ * until they match.
18
23
  */
19
- function resolvePools(pools, registry) {
24
+ function resolvePools(pools, readOnly, registry, members) {
20
25
  const resolved = new Map();
21
26
  if (!pools)
22
27
  return resolved;
23
28
  // The schema has already refused a pool that is not an object, a missing or
24
- // non-array `tools`, a non-function `grant`, and a misspelled key — a
25
- // misspelled `grant` would otherwise boot as a deny-all pool.
29
+ // non-array `tools` or `members`, and a misspelled key; the retired `grant`
30
+ // is refused there with its replacement named.
26
31
  for (const [name, pool] of Object.entries(pools)) {
27
32
  if (!POOL_NAME_RE.test(name)) {
28
33
  throw new Error(`ConnectaConfig.pools: pool name "${name}" must match [a-z0-9_-]+`);
@@ -52,7 +57,17 @@ function resolvePools(pools, registry) {
52
57
  }
53
58
  }
54
59
  }
55
- resolved.set(name, { access, trust: pool.trust, grant: pool.grant ?? (() => false) });
60
+ const path = `ConnectaConfig.pools.${name}.members`;
61
+ if (pool.members.length === 0) {
62
+ throw new ConfigError(`${path} is empty; a pool serves only its members, so list at least one.`);
63
+ }
64
+ // A pool inherits the deployment value unless it sets its own, either way.
65
+ resolved.set(name, Object.freeze({
66
+ access,
67
+ readOnly: pool.readOnly ?? readOnly,
68
+ members: Object.freeze(resolveMembers(pool.members, path, members)),
69
+ ...(pool.owner !== undefined ? { owner: pool.owner.trim() } : {}),
70
+ }));
56
71
  }
57
72
  return resolved;
58
73
  }
@@ -190,7 +205,9 @@ export function createConnecta(config) {
190
205
  results: resolved.results,
191
206
  classification: resolved.classification,
192
207
  });
193
- const pools = resolvePools(resolved.pools, registry);
208
+ const members = memberContext(resolved.auth, resolved.agents);
209
+ const maintainers = resolveMaintainers(input.maintainers, resolved.auth.some((provider) => !assertsAgents(provider)), members);
210
+ const pools = resolvePools(resolved.pools, resolved.readOnly, registry, members);
194
211
  warnInsecureConfig(resolved);
195
212
  const requestAdmission = new AdmissionController(resolved.admission.requests);
196
213
  let codeAdmission;
@@ -213,6 +230,8 @@ export function createConnecta(config) {
213
230
  registry,
214
231
  raw: input,
215
232
  config: resolved,
233
+ maintainers,
234
+ pools,
216
235
  executorName: configuredExecutorName,
217
236
  executorAdmits,
218
237
  });
@@ -221,6 +240,7 @@ export function createConnecta(config) {
221
240
  configDescription: description,
222
241
  configValueSources: describeConfigSources(description, input),
223
242
  registry,
243
+ maintainers,
224
244
  pools,
225
245
  executor,
226
246
  executorName: configuredExecutorName,
@@ -1,8 +1,9 @@
1
1
  import { withAuthorizationHandoff } from "./authorization-handoff.js";
2
2
  import { hasControlCharacters } from "./tool-name.js";
3
- import { surfaceAllowsTool } from "./tool-safety.js";
3
+ import { writeRefusal } from "./tool-safety.js";
4
4
  import { Cause, Effect, Exit } from "effect";
5
5
  import { isCallAdmissionError } from "./call-admission.js";
6
+ import { attachCallAdmission } from "./call-admission-context.js";
6
7
  import { boundedEchoText, classificationCode, classifyCallError, ConnectorCallError, echoedCallArgs, framingError, } from "./errors.js";
7
8
  import { downstreamValue } from "./mcp-result.js";
8
9
  import { carryFailureFacts, classifiedFailure, failureRecord, logFailure, recordedToolName, } from "./operator-record.js";
@@ -389,7 +390,7 @@ export class InvocationService {
389
390
  // Activity rows and this log line are both payload-free by
390
391
  // construction: the line is the failure's typed record, never its
391
392
  // message, which may be the downstream's own words (INV-6).
392
- if (target && details.code !== "destructive_tool_requires_approval") {
393
+ if (target && details.code !== "destructive_tool_requires_approval" && details.code !== "read_only_endpoint") {
393
394
  logFailure(this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope).logger, "call failed", failureRecord({
394
395
  connector: target.connector.id,
395
396
  // Named only when the catalog listed it (src/operator-record.ts).
@@ -463,8 +464,11 @@ export class InvocationService {
463
464
  reconciliationRequired: true,
464
465
  };
465
466
  }
466
- if (!surfaceAllowsTool(target.definition.classification, context.source, context.trust)) {
467
- const canonicalAddress = `${target.connector.id}.${target.toolName}`;
467
+ const refusal = writeRefusal(target.definition.classification, context.source, context.readOnly);
468
+ if (refusal === "read_only_endpoint") {
469
+ return framingError("read_only_endpoint", `Tool "${canonicalAddress}" is a write, and this endpoint is read-only. Nothing here can send it.`);
470
+ }
471
+ if (refusal === "destructive_tool_requires_approval") {
468
472
  return framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is a write. Invoke it through call_destructive_tool so the MCP host can request approval.`);
469
473
  }
470
474
  // Remote MCP tools advertise their input schema in the catalog. Validate
@@ -491,8 +495,9 @@ export class InvocationService {
491
495
  if (callSignal?.aborted)
492
496
  return yield* Effect.fail(callSignal.reason);
493
497
  attempts = 1;
494
- const call = async () => {
498
+ const call = async (permit) => {
495
499
  const connectorContext = this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope, defined({ signal: callSignal, timeoutMs: context.timeoutMs, defer: this.catalog.defer }));
500
+ attachCallAdmission(connectorContext, permit);
496
501
  trackCredentialReads(connectorContext);
497
502
  sentSecrets.include(sentSecretsFor(connectorContext));
498
503
  dispatchContext = connectorContext;
@@ -534,14 +539,14 @@ export class InvocationService {
534
539
  // cannot hold its permit past the deadline.
535
540
  const attempt = yield* Effect.exit(Effect.scoped(Effect.gen({ self: this }, function* () {
536
541
  stage = "admission";
537
- yield* timed((elapsed) => {
542
+ const permit = yield* timed((elapsed) => {
538
543
  admissionMs += elapsed;
539
544
  }, admitted(this.registry, target, args, admissionSignal));
540
545
  stage = "downstream";
541
546
  const reply = yield* timed((elapsed) => {
542
547
  connectorMs += elapsed;
543
548
  }, Effect.tryPromise({
544
- try: () => Promise.resolve(call()),
549
+ try: () => Promise.resolve(call(permit)),
545
550
  catch: (error) => error,
546
551
  }));
547
552
  if (target.connector.kind === "mcp" && isDownstreamInputResult(reply)) {
@@ -0,0 +1,51 @@
1
+ import type { ConnectorAccess } from "./connector-access.js";
2
+ import type { AuthenticatedIdentity, IdentityReference, InboundAuth } from "./types.js";
3
+ /**
4
+ * A person or an agent, as deployment code lists it: a bare principal id in
5
+ * the namespace of the deployment's one interactive auth provider,
6
+ * `{ namespace, id }` when that is ambiguous or the id contains `:`, or
7
+ * `"agent:<name>"` for an agent declared in `agents`.
8
+ */
9
+ export type MemberRef = string | Readonly<IdentityReference>;
10
+ /** A resolved member reference. Humans match by principal, agents by name. */
11
+ export type Member = {
12
+ kind: "human";
13
+ namespace: string;
14
+ id: string;
15
+ } | {
16
+ kind: "agent";
17
+ name: string;
18
+ };
19
+ /** A declared pool after construction-time validation. */
20
+ export interface ResolvedPool {
21
+ /** Resolved: the pool's own `readOnly`, else the deployment's. */
22
+ readOnly: boolean;
23
+ /** Exactly the pool's `tools`; nothing intersects or widens it. */
24
+ access: ConnectorAccess;
25
+ members: readonly Member[];
26
+ /** Trimmed `owner`: the Workspace address agents in this pool act as. */
27
+ owner?: string;
28
+ }
29
+ /** What resolving a reference needs to know about the deployment. */
30
+ export interface MemberContext {
31
+ /** `activityActorNamespace` of each interactive provider, `undefined` where it has none. */
32
+ interactiveNamespaces: ReadonlyArray<string | undefined>;
33
+ agents: readonly string[];
34
+ }
35
+ /** The namespaces bare ids may resolve in, read from the resolved providers. */
36
+ export declare function memberContext(auth: readonly InboundAuth[], agents: readonly string[]): MemberContext;
37
+ /**
38
+ * Resolve one list of references, throwing on any that is malformed,
39
+ * ambiguous, unknown, or repeated (INV-11).
40
+ */
41
+ export declare function resolveMembers(value: unknown, path: string, context: MemberContext): Member[];
42
+ /**
43
+ * `maintainers`: people only. Required whenever an inbound provider can admit
44
+ * a person, which is any provider but the built-in agent-token adapter. With
45
+ * agents alone nobody can be a maintainer, so plain `/mcp` admits nobody.
46
+ */
47
+ export declare function resolveMaintainers(value: unknown, admitsPeople: boolean, context: MemberContext): readonly IdentityReference[];
48
+ /** Whether an admitted identity is this member. An agent never matches a person. */
49
+ export declare function isMember(member: Member, identity: Readonly<AuthenticatedIdentity>): boolean;
50
+ /** Whether an admitted identity is a listed maintainer. */
51
+ export declare function isMaintainer(maintainers: readonly IdentityReference[], identity: Readonly<AuthenticatedIdentity>): boolean;
@@ -0,0 +1,121 @@
1
+ // Who may open which endpoint (#837, decisions/0006): `maintainers` open plain
2
+ // `/mcp`, and each pool's static `members` open `/mcp/<pool>`. References are
3
+ // resolved once at construction; a request is matched against them and never
4
+ // names its own scope (INV-4). Pure, so core reads it without any auth module.
5
+ import { isAgentName } from "./agent-identity.js";
6
+ import { ConfigError } from "./config-schema.js";
7
+ import { validIdentityReference } from "./identity.js";
8
+ const AGENT_PREFIX = "agent:";
9
+ /** The namespaces bare ids may resolve in, read from the resolved providers. */
10
+ export function memberContext(auth, agents) {
11
+ return {
12
+ interactiveNamespaces: auth
13
+ .filter((provider) => provider.interactiveOperator === true)
14
+ .map((provider) => typeof provider.activityActorNamespace === "string" &&
15
+ validIdentityReference({ namespace: provider.activityActorNamespace, id: "x" })
16
+ ? provider.activityActorNamespace
17
+ : undefined),
18
+ agents,
19
+ };
20
+ }
21
+ function resolveRef(value, path, context) {
22
+ if (typeof value === "string") {
23
+ if (value.startsWith(AGENT_PREFIX)) {
24
+ const name = value.slice(AGENT_PREFIX.length);
25
+ if (!isAgentName(name) || !context.agents.includes(name)) {
26
+ throw new ConfigError(`${path} names agent "${name}", which ConnectaConfig.agents does not declare.`);
27
+ }
28
+ return { kind: "agent", name };
29
+ }
30
+ if (value.includes(":")) {
31
+ throw new ConfigError(`${path} contains ":". Write a principal whose id contains ":" as { namespace, id }; ` +
32
+ 'agents are "agent:<name>".');
33
+ }
34
+ if (!validIdentityReference({ namespace: "x", id: value })) {
35
+ throw new ConfigError(`${path} must be a principal id of 1 to 256 printable, non-space ASCII characters.`);
36
+ }
37
+ const namespaces = context.interactiveNamespaces;
38
+ if (namespaces.length !== 1) {
39
+ throw new ConfigError(`${path} is a bare principal id, which resolves only when exactly one interactive auth provider is ` +
40
+ `configured (found ${namespaces.length}). Write it as { namespace, id }.`);
41
+ }
42
+ const namespace = namespaces[0];
43
+ if (namespace === undefined) {
44
+ throw new ConfigError(`${path} is a bare principal id, but the interactive auth provider declares no activityActorNamespace. ` +
45
+ "Write it as { namespace, id }.");
46
+ }
47
+ return { kind: "human", namespace, id: value };
48
+ }
49
+ if (typeof value !== "object" ||
50
+ value === null ||
51
+ Array.isArray(value) ||
52
+ Object.keys(value).length !== 2 ||
53
+ !Object.hasOwn(value, "namespace") ||
54
+ !Object.hasOwn(value, "id")) {
55
+ throw new ConfigError(`${path} must be a principal id string, { namespace, id }, or "agent:<name>".`);
56
+ }
57
+ const { namespace, id } = value;
58
+ if (typeof namespace !== "string" || typeof id !== "string" || !validIdentityReference({ namespace, id })) {
59
+ throw new ConfigError(`${path} must have a namespace and id of 1 to 256 printable, non-space ASCII characters each.`);
60
+ }
61
+ return { kind: "human", namespace, id };
62
+ }
63
+ const memberKey = (member) => member.kind === "agent" ? `agent\0${member.name}` : `human\0${member.namespace}\0${member.id}`;
64
+ /**
65
+ * Resolve one list of references, throwing on any that is malformed,
66
+ * ambiguous, unknown, or repeated (INV-11).
67
+ */
68
+ export function resolveMembers(value, path, context) {
69
+ if (!Array.isArray(value)) {
70
+ throw new ConfigError(`${path} must be an array of principal ids, { namespace, id }, or "agent:<name>".`);
71
+ }
72
+ const seen = new Set();
73
+ return value.map((entry, index) => {
74
+ const member = resolveRef(entry, `${path}[${index}]`, context);
75
+ const key = memberKey(member);
76
+ if (seen.has(key))
77
+ throw new ConfigError(`${path}[${index}] repeats an earlier member.`);
78
+ seen.add(key);
79
+ return Object.freeze(member);
80
+ });
81
+ }
82
+ /**
83
+ * `maintainers`: people only. Required whenever an inbound provider can admit
84
+ * a person, which is any provider but the built-in agent-token adapter. With
85
+ * agents alone nobody can be a maintainer, so plain `/mcp` admits nobody.
86
+ */
87
+ export function resolveMaintainers(value, admitsPeople, context) {
88
+ const path = "ConnectaConfig.maintainers";
89
+ if (value === undefined || (Array.isArray(value) && value.length === 0)) {
90
+ if (admitsPeople) {
91
+ throw new ConfigError(`${path} is required with inbound auth: list the people who may use plain /mcp, administer shared ` +
92
+ "credentials, manage agent tokens, and read activity.");
93
+ }
94
+ return Object.freeze([]);
95
+ }
96
+ if (!admitsPeople) {
97
+ throw new ConfigError(`${path} needs an inbound auth provider that admits people, such as clerkAuth or cloudflareAccessAuth. ` +
98
+ "Without one no maintainer can be admitted; agents are never maintainers.");
99
+ }
100
+ const members = resolveMembers(value, path, context);
101
+ return Object.freeze(members.map((member, index) => {
102
+ if (member.kind === "agent") {
103
+ throw new ConfigError(`${path}[${index}] is an agent. Agents are never maintainers; add them to a pool's members.`);
104
+ }
105
+ return Object.freeze({ namespace: member.namespace, id: member.id });
106
+ }));
107
+ }
108
+ /** Whether an admitted identity is this member. An agent never matches a person. */
109
+ export function isMember(member, identity) {
110
+ if (member.kind === "agent")
111
+ return identity.agent !== undefined && identity.agent === member.name;
112
+ const principal = identity.principal;
113
+ return (identity.agent === undefined &&
114
+ principal !== undefined &&
115
+ principal.namespace === member.namespace &&
116
+ principal.id === member.id);
117
+ }
118
+ /** Whether an admitted identity is a listed maintainer. */
119
+ export function isMaintainer(maintainers, identity) {
120
+ return maintainers.some((maintainer) => isMember({ kind: "human", ...maintainer }, identity));
121
+ }