@zackbart/connecta 0.36.2 → 0.37.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 (39) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/bin/version.mjs +1 -1
  3. package/dist/auth/downstream-oauth.js +31 -12
  4. package/dist/auth/static-oauth.js +3 -2
  5. package/dist/auth/stored-observation.js +8 -0
  6. package/dist/call-admission.js +27 -14
  7. package/dist/connectors/api-connector.js +4 -0
  8. package/dist/connectors/option-shapes.js +5 -1
  9. package/dist/connectors/remote-mcp.d.ts +29 -3
  10. package/dist/connectors/remote-mcp.js +142 -22
  11. package/dist/execute.js +5 -5
  12. package/dist/executor-admission.js +27 -13
  13. package/dist/executors/quickjs.js +4 -1
  14. package/dist/index.d.ts +2 -1
  15. package/dist/invocation.js +6 -7
  16. package/dist/operator-ui/generated.js +2 -2
  17. package/dist/operator-ui/model.d.ts +2 -0
  18. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  19. package/dist/request-principal.js +34 -0
  20. package/dist/request-token.d.ts +46 -0
  21. package/dist/routes/mcp.js +6 -6
  22. package/dist/routes/ui-config.js +38 -9
  23. package/dist/routes/ui-data.js +30 -5
  24. package/dist/routes/ui.js +10 -2
  25. package/dist/runtime/settlement.js +59 -0
  26. package/dist/version.d.ts +1 -1
  27. package/dist/version.js +1 -1
  28. package/dist/worker.js +4 -1
  29. package/documentation/architecture.md +68 -25
  30. package/documentation/auth.md +129 -11
  31. package/documentation/code-mode.md +25 -5
  32. package/documentation/integrating.md +18 -4
  33. package/documentation/operator-tests.md +19 -0
  34. package/documentation/operator-ui.md +57 -46
  35. package/examples/worker/README.md +5 -1
  36. package/guides/connecting-agents.md +6 -0
  37. package/guides/safety-and-trust.md +1 -0
  38. package/package.json +1 -1
  39. package/templates/node/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { withStoredAuth } from "../auth/stored-observation.js";
1
2
  import { authorizationParamsFor } from "../auth/oauth-authorization-params.js";
2
3
  import { authView } from "../auth/native-call.js";
3
4
  import { authFlow, authCall, authInvoke, authValue, authMethod, authCatch } from "../auth/native.js";
@@ -27,6 +28,7 @@ import { registerInvocationAuth } from "../invocation-auth.js";
27
28
  import { downstreamInputCapabilities, assertDownstreamOutputSafe, downstreamWriteContinuation, } from "../downstream-input-context.js";
28
29
  import { detach, runEdge } from "../runtime/run.js";
29
30
  import { assertKnownOptions, keys, optionsOf, strings } from "../config-schema.js";
31
+ import { requestPrincipalFor } from "../request-principal.js";
30
32
  import { describedEndpoint, describedUrl } from "../described.js";
31
33
  import { retryAfterMs } from "../http-policy.js";
32
34
  import { resourceUriMatchesTemplates } from "./resource-uri.js";
@@ -41,7 +43,7 @@ import { readNegotiation, storeNegotiation } from "./negotiation-cache.js";
41
43
  import { READ_ONLY, CALL_ADMISSION, REMOTE_MCP_AUTH, USAGE_GUIDE } from "./option-shapes.js";
42
44
  /** The closed options remoteMcp() accepts; see `assertKnownOptions`. */
