@zackbart/connecta 0.26.3 → 0.28.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 (69) hide show
  1. package/AGENTS.md +11 -11
  2. package/CHANGELOG.md +128 -1
  3. package/README.md +15 -19
  4. package/bin/connecta.mjs +10 -6
  5. package/dist/access-tokens.js +13 -2
  6. package/dist/activity.d.ts +5 -4
  7. package/dist/activity.js +1 -10
  8. package/dist/artifacts/connector.d.ts +3 -0
  9. package/dist/artifacts/connector.js +6 -0
  10. package/dist/artifacts/document.js +6 -0
  11. package/dist/artifacts/json.js +10 -0
  12. package/dist/artifacts/module.js +7 -1
  13. package/dist/artifacts/operations.d.ts +3 -1
  14. package/dist/artifacts/operations.js +69 -11
  15. package/dist/artifacts/refresh.d.ts +2 -0
  16. package/dist/artifacts/refresh.js +3 -2
  17. package/dist/artifacts/routes.js +4 -15
  18. package/dist/artifacts/validate.js +2 -4
  19. package/dist/auth/downstream-oauth.js +95 -36
  20. package/dist/catalog-service.js +1 -1
  21. package/dist/connector-scope.d.ts +2 -0
  22. package/dist/connector-scope.js +27 -0
  23. package/dist/connectors/api.js +8 -2
  24. package/dist/connectors/remote-mcp.js +57 -17
  25. package/dist/credentials.js +2 -1
  26. package/dist/execute.js +107 -368
  27. package/dist/executors/quickjs.js +1 -1
  28. package/dist/exempt-writes.js +162 -0
  29. package/dist/index.d.ts +12 -31
  30. package/dist/index.js +15 -47
  31. package/dist/invocation.js +6 -19
  32. package/dist/meta-tools.js +1 -2
  33. package/dist/oauth-partition.js +32 -0
  34. package/dist/operator-ui/generated.js +1 -1
  35. package/dist/operator-ui/model.d.ts +3 -3
  36. package/dist/operator-ui/view.js +6 -4
  37. package/dist/providers/notion.js +4 -1
  38. package/dist/providers/vercel.js +13 -9
  39. package/dist/registry.d.ts +1 -0
  40. package/dist/registry.js +58 -25
  41. package/dist/routes/credentials.js +16 -8
  42. package/dist/routes/mcp.js +5 -21
  43. package/dist/routes/oauth.js +70 -64
  44. package/dist/routes/shared.d.ts +1 -6
  45. package/dist/runtime/run.js +7 -1
  46. package/dist/server.js +0 -4
  47. package/dist/skills.js +8 -23
  48. package/dist/storage/file.js +57 -20
  49. package/dist/storage/memory.js +8 -1
  50. package/dist/tool-safety.d.ts +1 -1
  51. package/dist/tool-safety.js +1 -1
  52. package/dist/types.d.ts +2 -2
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/documentation/architecture.md +51 -14
  56. package/documentation/auth.md +7 -4
  57. package/documentation/code-mode.md +91 -194
  58. package/documentation/meta-tools.md +38 -41
  59. package/ethos.md +6 -7
  60. package/examples/worker/README.md +14 -17
  61. package/examples/worker/src/d1-activity-row.ts +4 -1
  62. package/examples/worker/src/d1-storage.ts +27 -5
  63. package/examples/worker/src/index.ts +1 -9
  64. package/package.json +4 -4
  65. package/templates/node/AGENTS.md +4 -3
  66. package/templates/node/README.md +3 -4
  67. package/templates/node/package.json +2 -2
  68. package/dist/resumable.js +0 -1291
  69. package/dist/run-journal.js +0 -347
@@ -7,10 +7,10 @@ import type { CatalogAccessObservation, CatalogDriftReport } from "../types.js";
7
7
  *
8
8
  * - `runs_in_programs` — explicitly read-only, so `execute_code` may call it.
9
9
  * - `exempt` — not read-only, but the deployment's `execute.approval` lets a
10
- * program call it without pausing (#566). Still approval-required
10
+ * program call it without asking (#566). Still approval-required
11
11
  * everywhere else.
12
- * - `needs_approval` — everything else: a program pauses for
13
- * `resume_execution`, and a direct call crosses `call_destructive_tool`.
12
+ * - `needs_approval` — everything else: a program refuses it, and it crosses
13
+ * `call_destructive_tool`, where the host asks.
14
14
  *
15
15
  * The badge renders from a table keyed by this union, so each value is one
16
16
  * entry, not a branch.
@@ -413,12 +413,12 @@ export const TOOL_SAFETY_BADGE = {
413
413
  exempt: {
414
414
  label: "exempt from approval",
415
415
  tone: "neutral",
416
- title: "Not read-only, but this deployment's config lets programs call it without pausing. Each call still counts against the write budget and appears in activity; call_tool still refuses it.",
416
+ title: "Not read-only, but this deployment's config lets programs call it without asking. Each call still counts against the write budget and appears in activity; call_tool still refuses it.",
417
417
  },
418
418
  needs_approval: {
419
419
  label: "asks for approval",
420
420
  tone: "warn",
421
- title: "Not explicitly read-only: a program pauses for resume_execution, and a direct call crosses call_destructive_tool — either way the host asks first.",
421
+ title: "Not explicitly read-only: programs refuse it, and it runs through call_destructive_tool, where the host asks first.",
422
422
  },
423
423
  };
424
424
  /**
@@ -608,7 +608,8 @@ export function activitySummary(events) {
608
608
  return `${events.length} loaded call${events.length === 1 ? "" : "s"} · ${tools} tool${tools === 1 ? "" : "s"}`;
609
609
  }
610
610
  // A pause and an approval are neither success nor failure: each gets its own
611
- // class, and the stylesheet paints neither as an error.
611
+ // class, and the stylesheet paints neither as an error. Nothing has emitted
612
+ // either since issue #672 removed program pauses, but older rows carry them.
612
613
  const ACTIVITY_OUTCOMES = [
613
614
  "success",
614
615
  "error",
@@ -626,7 +627,7 @@ const OUTCOME_BADGE = {
626
627
  error: { label: "Failed", tone: "danger" },
627
628
  timeout: { label: "Timed out", tone: "danger" },
628
629
  cancelled: { label: "Cancelled", tone: "neutral" },
629
- paused: { label: "Waiting for approval", tone: "warn" },
630
+ paused: { label: "Paused for approval", tone: "warn" },
630
631
  approved: { label: "Approved", tone: "ok" },
631
632
  };
632
633
  export function activityOutcomeBadge(outcome) {
@@ -636,6 +637,7 @@ const SOURCE_LABELS = {
636
637
  execute_code: "In a program",
637
638
  call_tool: "Direct call",
638
639
  call_destructive_tool: "Direct call, approved by the host",
640
+ // History only: resume_execution left with program pauses (#672).
639
641
  resume_execution: "Resumed program",
640
642
  batch_call: "Batch call",
641
643
  };
@@ -147,7 +147,10 @@ async function notionRequest(ctx, request) {
147
147
  if (!response.ok) {
148
148
  throw notionFailure(response.status, payload, response.headers);
149
149
  }
150
- return payload ?? {};
150
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
151
+ throw new ConnectorCallError("connector_call_failed", "Notion returned an unreadable or non-object JSON response for a successful status. The request was sent, but its result could not be read; do not repeat a write to recover its result.", { retryable: false });
152
+ }
153
+ return payload;
151
154
  });
152
155
  }
153
156
  // Projections. Notion wraps every property in a discriminated object, every
@@ -83,7 +83,7 @@ function vercelFailure(status, headers, payload) {
83
83
  retryable: false,
84
84
  });
85
85
  }
86
- function parseBody(text, contentType) {
86
+ function parseBody(text, contentType, strict = false) {
87
87
  if (!text)
88
88
  return undefined;
89
89
  try {
@@ -91,6 +91,8 @@ function parseBody(text, contentType) {
91
91
  }
92
92
  catch {
93
93
  if (contentType?.includes("stream+json") || contentType?.includes("ndjson")) {
94
+ if (strict)
95
+ return parseStreamRows(text);
94
96
  const rows = [];
95
97
  for (const line of text.split("\n")) {
96
98
  if (!line.trim())
@@ -104,6 +106,9 @@ function parseBody(text, contentType) {
104
106
  }
105
107
  return rows;
106
108
  }
109
+ if (strict) {
110
+ throw new ConnectorCallError("connector_call_failed", "Vercel returned a malformed successful response.", { retryable: false });
111
+ }
107
112
  return text;
108
113
  }
109
114
  }
@@ -144,14 +149,13 @@ function vercelTransport(baseUrl) {
144
149
  },
145
150
  });
146
151
  }
147
- async function callVercel(send, request, ctx, parseSuccess) {
152
+ async function callVercel(send, request, ctx, success = {}) {
148
153
  return await send(request, ctx, async (response) => {
149
154
  const text = await response.text();
150
- const payload = parseBody(text, response.headers.get("content-type"));
151
155
  if (!response.ok) {
152
- throw vercelFailure(response.status, response.headers, payload);
156
+ throw vercelFailure(response.status, response.headers, parseBody(text, response.headers.get("content-type")));
153
157
  }
154
- return parseSuccess ? parseSuccess(text) : payload;
158
+ return success.parse ? success.parse(text) : parseBody(text, response.headers.get("content-type"), !success.raw);
155
159
  });
156
160
  }
157
161
  function teamQuery(args, defaultTeamId) {
@@ -553,7 +557,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
553
557
  required: ["result"],
554
558
  },
555
559
  handler: async (args, ctx) => ({
556
- result: (await callVercel(send, { method: "GET", ...rawRequest(args, defaultTeamId) }, ctx)) ?? null,
560
+ result: (await callVercel(send, { method: "GET", ...rawRequest(args, defaultTeamId) }, ctx, { raw: true })) ?? null,
557
561
  }),
558
562
  },
559
563
  {
@@ -588,7 +592,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
588
592
  method: args["method"],
589
593
  ...rawRequest(args, defaultTeamId),
590
594
  ...(args["body"] !== undefined ? { body: args["body"] } : {}),
591
- }, ctx)) ?? null,
595
+ }, ctx, { raw: true })) ?? null,
592
596
  }),
593
597
  },
594
598
  {
@@ -641,7 +645,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
641
645
  "Content-Type": args["contentType"],
642
646
  },
643
647
  rawBody: uploadBody(args),
644
- }, ctx)) ?? null,
648
+ }, ctx, { raw: true })) ?? null,
645
649
  }),
646
650
  },
647
651
  {
@@ -839,7 +843,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
839
843
  method: "GET",
840
844
  path: `/v1/projects/${encodeURIComponent(args["projectId"])}/deployments/${encodeURIComponent(args["deploymentId"])}/runtime-logs`,
841
845
  query: team(args), headers: { Accept: "application/stream+json" },
842
- }, { ...ctx, signal }, parseStreamRows), {
846
+ }, { ...ctx, signal }, { parse: parseStreamRows }), {
843
847
  timeoutMs: RUNTIME_LOG_TIMEOUT_MS,
844
848
  ...(ctx.signal ? { signal: ctx.signal } : {}),
845
849
  timeoutError: new ConnectorCallError("unavailable", `Vercel's runtime-log stream stayed open past this connector's ${RUNTIME_LOG_TIMEOUT_MS / 1_000}-second bound. Retry for a fresh snapshot.`),
@@ -171,6 +171,7 @@ export declare class Registry implements RegistryView {
171
171
  private resultStashBytes;
172
172
  private readonly configuredConnectors;
173
173
  private readonly personalRegistries;
174
+ private readonly oauthPartition;
174
175
  private callAdmissionClosed;
175
176
  /** Bounded FIFO of absent grants already warned about. */
176
177
  private readonly warnedAbsentGrants;
package/dist/registry.js CHANGED
@@ -8,6 +8,7 @@ import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTE
8
8
  import { ObservedOutputSchemas } from "./result-shapes.js";
9
9
  import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary, } from "./skills.js";
10
10
  import { attachOAuthSealer, vaultOAuthSealer } from "./oauth-sealing.js";
11
+ import { attachOAuthPartition, oauthPartitionIdle } from "./oauth-partition.js";
11
12
  import { attachCaller } from "./connector-caller.js";
12
13
  import { closeScopeOnExit } from "./runtime/connector-scope.js";
13
14
  import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
@@ -164,6 +165,7 @@ export class Registry {
164
165
  resultStashBytes = 0;
165
166
  configuredConnectors;
166
167
  personalRegistries = new Map();
168
+ oauthPartition = {};
167
169
  callAdmissionClosed = false;
168
170
  /** Bounded FIFO of absent grants already warned about. */
169
171
  warnedAbsentGrants = new Set();
@@ -226,9 +228,12 @@ export class Registry {
226
228
  // principal starts with no generations and no mutation queue, so a
227
229
  // refresh the evicted registry finishes later would persist a listing
228
230
  // that a credential change on the replacement had just deleted.
231
+ // OAuth status/start work bypasses both gates, and accepted rotations
232
+ // may still be saving after their caller leaves. Preserve its partition.
229
233
  const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()) &&
230
234
  candidate.catalogRefreshes.size === 0 &&
231
- candidate.catalogMutations.size === 0);
235
+ candidate.catalogMutations.size === 0 &&
236
+ oauthPartitionIdle(candidate.oauthPartition));
232
237
  if (!idle) {
233
238
  throw new Error("Personal connector capacity is exhausted; retry after calls, rolling budgets, and catalog refreshes drain.");
234
239
  }