43
45
  const REMOTE_MCP_OPTIONS = optionsOf()({
44
- ...keys("url", "title", "description", "authScope", "maxResultBytes", "versionNegotiation", "redirects", "requireHttps", "logger", "_transportFactory", "skills"),
46
+ ...keys("url", "title", "description", "authScope", "maxResultBytes", "versionNegotiation", "redirects", "requireHttps", "logger", "fetch", "_transportFactory", "skills"),
45
47
  readOnly: READ_ONLY,
46
48
  callAdmission: CALL_ADMISSION,
47
49
  usageGuide: USAGE_GUIDE,
@@ -1135,14 +1137,35 @@ export function remoteMcp(id, opts) {
1135
1137
  // its actual sent auth with every operation using that client, including
1136
1138
  // handshake/discovery requests and OAuth rotations. Remove settled users.
1137
1139
  const activeContexts = new WeakMap();
1140
+ // Resource headers are non-secret context by contract. They never join the
1141
+ // sent set, so arguments and results equal to them pass unchanged (#1108).
1142
+ // An argument mirrored as `Mcp-Param-*` carrying a resource value is the
1143
+ // same context under a name the tracker may read as credential-shaped.
1144
+ const resourceValues = new Set(Object.values(extraHeaders ?? {}));
1145
+ const withoutResourceHeaders = (input, init) => {
1146
+ if (!extraHeaders)
1147
+ return init;
1148
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
1149
+ for (const [name, value] of Object.entries(extraHeaders))
1150
+ if (headers.get(name) === value)
1151
+ headers.delete(name);
1152
+ const mirrored = [];
1153
+ for (const [name, value] of headers)
1154
+ if (name.startsWith("mcp-param-") && resourceValues.has(value))
1155
+ mirrored.push(name);
1156
+ for (const name of mirrored)
1157
+ headers.delete(name);
1158
+ return { ...init, headers };
1159
+ };
1138
1160
  const trackSentRequest = (ctx, input, init) => {
1139
1161
  const active = activeContexts.get(entryFor(ctx));
1140
1162
  const recipients = new Set([sentSecretsFor(ctx), ...[...(active ?? [])].map(sentSecretsFor)]);
1163
+ const tracked = withoutResourceHeaders(input, init);
1141
1164
  for (const secrets of recipients) {
1142
1165
  if (opts.auth?.type === "oauth")
1143
- trackRemoteClientRequest(secrets, input, init);
1166
+ trackRemoteClientRequest(secrets, input, tracked);
1144
1167
  else
1145
- secrets.request(input, init);
1168
+ secrets.request(input, tracked);
1146
1169
  }
1147
1170
  };
1148
1171
  const connectingWaiters = new WeakMap();
@@ -1179,10 +1202,29 @@ export function remoteMcp(id, opts) {
1179
1202
  "this shape sends one secret in one header and reads the reserved " +
1180
1203
  "`value` field only.");
1181
1204
  }
1205
+ // Both request shapes, read field by field; a mixture is refused below.
1182
1206
  const requestAuth = opts.auth?.type === "request" ? opts.auth : undefined;
1183
- if (requestAuth && (typeof requestAuth.token !== "function" || opts.authScope === "personal")) {
1207
+ // Opt-in only: without it the resolver keeps receiving the context alone.
1208
+ // `principal` replaces `token`; a malformed or mixed shape has no resolver.
1209
+ const forwarding = requestAuth?.principal;
1210
+ const requestToken = forwarding
1211
+ ? requestAuth?.token === undefined && (forwarding.accept === "human" || forwarding.accept === "human-or-agent")
1212
+ ? forwarding.token
1213
+ : undefined
1214
+ : requestAuth?.token;
1215
+ if (requestAuth && (typeof requestToken !== "function" || opts.authScope === "personal")) {
1184
1216
  throw new Error(`remoteMcp(${JSON.stringify(id)}) requires a request token callback and shared authScope.`);
1185
1217
  }
1218
+ const principalPolicy = forwarding?.accept;
1219
+ const customFetch = opts.fetch;
1220
+ // OAuth discovery, registration and token requests reach authorization
1221
+ // servers the downstream names, through the owned credential sender. A
1222
+ // custom fetch would split that traffic between two transports.
1223
+ if (customFetch !== undefined && (typeof customFetch !== "function" || opts.auth?.type === "oauth")) {
1224
+ throw new Error(`remoteMcp(${JSON.stringify(id)}).fetch must be a function, without OAuth auth.`);
1225
+ }
1226
+ // No delayed dispatch: a write counts as sent once it is handed to the function.
1227
+ const customSender = customFetch && { send: customFetch };
1186
1228
  if (requestAuth?.headers) {
1187
1229
  try {
1188
1230
  if (Object.keys(requestAuth.headers).some((name) => name.toLowerCase() === "authorization"))
@@ -1450,11 +1492,12 @@ export function remoteMcp(id, opts) {
1450
1492
  * trusting any cached client, so an operator's replacement is picked up
1451
1493
  * without a redeploy. The value stays in the caller's local scope.
1452
1494
  */
1453
- const readCredential = authMethod((ctx) => authFlow(function* () {
1495
+ const readCredential = authMethod((ctx, principal) => authFlow(function* () {
1454
1496
  if (requestAuth) {
1455
1497
  let value;
1456
1498
  try {
1457
- value = (yield authInvoke(requestAuth, "token", [ctx]));
1499
+ // A fresh frozen input per resolution: one resolver cannot alter another's.
1500
+ value = (yield authInvoke(forwarding ?? requestAuth, "token", principal ? [ctx, Object.freeze({ principal })] : [ctx]));
1458
1501
  }
1459
1502
  catch (error) {
1460
1503
  if (error instanceof ConnectorCallError)
@@ -1753,7 +1796,9 @@ export function remoteMcp(id, opts) {
1753
1796
  /** The raw value behind `credentialFramed`, under the same terms. */
1754
1797
  credentialValue = null,
1755
1798
  /** A code exchange never transfers ownership to the refresh coordinator. */
1756
- exchangeOnly = false) => {
1799
+ exchangeOnly = false,
1800
+ /** The admitted principal `credentialValue` was resolved for, under the same terms. */
1801
+ principal = undefined) => {
1757
1802
  const resourceInit = (input, init) => {
1758
1803
  if (!extraHeaders)
1759
1804
  return init;
@@ -1805,7 +1850,12 @@ export function remoteMcp(id, opts) {
1805
1850
  if (toolCall && !flight && !untrackedRead(toolCall.params))
1806
1851
  throw missingDispatch();
1807
1852
  const credentialPost = isOauth && (init?.method ?? "GET").toUpperCase() === "POST" && (!skillObject(rpc) || rpc.jsonrpc !== "2.0");
1808
- const sender = flight?.write ? guardedTransport(true) : undefined;
1853
+ // A custom transport reaches only the configured origin; redirects are
1854
+ // already same-origin, and OAuth cannot be combined with it.
1855
+ if (customFetch && new URL(input instanceof Request ? input.url : String(input)).origin !== destination.origin)
1856
+ throw new ConnectorCallError("connector_call_failed", `Connector "${id}" refused a request outside its origin; nothing was sent.`, { retryable: false });
1857
+ // The deployment's fetch owns the wire: each write is handed over once.
1858
+ const sender = flight?.write ? (customSender ?? guardedTransport(true)) : undefined;
1809
1859
  if (flight?.write) {
1810
1860
  if (flight.sent) {
1811
1861
  if (flight.request && flight.authorization)
@@ -1843,7 +1893,8 @@ export function remoteMcp(id, opts) {
1843
1893
  ? sendGuarded(sender, new URL(input instanceof Request ? input.url : String(input)), { ...init, redirect: "manual" }, dispatch)
1844
1894
  : (() => {
1845
1895
  dispatch();
1846
- return writeContinuation ? fetch(input, { ...init, redirect: "manual" }) : fetch(input, init);
1896
+ const send = customFetch ?? fetch;
1897
+ return writeContinuation ? send(input, { ...init, redirect: "manual" }) : send(input, init);
1847
1898
  })();
1848
1899
  return pending.then((response) => {
1849
1900
  if (entry)
@@ -1859,10 +1910,10 @@ export function remoteMcp(id, opts) {
1859
1910
  return response;
1860
1911
  });
1861
1912
  }));
1862
- if (response.status === 401 && requestAuth?.token.invalidate && credentialValue !== null) {
1913
+ if (response.status === 401 && requestToken?.invalidate && credentialValue !== null) {
1863
1914
  // Invalidate the exact bytes this transport sent, never a newer token.
1864
1915
  // Preserve the original 401 even if an operator hook fails. No replay.
1865
- (yield authCatch(authInvoke(requestAuth.token, "invalidate", [credentialValue, ctx]), () => { }));
1916
+ (yield authCatch(authInvoke(requestToken, "invalidate", principal ? [credentialValue, ctx, Object.freeze({ principal })] : [credentialValue, ctx]), () => { }));
1866
1917
  }
1867
1918
  if (credentialPost && !isOAuthCredentialRequest(init) && REDIRECT_STATUSES.has(response.status)) {
1868
1919
  (yield authCatch(authValue(() => response.body?.cancel()), () => { }));
@@ -2079,15 +2130,24 @@ export function remoteMcp(id, opts) {
2079
2130
  let credentialValue = null;
2080
2131
  let credentialFramed = null;
2081
2132
  let credentialDigest = null;
2133
+ // Only the admitted caller on this operation's context, never an argument.
2134
+ // Refused before the resolver runs, so no token is minted for anyone else.
2135
+ const principal = principalPolicy
2136
+ ? requestPrincipalFor(principalPolicy, ctx, (reason) => new CredentialRequiredError(`Connector "${id}" ${reason}.`))
2137
+ : undefined;
2082
2138
  if (credentialAuth || requestAuth) {
2083
- credentialValue = (yield authCall(readCredential, [ctx]));
2139
+ credentialValue = (yield authCall(readCredential, [ctx, principal]));
2084
2140
  credentialFramed = credentialHeaderValue(credentialScheme, credentialValue);
2085
- credentialDigest = (yield authCall(digestOf, [credentialValue]));
2141
+ // The principal joins the client's identity: another principal never
2142
+ // reuses this client, even if its resolver returns the same token.
2143
+ credentialDigest = (yield authCall(digestOf, [
2144
+ principal ? JSON.stringify([principal, credentialValue]) : credentialValue,
2145
+ ]));
2086
2146
  if ((state.client || state.connecting) &&
2087
2147
  state.credentialDigest !== null &&
2088
2148
  state.credentialDigest !== credentialDigest) {
2089
2149
  const observedConnection = state.client ?? state.connecting;
2090
- const currentCredential = (yield authCall(readCredential, [ctx]));
2150
+ const currentCredential = (yield authCall(readCredential, [ctx, principal]));
2091
2151
  if (isClosed(state))
2092
2152
  throw scopeEndedError();
2093
2153
  if (currentCredential !== credentialValue || (state.client ?? state.connecting) !== observedConnection) {
@@ -2103,7 +2163,7 @@ export function remoteMcp(id, opts) {
2103
2163
  throw scopeEndedError();
2104
2164
  if (state.client)
2105
2165
  return state.client;
2106
- const attempt = state.connecting ?? startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest);
2166
+ const attempt = state.connecting ?? startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest, principal);
2107
2167
  if (joined !== attempt) {
2108
2168
  leave();
2109
2169
  joined = attempt;
@@ -2124,14 +2184,14 @@ export function remoteMcp(id, opts) {
2124
2184
  * started it included — waits on that; nothing awaits the run directly,
2125
2185
  * which is why handing it to `detach` orphans nothing.
2126
2186
  */
2127
- const startConnect = (ctx, state, credentialValue, credentialFramed, credentialDigest) => {
2187
+ const startConnect = (ctx, state, credentialValue, credentialFramed, credentialDigest, principal) => {
2128
2188
  const attempt = Deferred.makeUnsafe();
2129
2189
  state.connecting = attempt;
2130
2190
  // Published with the attempt, not with its result: a rotation that lands
2131
2191
  // while this connect is in flight has to be visible to the next caller,
2132
2192
  // which would otherwise wait on a client bound to the older key.
2133
2193
  state.credentialDigest = credentialDigest;
2134
- detach(connect(ctx, state, attempt, credentialValue, credentialFramed).pipe(
2194
+ detach(connect(ctx, state, attempt, credentialValue, credentialFramed, principal).pipe(
2135
2195
  // The connection belongs to this request, without the first waiter's
2136
2196
  // call/credential/budget authority. Refresh coordinators stay separate.
2137
2197
  Effect.provideService(RequestScope, Context.get(viewServices(ctx), RequestScope)), Effect.onExit((exit) => Effect.sync(() => {
@@ -2145,7 +2205,7 @@ export function remoteMcp(id, opts) {
2145
2205
  }))));
2146
2206
  return attempt;
2147
2207
  };
2148
- const connect = (ctx, state, attempt, credentialValue, credentialFramed) => {
2208
+ const connect = (ctx, state, attempt, credentialValue, credentialFramed, principal) => {
2149
2209
  // Force reset, rotation, and teardown all abandon an attempt the same way:
2150
2210
  // they unpublish it and close the lease it holds, if it holds one yet.
2151
2211
  const owned = () => state.connecting === attempt && !isClosed(state);
@@ -2277,7 +2337,7 @@ export function remoteMcp(id, opts) {
2277
2337
  return client;
2278
2338
  };
2279
2339
  let c = makeClient();
2280
- let t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue);
2340
+ let t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
2281
2341
  if (!owned()) {
2282
2342
  held.transport = t;
2283
2343
  return yield* Effect.fail(scopeEndedError());
@@ -2303,7 +2363,7 @@ export function remoteMcp(id, opts) {
2303
2363
  handshakeAbort.signal.aborted)
2304
2364
  throw error;
2305
2365
  c = makeClient();
2306
- t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue);
2366
+ t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
2307
2367
  state.transport = held.transport = t;
2308
2368
  recordWireErrors(t, () => observeCatalogChange(ctx, id, connectionAbort.signal, cacheOwner));
2309
2369
  (yield authInvoke(c, "connect", [
@@ -2397,6 +2457,56 @@ export function remoteMcp(id, opts) {
2397
2457
  closeHalf(state);
2398
2458
  }
2399
2459
  }));
2460
+ /**
2461
+ * Whether the owner's grant holds tokens, read the way the invocation gate
2462
+ * reads it. A downstream may answer its handshake and listing without
2463
+ * credentials, so a healthy connection proves nothing about the grant.
2464
+ * Read after connecting: connect has bound the epoch, migrated an older
2465
+ * layout, and refused a disconnected one within the scope's lifetime.
2466
+ */
2467
+ const granted = authMethod((ctx) => authFlow(function* () {
2468
+ return Boolean(yield authInvoke(newProvider(ctx), "tokens", []));
2469
+ }));
2470
+ /**
2471
+ * Start consent for a downstream that answered without challenging. The
2472
+ * transport's bound 401 seam runs the SDK flow exactly as a challenge
2473
+ * without `resource_metadata` would: RFC 9728 discovery at the resource's
2474
+ * well-known URLs, the same issuer and resource checks, consent state, and
2475
+ * CIMD/DCR/static client order. Nothing is sent to the MCP endpoint.
2476
+ */
2477
+ const startUnchallenged = authMethod((ctx) => authFlow(function* () {
2478
+ const required = {
2479
+ state: "auth_required",
2480
+ message: "Authorization required \u2014 open the URL to connect.",
2481
+ };
2482
+ // A test transport carries no OAuth flow to start.
2483
+ if (opts._transportFactory)
2484
+ return required;
2485
+ // Bound to the live epoch, which the connect just found connected: a
2486
+ // reset or disconnect meanwhile fails this flow's writes.
2487
+ const provider = newProvider(ctx);
2488
+ (yield authInvoke(provider, "beginFlow", []));
2489
+ // Never started, this transport holds no session or stream to close.
2490
+ const transport = buildTransport(ctx, provider);
2491
+ const internals = transport;
2492
+ try {
2493
+ (yield authInvoke(internals._authProvider, "onUnauthorized", [
2494
+ {
2495
+ response: new Response(null, { status: 401 }),
2496
+ serverUrl: new URL(opts.url),
2497
+ fetchFn: internals._fetchWithInit,
2498
+ },
2499
+ ]));
2500
+ // Authorized without consent: nothing is pending to hand back.
2501
+ return { state: "ok", message: "Already authorized \u2014 connection is healthy." };
2502
+ }
2503
+ catch (err) {
2504
+ if (!(err instanceof UnauthorizedError))
2505
+ throw err;
2506
+ }
2507
+ const authorizationUrl = (yield authInvoke(provider, "consentUrl", [viewSignal(ctx, true)]));
2508
+ return { ...required, ...(authorizationUrl !== undefined ? { authorizationUrl } : {}) };
2509
+ }));
2400
2510
  // Built once from construction-time values: names, framing, and public
2401
2511
  // URLs only. A static header's value and a stored credential never reach
2402
2512
  // it, and a URL keeps origin and path: a CIMD document's query may carry a
@@ -2870,6 +2980,12 @@ export function remoteMcp(id, opts) {
2870
2980
  }));
2871
2981
  try {
2872
2982
  (yield authCall(ensureConnected, [ctx, state]));
2983
+ // The grant decides, as it does for a call: a downstream that
2984
+ // answers without credentials is not connected for this owner.
2985
+ if (isOauth && !(yield authCall(granted, [ctx])))
2986
+ return (yield authCall(report, [
2987
+ { state: "auth_required", message: "Authorization required \u2014 open the URL to connect." },
2988
+ ]));
2873
2989
  return (yield authCall(report, [{ state: "ok" }]));
2874
2990
  }
2875
2991
  catch (err) {
@@ -2974,7 +3090,11 @@ export function remoteMcp(id, opts) {
2974
3090
  if (ctx.signal?.aborted)
2975
3091
  throw ctx.signal.reason;
2976
3092
  try {
3093
+ // The handshake runs first, so a 401 challenge's
3094
+ // `resource_metadata` starts consent as it always has.
2977
3095
  (yield authCall(ensureConnected, [ctx, state]));
3096
+ if (!(yield authCall(granted, [ctx])))
3097
+ return (yield authCall(startUnchallenged, [ctx]));
2978
3098
  return {
2979
3099
  state: "ok",
2980
3100
  message: "Already authorized \u2014 connection is healthy.",
@@ -3017,8 +3137,6 @@ export function remoteMcp(id, opts) {
3017
3137
  for (const value of Object.values(opts.auth.headers))
3018
3138
  secrets.header(value);
3019
3139
  }
3020
- for (const value of Object.values(extraHeaders ?? {}))
3021
- secrets.header(value);
3022
3140
  const state = entryFor(ctx);
3023
3141
  let active = activeContexts.get(state);
3024
3142
  if (!active) {
@@ -3152,5 +3270,7 @@ export function remoteMcp(id, opts) {
3152
3270
  if (!(yield authInvoke(provider, "tokens", [])))
3153
3271
  throw authRequiredError();
3154
3272
  })), 0), 0));
3273
+ if (isOauth)
3274
+ withStoredAuth(connector, (ctx) => newProvider(ctx).storedAuthorization());
3155
3275
  return connector;
3156
3276
  }
package/dist/execute.js CHANGED
@@ -1012,11 +1012,11 @@ function runSignal(caller) {
1012
1012
  /**
1013
1013
  * An admitting executor's lease, released when the run's scope closes.
1014
1014
  *
1015
- * `acquire()` is the executor's Promise, awaited as one on purpose. A queued
1016
- * request is handed its lease from inside whichever request released one, so
1017
- * a fiber resumed straight from the executor's admission queue would build
1018
- * this request's providers and run its program in the releasing request —
1019
- * which workerd refuses for any I/O. Awaiting the promise resumes it here.
1015
+ * A queued request is handed its lease from inside whichever request released
1016
+ * one. The built-in queues wake it through a Promise its own request created
1017
+ * (src/runtime/settlement.ts), and `acquire()` is awaited as a Promise for any
1018
+ * executor, so this request's providers and program run here, never in the
1019
+ * releasing request, which workerd refuses.
1020
1020
  */
1021
1021
  function leased(executor, signal) {
1022
1022
  return Effect.acquireRelease(awaitExecutor(() => executor.acquire({ signal, wait: true }), signal, {
@@ -1,6 +1,7 @@
1
- import { Deferred, Duration, Effect } from "effect";
1
+ import { Duration, Effect, Exit } from "effect";
2
2
  import { admit, provideAdmissionProgram } from "./runtime/admission.js";
3
3
  import { fromSignal, runEdge } from "./runtime/run.js";
4
+ import { Settlement } from "./runtime/settlement.js";
4
5
  import { keepErrorName } from "./redaction-identity.js";
5
6
  /**
6
7
  * A stable, machine-readable admission failure. Overload is retryable; caller
@@ -119,7 +120,7 @@ export class AdmissionController {
119
120
  this.closed = true;
120
121
  for (const waiter of this.waiters.splice(0)) {
121
122
  this.closedTotal++;
122
- Deferred.doneUnsafe(waiter.outcome, Effect.fail(new ExecutorAdmissionError("executor_closed", "Executor is shutting down.")));
123
+ waiter.outcome.settle(Exit.fail(new ExecutorAdmissionError("executor_closed", "Executor is shutting down.")));
123
124
  }
124
125
  }
125
126
  overloaded(message) {
@@ -164,7 +165,7 @@ export class AdmissionController {
164
165
  this.queueWaitTotalMs += waitMs;
165
166
  this.queueWaitMaxMs = Math.max(this.queueWaitMaxMs, waitMs);
166
167
  this.active++;
167
- Deferred.doneUnsafe(waiter.outcome, Effect.succeed(this.makeLease(waitMs)));
168
+ waiter.outcome.settle(Exit.succeed(this.makeLease(waitMs)));
168
169
  }
169
170
  }
170
171
  pruneExpiredWaiters() {
@@ -175,7 +176,7 @@ export class AdmissionController {
175
176
  continue;
176
177
  this.waiters.splice(index, 1);
177
178
  this.rejectedTotal++;
178
- Deferred.doneUnsafe(waiter.outcome, Effect.fail(this.overloaded(`Executor admission timed out after ${this.queueTimeoutMs}ms.`)));
179
+ waiter.outcome.settle(Exit.fail(this.overloaded(`Executor admission timed out after ${this.queueTimeoutMs}ms.`)));
179
180
  }
180
181
  }
181
182
  reapExpired() {
@@ -219,9 +220,11 @@ export class AdmissionController {
219
220
  this.cancelledTotal++;
220
221
  return Effect.void;
221
222
  }
222
- if (!Deferred.isDoneUnsafe(waiter.outcome))
223
- return Effect.void;
224
- return Deferred.await(waiter.outcome).pipe(Effect.tap((lease) => Effect.sync(() => lease.release())), Effect.ignore);
223
+ return Effect.sync(() => {
224
+ const outcome = waiter.outcome.outcome;
225
+ if (outcome && Exit.isSuccess(outcome))
226
+ outcome.value.release();
227
+ });
225
228
  }
226
229
  // The admission program, handed to src/runtime/admission.ts. It lives in a
227
230
  // static block because the class declaration ships, private member names
@@ -254,22 +257,22 @@ export class AdmissionController {
254
257
  }
255
258
  const waiter = {
256
259
  queuedAt: Date.now(),
257
- outcome: Deferred.makeUnsafe(),
260
+ outcome: new Settlement(),
258
261
  };
259
262
  controller.waiters.push(waiter);
260
263
  controller.queuedTotal++;
261
264
  // A timeout or abort settles the waiter only if it takes it out of the
262
- // queue first. Otherwise release() or close() got there in the same
263
- // tick, and the outcome they recorded is the answer.
265
+ // queue first. Otherwise release() or close() got there first, and the
266
+ // outcome they recorded is the answer, rechecked once heard.
264
267
  const giveUp = (count, error) => Effect.suspend(() => {
265
268
  if (!controller.remove(waiter))
266
- return Deferred.await(waiter.outcome);
269
+ return waiter.outcome.wait();
267
270
  count();
268
271
  return Effect.fail(error());
269
272
  });
270
273
  // One flat race; each contender is a forked fiber.
271
274
  const contenders = [
272
- Deferred.await(waiter.outcome),
275
+ waiter.outcome.wait(),
273
276
  Effect.sleep(Duration.millis(controller.queueTimeoutMs)).pipe(Effect.andThen(giveUp(() => controller.rejectedTotal++, () => controller.overloaded(`Executor admission timed out after ${controller.queueTimeoutMs}ms.`)))),
274
277
  ];
275
278
  if (controller.maxDurationMs !== undefined) {
@@ -278,7 +281,18 @@ export class AdmissionController {
278
281
  if (signal) {
279
282
  contenders.push(fromSignal(signal).pipe(Effect.catch(() => giveUp(() => controller.cancelledTotal++, () => new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled while queued.")))));
280
283
  }
281
- return Effect.raceAllFirst(contenders).pipe(Effect.onInterrupt(() => controller.cleanup(waiter)));
284
+ // A grant is recorded in the releasing request and heard here a turn
285
+ // later. A shutdown in that gap wins, so nothing granted before
286
+ // close() starts after it: give the slot back. A grant that beat the
287
+ // caller's abort stays the caller's to release, as it always has.
288
+ const received = (lease) => Effect.suspend(() => {
289
+ if (!controller.closed)
290
+ return Effect.succeed(lease);
291
+ lease.release();
292
+ controller.closedTotal++;
293
+ return Effect.fail(new ExecutorAdmissionError("executor_closed", "Executor is shutting down."));
294
+ });
295
+ return Effect.raceAllFirst(contenders).pipe(Effect.flatMap(received), Effect.onInterrupt(() => controller.cleanup(waiter)));
282
296
  }));
283
297
  }
284
298
  }
@@ -10,6 +10,7 @@
10
10
  // Respawn after a crash waits out a Schedule. The IPC protocol stays plain:
11
11
  // the child's messages arrive as Node events, and host calls are the
12
12
  // Promise-shaped provider functions execute.ts hands over.
13
+ import { AsyncResource } from "node:async_hooks";
13
14
  import { Buffer } from "node:buffer";
14
15
  import { fork } from "node:child_process";
15
16
  import { existsSync } from "node:fs";
@@ -273,6 +274,7 @@ class QuickJsChildPool {
273
274
  providers: providerMap,
274
275
  outcome: Deferred.makeUnsafe(),
275
276
  hosts: Scope.makeUnsafe(),
277
+ owner: new AsyncResource("connecta.quickjs.run"),
276
278
  };
277
279
  slot.active = active;
278
280
  try {
@@ -485,7 +487,8 @@ class QuickJsChildPool {
485
487
  if (message.type === "host-call") {
486
488
  if (message.jobId !== active.id)
487
489
  return;
488
- void detach(forkOwned(this.handleHostCall(slot, child.process, active, message), active.hosts));
490
+ // Dispatch in the program's own request, never the spawner's.
491
+ active.owner.runInAsyncScope(() => void detach(forkOwned(this.handleHostCall(slot, child.process, active, message), active.hosts)));
489
492
  return;
490
493
  }
491
494
  if ((message.type !== "result" && message.type !== "result-chunk") || message.jobId !== active.id)
package/dist/index.d.ts CHANGED
@@ -38,7 +38,8 @@ export { memoryStorage } from "./storage/memory.js";
38
38
  export { CONNECTA_VERSION } from "./version.js";
39
39
  export type { GuestApi, GuestResourceResult, GuestResult, GuestResultPage, GuestBlock, GuestNativeBlock, } from "./guest-types.js";
40
40
  export type { Registry } from "./registry.js";
41
- export type { RemoteMcpOptions, RemoteMcpAuth, RemoteMcpRedirectPolicy } from "./connectors/remote-mcp.js";
41
+ export type { RemoteMcpOptions, RemoteMcpAuth, RemoteMcpRequestAuth, RemoteMcpRedirectPolicy, } from "./connectors/remote-mcp.js";
42
+ export type { AgentRequestPrincipal, HumanRequestPrincipal, PrincipalForwarding, PrincipalRequestToken, RequestPrincipal, RequestPrincipalPolicy, RequestToken, RequestTokenInput, } from "./request-token.js";
42
43
  export type { ApiHandlerContext, ApiOAuthAccess, ApiOAuthClientAuthentication, ApiOAuthConfig, ApiOptions, ApiTool, } from "./connectors/api.js";
43
44
  export type { ConnectaBranding, ConnectaTheme, Connector, ConnectorCallAdmissionInput, ConnectorCallAdmissionPolicy, ConnectorCallAdmissionRule, ConnectorRollingWindowBudget, ConnectorCredentialAccess, ConnectorCredentialConfig, ConnectorCredentialFieldConfig, ConnectorCredentialValues, ConnectorContext, ConnectorAuthDescription, ConnectorDescription, ConnectorToolDescription, ConnectorUsageGuide, ConnectorSkill, ConnectorSkillResourceContents, DescribedEndpoint, ConnectorStatus, CredentialTestResult, AdmittingExecutor, AdmissionSnapshot, ExecuteResult, Executor, ExecutorLease, ExecutorProvider, InboundAuth, InboundAuthRuntimeContext, UiAuthConfig, AuthResult, AuthenticatedIdentity, IdentityReference, JsonSchema, KVStorage, Logger, ToolDef, ToolAnnotations, } from "./types.js";
44
45
  export type { ActivityActor, ActivityCatalogChange, ActivityCallSource, ActivityOutcome, ActivityPage, ActivityReadActor, ActivityReadEvent, ActivityReader, ActivityReadGate, ActivityReadPage, ActivitySink, ActivityStore, AgentFriction, ToolCallActivityEvent, } from "./activity.js";
@@ -33,13 +33,12 @@ export function timed(bucket, effect) {
33
33
  /**
34
34
  * The connector's call permit, owned by the enclosing Scope.
35
35
  *
36
- * Admission goes through `registry.admitCall`, a Promise, on purpose. The
37
- * permit queue is shared by every request in the isolate, and a queued
38
- * waiter is resumed from inside the request that released the slot. Were
39
- * this fiber to wait on the controller's Effect program itself
40
- * (`acquireCallScoped`), it would be resumed there and run this call's
41
- * downstream I/O in the releasing request, which workerd refuses. Awaiting
42
- * admitCall's promise resumes it in its own request instead.
36
+ * The permit queue is shared by every request in the isolate, and a queued
37
+ * waiter is handed its permit from inside the request that released the
38
+ * slot. The queue wakes it through a Promise its own request created
39
+ * (src/runtime/settlement.ts), and this fiber awaits `registry.admitCall`'s
40
+ * Promise, so this call's downstream I/O runs in its own request, never the
41
+ * releasing one, which workerd refuses.
43
42
  *
44
43
  * The wait stays interruptible, so a deadline ends it. A permit granted just
45
44
  * as the wait was interrupted still arrives, later, and is released on