@@ -287,11 +292,24 @@ export class Registry {
287
292
  }
288
293
  async storeOAuthHandoff(connectorId, state, principalKey) {
289
294
  const key = this.oauthHandoffKey(connectorId, await sha256Hex(state));
290
- const existing = await this.opts.storage.get(key);
291
- if (existing && existing !== principalKey) {
292
- throw new Error(`Connector "${connectorId}" reused one OAuth state across principals`);
295
+ for (let attempt = 0; attempt < 32; attempt++) {
296
+ const existing = await this.opts.storage.get(key);
297
+ if (existing && existing !== principalKey) {
298
+ throw new Error(`Connector "${connectorId}" reused one OAuth state across principals`);
299
+ }
300
+ const expiry = { ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS };
301
+ if (this.opts.storage.compareAndSet) {
302
+ // Bind ownership atomically, including renewal of the same owner's
303
+ // handoff. Two owners reading a miss must not overwrite each other.
304
+ if (!await this.opts.storage.compareAndSet(key, existing, principalKey, expiry))
305
+ continue;
306
+ }
307
+ else {
308
+ await this.opts.storage.set(key, principalKey, expiry);
309
+ }
310
+ return;
293
311
  }
294
- await this.opts.storage.set(key, principalKey, { ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS });
312
+ throw new Error(`OAuth handoff for "${connectorId}" is busy; retry authorization`);
295
313
  }
296
314
  async oauthCallbackView(connectorId, state) {
297
315
  const connector = this.connectors.get(connectorId);
@@ -389,7 +407,10 @@ export class Registry {
389
407
  return values;
390
408
  };
391
409
  credentialAccess = {
392
- get: async (field = "value") => (await readValues())?.[field] ?? null,
410
+ get: async (field = "value") => {
411
+ const values = await readValues();
412
+ return values && Object.hasOwn(values, field) ? values[field] : null;
413
+ },
393
414
  getAll: readValues,
394
415
  };
395
416
  }
@@ -401,6 +422,7 @@ export class Registry {
401
422
  requestScope,
402
423
  ...callOptions,
403
424
  };
425
+ attachOAuthPartition(context, this.oauthPartition);
404
426
  // Downstream OAuth state is sealed under the vault key, bound to this
405
427
  // connector and owner. The sealer rides beside the context, not on it.
406
428
  return this.opts.credentialVault
@@ -818,8 +840,12 @@ export class Registry {
818
840
  const turn = Deferred.makeUnsafe();
819
841
  this.catalogMutations.set(id, turn);
820
842
  try {
821
- if (previous)
822
- await runEdge(Deferred.await(previous));
843
+ if (previous) {
844
+ // A cross-request Deferred alone looks hung to workerd. Keep a timer
845
+ // owned by this request until the preceding mutation hands over its
846
+ // turn; the race interrupts the timer as soon as the wait settles.
847
+ await runEdge(Effect.raceFirst(Deferred.await(previous), Effect.forever(Effect.sleep(Duration.seconds(1)))));
848
+ }
823
849
  await runOnPartition(operation, this.opts);
824
850
  }
825
851
  finally {
@@ -841,14 +867,8 @@ export class Registry {
841
867
  // observation, but must not overwrite a newer same-generation refresh.
842
868
  // Blocking callers do not set this flag and retain their prior publication
843
869
  // behavior when an inbound abort races a completed listing.
844
- if (skipPublicationWhenAborted && ctx.signal?.aborted)
845
- return tools;
846
- // The caller that began this refresh may still use its result, but a
847
- // credential/OAuth change that landed while listTools was in flight, or a
848
- // flight abandoned while it listed, means the listing must not enter
849
- // either shared cache layer.
850
- if (!this.mayPublish(id, generation, flight))
851
- return tools;
870
+ // Every returned listing must pass the ceilings, even when invalidation
871
+ // or abandonment means it can no longer enter either shared cache layer.
852
872
  if (tools.length > MAX_CATALOG_TOOLS) {
853
873
  const message = `Connector "${id}" returned ${tools.length} tools, over the ` +
854
874
  `${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
@@ -857,10 +877,6 @@ export class Registry {
857
877
  }
858
878
  const previous = this.cache.get(id);
859
879
  const snapshot = await snapshotCatalog(tools);
860
- if (!this.mayPublish(id, generation, flight) ||
861
- (skipPublicationWhenAborted && ctx.signal?.aborted)) {
862
- return tools;
863
- }
864
880
  if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
865
881
  const message = `Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
866
882
  `serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
@@ -868,6 +884,13 @@ export class Registry {
868
884
  this.opts.logger.warn(`[connecta] ${message}`);
869
885
  throw new Error(message);
870
886
  }
887
+ // The caller that began this refresh may still use its bounded result,
888
+ // but credential changes, abandoned flights, and deferred cancellation
889
+ // prevent publication. Recheck after snapshotting, which is asynchronous.
890
+ if (!this.mayPublish(id, generation, flight) ||
891
+ (skipPublicationWhenAborted && ctx.signal?.aborted)) {
892
+ return tools;
893
+ }
871
894
  const now = Date.now();
872
895
  const catalogChanged = !previous || previous.fingerprint !== snapshot.fingerprint;
873
896
  const shouldPersist = catalogChanged ||
@@ -1296,23 +1319,33 @@ class ScopedRegistryView {
1296
1319
  allowed;
1297
1320
  scope;
1298
1321
  maxResultBytes;
1299
- personal;
1322
+ /** An evicted view's admission controller must remain closed. */
1323
+ admissionPersonal;
1324
+ get personal() {
1325
+ return this.scope.principalKey
1326
+ ? this.root.personalRegistry(this.scope.principalKey)
1327
+ : undefined;
1328
+ }
1300
1329
  constructor(root, allowed, scope) {
1301
1330
  this.root = root;
1302
1331
  this.allowed = allowed;
1303
1332
  this.scope = scope;
1304
1333
  this.maxResultBytes = root.maxResultBytes;
1305
- this.personal = scope.principalKey
1334
+ // Check capacity at construction, then resolve the current registry on
1335
+ // use. A retained view must not revive one evicted while it was idle.
1336
+ this.admissionPersonal = scope.principalKey
1306
1337
  ? root.personalRegistry(scope.principalKey)
1307
1338
  : undefined;
1308
1339
  }
1309
- registryFor(id) {
1340
+ registryFor(id, admission = false) {
1310
1341
  if (!this.allowed.has(id))
1311
1342
  return undefined;
1312
1343
  const connector = this.root.getConnector(id);
1313
1344
  if (!connector)
1314
1345
  return undefined;
1315
- return connector.authScope === "personal" ? this.personal : this.root;
1346
+ if (connector.authScope !== "personal")
1347
+ return this.root;
1348
+ return admission ? this.admissionPersonal : this.personal;
1316
1349
  }
1317
1350
  listConnectors() {
1318
1351
  return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
@@ -1358,7 +1391,7 @@ class ScopedRegistryView {
1358
1391
  return attachCaller(registry.contextFor(...args), this.scope.caller);
1359
1392
  }
1360
1393
  admitCall(...args) {
1361
- const registry = this.registryFor(args[0]);
1394
+ const registry = this.registryFor(args[0], true);
1362
1395
  if (!registry) {
1363
1396
  return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
1364
1397
  }
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect";
2
+ import { claimConnectorScopeCleanup, closeConnectorScope } from "../connector-scope.js";
2
3
  import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape, } from "../credential-rules.js";
3
4
  import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
4
5
  import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
@@ -164,14 +165,21 @@ function credentialRequest(context, connectorId, action) {
164
165
  }
165
166
  const storedValues = values;
166
167
  const ctx = registry.contextFor(connectorId, baseUrl);
167
- const result = mode === "multiple"
168
- ? await connector.testCredentials(storedValues, ctx)
169
- : await connector.testCredential(
170
- // The single-value shape check above guarantees this key.
171
- storedValues.value, ctx);
172
- const ok = result?.ok === true;
173
- logged(ok, result?.message);
174
- return privateJson({ ok });
168
+ const releaseCleanup = claimConnectorScopeCleanup(ctx, connectorId);
169
+ try {
170
+ const result = mode === "multiple"
171
+ ? await connector.testCredentials(storedValues, ctx)
172
+ : await connector.testCredential(
173
+ // The single-value shape check above guarantees this key.
174
+ storedValues.value, ctx);
175
+ const ok = result?.ok === true;
176
+ logged(ok, result?.message);
177
+ return privateJson({ ok });
178
+ }
179
+ finally {
180
+ releaseCleanup();
181
+ await closeConnectorScope(connector, ctx, context.defer);
182
+ }
175
183
  },
176
184
  catch: (error) => error,
177
185
  }).pipe(Effect.catch((error) => {
@@ -4,9 +4,8 @@ import { notFoundResponse } from "../branding.js";
4
4
  import { registerExecuteTool } from "../execute.js";
5
5
  import { ExecutorAdmissionError, } from "../executor-admission.js";
6
6
  import { registerMetaTools } from "../meta-tools.js";
7
- import { registerResumeTool } from "../resumable.js";
8
7
  import { intersectAccess } from "../connector-access.js";
9
- import { instructionsFor } from "../skills.js";
8
+ import { CONNECTA_INSTRUCTIONS } from "../skills.js";
10
9
  import { msg } from "../errors.js";
11
10
  import { detach } from "../runtime/run.js";
12
11
  import { authorize, loggableValue, mayManageConnector, validateAuthPermissions, } from "./shared.js";
@@ -218,19 +217,14 @@ function toolkitRetired(logger) {
218
217
  },
219
218
  });
220
219
  }
221
- function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canManageAuth, poolName, runtimeContext) {
222
- // A paused run belongs to the endpoint it started on: resuming it through
223
- // another pool's endpoint finds nothing, as another subject would.
224
- const resumable = opts.resumable
225
- ? { ...opts.resumable, pool: poolName ?? null, approval: opts.approval }
226
- : undefined;
220
+ function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canManageAuth, runtimeContext) {
227
221
  // Every McpServer the request builds is fresh and closes with its scope.
228
222
  // The modern handler tears its own down after the exchange; the legacy
229
223
  // transport never does, and neither may stay wired to an ended request.
230
224
  const servers = [];
231
225
  const createServer = () => {
232
226
  const server = new McpServer(opts.serverInfo, {
233
- instructions: instructionsFor(resumable !== undefined),
227
+ instructions: CONNECTA_INSTRUCTIONS,
234
228
  cacheHints: {
235
229
  "tools/list": {
236
230
  ttlMs: 3_600_000,
@@ -274,7 +268,7 @@ function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canMan
274
268
  : {}),
275
269
  approval: opts.approval,
276
270
  });
277
- const runner = registerExecuteTool(server, registry, {
271
+ registerExecuteTool(server, registry, {
278
272
  baseUrl,
279
273
  executor: opts.executor,
280
274
  logger: opts.logger,
@@ -304,19 +298,9 @@ function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canMan
304
298
  ...(opts.watchdogMs !== undefined
305
299
  ? { watchdogMs: opts.watchdogMs }
306
300
  : {}),
307
- resumable,
308
301
  approval: opts.approval,
309
302
  maxWrites: opts.maxWrites,
310
303
  });
311
- // Always registered, so the surface is the same eight tools on every
312
- // deployment; without resumable writes it answers that there is nothing
313
- // to resume.
314
- registerResumeTool(server, registry, {
315
- runner,
316
- settings: resumable,
317
- ...(activity ? { activity } : {}),
318
- requestSignal,
319
- });
320
304
  servers.push(server);
321
305
  return server;
322
306
  };
@@ -527,7 +511,7 @@ export function createMcpRoute(opts) {
527
511
  if (new URL(request.url).searchParams.has("toolkit")) {
528
512
  return cors(toolkitRetired(opts.logger));
529
513
  }
530
- return cors(yield* serveMcp(localRequest, localAbort.signal, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, poolName, runtimeContext));
514
+ return cors(yield* serveMcp(localRequest, localAbort.signal, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext));
531
515
  });
532
516
  if (remainingMs === undefined)
533
517
  return yield* handled;
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect";
2
+ import { closeConnectorScope } from "../connector-scope.js";
2
3
  import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
3
4
  import { escapeHtml, renderPage, resolveBranding, STATUS_ICONS } from "../branding.js";
4
5
  import { oauthCallbackOutcome, providerErrorReason, } from "../oauth-callback-outcome.js";
@@ -127,79 +128,84 @@ async function finishOAuthCallback(context) {
127
128
  await equalizeRefusalCost(connectorContext);
128
129
  return refused();
129
130
  }
130
- const expectedPrincipalKey = callbackTarget?.principalKey;
131
- // A browser returning from consent normally has no MCP Authorization
132
- // header. An interactive bearer provider therefore answers 401 here; state
133
- // and the saved state-to-principal handoff still prove ownership below.
134
- // Rejecting 401 would break that callback. A 403 is an explicit denial.
135
- const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.auth, "OAuth callback", context.runtimeContext, opts.identity);
136
- if (browserIdentity.ok) {
137
- try {
138
- validateAuthPermissions(browserIdentity, opts.registry);
131
+ try {
132
+ const expectedPrincipalKey = callbackTarget?.principalKey;
133
+ // A browser returning from consent normally has no MCP Authorization
134
+ // header. An interactive bearer provider therefore answers 401 here; state
135
+ // and the saved state-to-principal handoff still prove ownership below.
136
+ // Rejecting 401 would break that callback. A 403 is an explicit denial.
137
+ const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.auth, "OAuth callback", context.runtimeContext, opts.identity);
138
+ if (browserIdentity.ok) {
139
+ try {
140
+ validateAuthPermissions(browserIdentity, opts.registry);
141
+ }
142
+ catch {
143
+ return refused();
144
+ }
145
+ if (!mayManageConnector(browserIdentity, connector) || (expectedPrincipalKey && browserIdentity.principalKey !== expectedPrincipalKey))
146
+ return refused();
139
147
  }
140
- catch {
148
+ else if (browserIdentity.response.status === 403 && opts.auth.some(provider => provider.interactiveOperator)) {
141
149
  return refused();
142
150
  }
143
- if (!mayManageConnector(browserIdentity, connector) || (expectedPrincipalKey && browserIdentity.principalKey !== expectedPrincipalKey))
151
+ // CSRF / login-fixation guard: this route is intentionally public, so verify
152
+ // the `state` matches the flow connecta started BEFORE exchanging the code.
153
+ if (!connector.verifyState) {
154
+ await equalizeRefusalCost(connectorContext);
155
+ opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
156
+ `${loggableValue(id)} with 400: it implements finishAuth but no ` +
157
+ "verifyState, so connecta cannot establish that it started this flow. " +
158
+ "No authorization code was exchanged. Implement verifyState before " +
159
+ "trying again.");
144
160
  return refused();
145
- }
146
- else if (browserIdentity.response.status === 403 && opts.auth.some(provider => provider.interactiveOperator)) {
147
- return refused();
148
- }
149
- // CSRF / login-fixation guard: this route is intentionally public, so verify
150
- // the `state` matches the flow connecta started BEFORE exchanging the code.
151
- if (!connector.verifyState) {
152
- await equalizeRefusalCost(connectorContext);
153
- opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
154
- `${loggableValue(id)} with 400: it implements finishAuth but no ` +
155
- "verifyState, so connecta cannot establish that it started this flow. " +
156
- "No authorization code was exchanged. Implement verifyState before " +
157
- "trying again.");
158
- return refused();
159
- }
160
- let stateMatches;
161
- try {
162
- stateMatches = await connector.verifyState(state, connectorContext);
163
- }
164
- catch (err) {
165
- opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
166
- `${loggableValue(id)} with 400: verifyState threw ` +
167
- `${loggableValue(msg(err))}. No authorization code was exchanged. ` +
168
- "Re-run authorization from connecta and check the verifier if it " +
169
- "fails again.");
170
- return refused();
171
- }
172
- if (!stateMatches) {
173
- opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
174
- `${loggableValue(id)} with 400: ` +
175
- (state === null
176
- ? "the state parameter was missing"
177
- : "the state did not match the pending authorization flow") +
178
- ". No authorization code was exchanged. Re-run authorization from " +
179
- "connecta and try again.");
180
- return refused();
181
- }
182
- if (connector.authScope === "personal") {
161
+ }
162
+ let stateMatches;
183
163
  try {
184
- await opts.registry.clearOAuthHandoff(id, state);
164
+ stateMatches = await connector.verifyState(state, connectorContext);
185
165
  }
186
166
  catch (err) {
187
167
  opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
188
- `${loggableValue(id)} with 500: its principal handoff could not be ` +
189
- `consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
190
- return html("handoff_failed", opts, connector);
168
+ `${loggableValue(id)} with 400: verifyState threw ` +
169
+ `${loggableValue(msg(err))}. No authorization code was exchanged. ` +
170
+ "Re-run authorization from connecta and check the verifier if it " +
171
+ "fails again.");
172
+ return refused();
173
+ }
174
+ if (!stateMatches) {
175
+ opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
176
+ `${loggableValue(id)} with 400: ` +
177
+ (state === null
178
+ ? "the state parameter was missing"
179
+ : "the state did not match the pending authorization flow") +
180
+ ". No authorization code was exchanged. Re-run authorization from " +
181
+ "connecta and try again.");
182
+ return refused();
183
+ }
184
+ if (connector.authScope === "personal") {
185
+ try {
186
+ await opts.registry.clearOAuthHandoff(id, state);
187
+ }
188
+ catch (err) {
189
+ opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
190
+ `${loggableValue(id)} with 500: its principal handoff could not be ` +
191
+ `consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
192
+ return html("handoff_failed", opts, connector);
193
+ }
194
+ }
195
+ try {
196
+ await connector.finishAuth(code, connectorContext, url.searchParams);
197
+ await callbackRegistry.invalidateStored(id);
198
+ return html("connected", opts, connector);
199
+ }
200
+ catch (err) {
201
+ // The page names the reason and nothing else; what the exchange threw can
202
+ // quote a token endpoint's body, so it goes only to the operator log.
203
+ opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
204
+ `with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
205
+ return html("exchange_failed", opts, connector);
191
206
  }
192
207
  }
193
- try {
194
- await connector.finishAuth(code, connectorContext, url.searchParams);
195
- await callbackRegistry.invalidateStored(id);
196
- return html("connected", opts, connector);
197
- }
198
- catch (err) {
199
- // The page names the reason and nothing else; what the exchange threw can
200
- // quote a token endpoint's body, so it goes only to the operator log.
201
- opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
202
- `with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
203
- return html("exchange_failed", opts, connector);
208
+ finally {
209
+ await closeConnectorScope(connector, connectorContext, context.defer);
204
210
  }
205
211
  }
@@ -41,14 +41,9 @@ export interface ServerOptions {
41
41
  hostCallTimeoutMs?: number | undefined;
42
42
  /** Hard ceiling on one execute_code run, outside the sandbox. Default 120_000. */
43
43
  watchdogMs?: number | undefined;
44
- /** Resumable writes' settings; absent when they are off. */
45
- resumable?: {
46
- maxWrites: number;
47
- ttlSeconds: number;
48
- } | undefined;
49
44
  /** Config approval exemptions (`execute.approval`), resolved at construction. */
50
45
  approval?: ApprovalPolicy | undefined;
51
- /** Writes one program may send (`execute.maxWrites`), exempt ones included. */
46
+ /** Exempt writes one program may send (`execute.maxWrites`). */
52
47
  maxWrites?: number | undefined;
53
48
  /** Required sandbox backing the execute_code meta-tool. */
54
49
  executor: Executor;
@@ -34,7 +34,9 @@ function setMicrotask(task) {
34
34
  export const microtaskScheduler = new Scheduler.MixedScheduler("async", setMicrotask);
35
35
  const runExit = Effect.runPromiseExitWith(Context.make(Scheduler.Scheduler, microtaskScheduler));
36
36
  function abortReason(signal) {
37
- return signal?.reason ?? new DOMException("aborted", "AbortError");
37
+ return signal && signal.reason !== undefined
38
+ ? signal.reason
39
+ : new DOMException("aborted", "AbortError");
38
40
  }
39
41
  /**
40
42
  * Turn a failed Exit back into the value a Promise caller would have seen.
@@ -136,6 +138,10 @@ export function fromSignal(signal) {
136
138
  */
137
139
  export function withDeadlineEffect(operation, options) {
138
140
  return Effect.suspend(() => {
141
+ // Do not dispatch work that already lost its caller. The operation is the
142
+ // first race contender and can finish before the abort contender starts.
143
+ if (options.signal?.aborted)
144
+ return Effect.fail(abortReason(options.signal));
139
145
  const controller = new AbortController();
140
146
  const forwardAbort = () => controller.abort(options.signal?.reason);
141
147
  options.signal?.addEventListener("abort", forwardAbort, { once: true });