@zackbart/connecta 0.36.1 → 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 (46) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bin/version.mjs +1 -1
  3. package/dist/auth/downstream-oauth.js +34 -12
  4. package/dist/auth/oauth-authorization-params.js +52 -0
  5. package/dist/auth/static-oauth.js +4 -6
  6. package/dist/auth/stored-observation.js +8 -0
  7. package/dist/call-admission.js +27 -14
  8. package/dist/connectors/api-connector.js +4 -0
  9. package/dist/connectors/option-shapes.js +6 -1
  10. package/dist/connectors/remote-mcp.d.ts +32 -3
  11. package/dist/connectors/remote-mcp.js +152 -21
  12. package/dist/execute.js +5 -5
  13. package/dist/executor-admission.js +27 -13
  14. package/dist/executors/quickjs.js +4 -1
  15. package/dist/google.d.ts +13 -0
  16. package/dist/google.js +42 -0
  17. package/dist/index.d.ts +2 -1
  18. package/dist/invocation.js +6 -7
  19. package/dist/operator-ui/generated.js +2 -2
  20. package/dist/operator-ui/model.d.ts +2 -0
  21. package/dist/providers/_shared/google/service-account.d.ts +1 -1
  22. package/dist/providers/_shared/google/service-account.js +20 -8
  23. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  24. package/dist/request-principal.js +34 -0
  25. package/dist/request-token.d.ts +53 -0
  26. package/dist/request-token.js +1 -0
  27. package/dist/routes/mcp.js +6 -6
  28. package/dist/routes/ui-config.js +38 -9
  29. package/dist/routes/ui-data.js +30 -5
  30. package/dist/routes/ui.js +10 -2
  31. package/dist/runtime/settlement.js +59 -0
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/dist/worker.js +4 -1
  35. package/documentation/README.md +13 -12
  36. package/documentation/architecture.md +91 -26
  37. package/documentation/auth.md +317 -12
  38. package/documentation/code-mode.md +25 -5
  39. package/documentation/integrating.md +18 -4
  40. package/documentation/operator-tests.md +19 -0
  41. package/documentation/operator-ui.md +57 -46
  42. package/examples/worker/README.md +5 -1
  43. package/guides/connecting-agents.md +6 -0
  44. package/guides/safety-and-trust.md +1 -0
  45. package/package.json +5 -1
  46. package/templates/node/package.json +1 -1
@@ -1,3 +1,5 @@
1
+ import { withStoredAuth } from "../auth/stored-observation.js";
2
+ import { authorizationParamsFor } from "../auth/oauth-authorization-params.js";
1
3
  import { authView } from "../auth/native-call.js";
2
4
  import { authFlow, authCall, authInvoke, authValue, authMethod, authCatch } from "../auth/native.js";
3
5
  import { isOAuthCredentialRequest } from "../auth/oauth-token-response.js";
@@ -26,6 +28,7 @@ import { registerInvocationAuth } from "../invocation-auth.js";
26
28
  import { downstreamInputCapabilities, assertDownstreamOutputSafe, downstreamWriteContinuation, } from "../downstream-input-context.js";
27
29
  import { detach, runEdge } from "../runtime/run.js";
28
30
  import { assertKnownOptions, keys, optionsOf, strings } from "../config-schema.js";
31
+ import { requestPrincipalFor } from "../request-principal.js";
29
32
  import { describedEndpoint, describedUrl } from "../described.js";
30
33
  import { retryAfterMs } from "../http-policy.js";
31
34
  import { resourceUriMatchesTemplates } from "./resource-uri.js";
@@ -40,7 +43,7 @@ import { readNegotiation, storeNegotiation } from "./negotiation-cache.js";
40
43
  import { READ_ONLY, CALL_ADMISSION, REMOTE_MCP_AUTH, USAGE_GUIDE } from "./option-shapes.js";
41
44
  /** The closed options remoteMcp() accepts; see `assertKnownOptions`. */
42
45
  const REMOTE_MCP_OPTIONS = optionsOf()({
43
- ...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"),
44
47
  readOnly: READ_ONLY,
45
48
  callAdmission: CALL_ADMISSION,
46
49
  usageGuide: USAGE_GUIDE,
@@ -1101,6 +1104,7 @@ export function remoteMcp(id, opts) {
1101
1104
  const clientMetadataUrl = opts.auth?.type === "oauth" ? opts.auth.clientMetadataUrl : undefined;
1102
1105
  const oauthScope = opts.auth?.type === "oauth" ? opts.auth.scope : undefined;
1103
1106
  assertOAuthScope(id, oauthScope);
1107
+ const authorizationParams = authorizationParamsFor(opts.auth?.type === "oauth" ? opts.auth.authorizationParams : undefined, `remoteMcp(${JSON.stringify(id)}).auth.authorizationParams`);
1104
1108
  const staticClient = opts.auth?.type === "oauth" && opts.auth.client ? { ...opts.auth.client } : undefined;
1105
1109
  assertRemoteOAuthClient(id, staticClient);
1106
1110
  if (staticClient && clientMetadataUrl !== undefined)
@@ -1133,14 +1137,35 @@ export function remoteMcp(id, opts) {
1133
1137
  // its actual sent auth with every operation using that client, including
1134
1138
  // handshake/discovery requests and OAuth rotations. Remove settled users.
1135
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
+ };
1136
1160
  const trackSentRequest = (ctx, input, init) => {
1137
1161
  const active = activeContexts.get(entryFor(ctx));
1138
1162
  const recipients = new Set([sentSecretsFor(ctx), ...[...(active ?? [])].map(sentSecretsFor)]);
1163
+ const tracked = withoutResourceHeaders(input, init);
1139
1164
  for (const secrets of recipients) {
1140
1165
  if (opts.auth?.type === "oauth")
1141
- trackRemoteClientRequest(secrets, input, init);
1166
+ trackRemoteClientRequest(secrets, input, tracked);
1142
1167
  else
1143
- secrets.request(input, init);
1168
+ secrets.request(input, tracked);
1144
1169
  }
1145
1170
  };
1146
1171
  const connectingWaiters = new WeakMap();
@@ -1177,10 +1202,29 @@ export function remoteMcp(id, opts) {
1177
1202
  "this shape sends one secret in one header and reads the reserved " +
1178
1203
  "`value` field only.");
1179
1204
  }
1205
+ // Both request shapes, read field by field; a mixture is refused below.
1180
1206
  const requestAuth = opts.auth?.type === "request" ? opts.auth : undefined;
1181
- 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")) {
1182
1216
  throw new Error(`remoteMcp(${JSON.stringify(id)}) requires a request token callback and shared authScope.`);
1183
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 };
1184
1228
  if (requestAuth?.headers) {
1185
1229
  try {
1186
1230
  if (Object.keys(requestAuth.headers).some((name) => name.toLowerCase() === "authorization"))
@@ -1448,11 +1492,12 @@ export function remoteMcp(id, opts) {
1448
1492
  * trusting any cached client, so an operator's replacement is picked up
1449
1493
  * without a redeploy. The value stays in the caller's local scope.
1450
1494
  */
1451
- const readCredential = authMethod((ctx) => authFlow(function* () {
1495
+ const readCredential = authMethod((ctx, principal) => authFlow(function* () {
1452
1496
  if (requestAuth) {
1453
1497
  let value;
1454
1498
  try {
1455
- 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]));
1456
1501
  }
1457
1502
  catch (error) {
1458
1503
  if (error instanceof ConnectorCallError)
@@ -1722,6 +1767,7 @@ export function remoteMcp(id, opts) {
1722
1767
  url: new URL(opts.url).href,
1723
1768
  redirectUri,
1724
1769
  clientMetadata: downstreamClientMetadata(redirectUri, oauthScope, ctx.oauthClientName, staticClient ? remoteClientAuthMethod(staticClient) : "none"),
1770
+ ...(authorizationParams.length ? { authorizationParams } : {}),
1725
1771
  authScope: opts.authScope ?? "shared",
1726
1772
  versionNegotiation: opts.versionNegotiation ?? "auto",
1727
1773
  redirects: opts.redirects ?? "none",
@@ -1735,7 +1781,7 @@ export function remoteMcp(id, opts) {
1735
1781
  },
1736
1782
  }
1737
1783
  : {}),
1738
- }), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), metadataUrl, oauthScope, undefined, { name: ctx.oauthClientName, client: staticClient, secrets: sentSecretsFor(ctx) });
1784
+ }), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), metadataUrl, oauthScope, undefined, { name: ctx.oauthClientName, client: staticClient, secrets: sentSecretsFor(ctx), authorizationParams });
1739
1785
  if (state)
1740
1786
  state.provider = provider;
1741
1787
  return provider;
@@ -1750,7 +1796,9 @@ export function remoteMcp(id, opts) {
1750
1796
  /** The raw value behind `credentialFramed`, under the same terms. */
1751
1797
  credentialValue = null,
1752
1798
  /** A code exchange never transfers ownership to the refresh coordinator. */
1753
- exchangeOnly = false) => {
1799
+ exchangeOnly = false,
1800
+ /** The admitted principal `credentialValue` was resolved for, under the same terms. */
1801
+ principal = undefined) => {
1754
1802
  const resourceInit = (input, init) => {
1755
1803
  if (!extraHeaders)
1756
1804
  return init;
@@ -1802,7 +1850,12 @@ export function remoteMcp(id, opts) {
1802
1850
  if (toolCall && !flight && !untrackedRead(toolCall.params))
1803
1851
  throw missingDispatch();
1804
1852
  const credentialPost = isOauth && (init?.method ?? "GET").toUpperCase() === "POST" && (!skillObject(rpc) || rpc.jsonrpc !== "2.0");
1805
- 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;
1806
1859
  if (flight?.write) {
1807
1860
  if (flight.sent) {
1808
1861
  if (flight.request && flight.authorization)
@@ -1840,7 +1893,8 @@ export function remoteMcp(id, opts) {
1840
1893
  ? sendGuarded(sender, new URL(input instanceof Request ? input.url : String(input)), { ...init, redirect: "manual" }, dispatch)
1841
1894
  : (() => {
1842
1895
  dispatch();
1843
- 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);
1844
1898
  })();
1845
1899
  return pending.then((response) => {
1846
1900
  if (entry)
@@ -1856,6 +1910,11 @@ export function remoteMcp(id, opts) {
1856
1910
  return response;
1857
1911
  });
1858
1912
  }));
1913
+ if (response.status === 401 && requestToken?.invalidate && credentialValue !== null) {
1914
+ // Invalidate the exact bytes this transport sent, never a newer token.
1915
+ // Preserve the original 401 even if an operator hook fails. No replay.
1916
+ (yield authCatch(authInvoke(requestToken, "invalidate", principal ? [credentialValue, ctx, Object.freeze({ principal })] : [credentialValue, ctx]), () => { }));
1917
+ }
1859
1918
  if (credentialPost && !isOAuthCredentialRequest(init) && REDIRECT_STATUSES.has(response.status)) {
1860
1919
  (yield authCatch(authValue(() => response.body?.cancel()), () => { }));
1861
1920
  throw new ConnectorCallError("connector_call_failed", "OAuth registration endpoint redirected; the request was not repeated.", { retryable: false });
@@ -2071,15 +2130,24 @@ export function remoteMcp(id, opts) {
2071
2130
  let credentialValue = null;
2072
2131
  let credentialFramed = null;
2073
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;
2074
2138
  if (credentialAuth || requestAuth) {
2075
- credentialValue = (yield authCall(readCredential, [ctx]));
2139
+ credentialValue = (yield authCall(readCredential, [ctx, principal]));
2076
2140
  credentialFramed = credentialHeaderValue(credentialScheme, credentialValue);
2077
- 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
+ ]));
2078
2146
  if ((state.client || state.connecting) &&
2079
2147
  state.credentialDigest !== null &&
2080
2148
  state.credentialDigest !== credentialDigest) {
2081
2149
  const observedConnection = state.client ?? state.connecting;
2082
- const currentCredential = (yield authCall(readCredential, [ctx]));
2150
+ const currentCredential = (yield authCall(readCredential, [ctx, principal]));
2083
2151
  if (isClosed(state))
2084
2152
  throw scopeEndedError();
2085
2153
  if (currentCredential !== credentialValue || (state.client ?? state.connecting) !== observedConnection) {
@@ -2095,7 +2163,7 @@ export function remoteMcp(id, opts) {
2095
2163
  throw scopeEndedError();
2096
2164
  if (state.client)
2097
2165
  return state.client;
2098
- const attempt = state.connecting ?? startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest);
2166
+ const attempt = state.connecting ?? startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest, principal);
2099
2167
  if (joined !== attempt) {
2100
2168
  leave();
2101
2169
  joined = attempt;
@@ -2116,14 +2184,14 @@ export function remoteMcp(id, opts) {
2116
2184
  * started it included — waits on that; nothing awaits the run directly,
2117
2185
  * which is why handing it to `detach` orphans nothing.
2118
2186
  */
2119
- const startConnect = (ctx, state, credentialValue, credentialFramed, credentialDigest) => {
2187
+ const startConnect = (ctx, state, credentialValue, credentialFramed, credentialDigest, principal) => {
2120
2188
  const attempt = Deferred.makeUnsafe();
2121
2189
  state.connecting = attempt;
2122
2190
  // Published with the attempt, not with its result: a rotation that lands
2123
2191
  // while this connect is in flight has to be visible to the next caller,
2124
2192
  // which would otherwise wait on a client bound to the older key.
2125
2193
  state.credentialDigest = credentialDigest;
2126
- detach(connect(ctx, state, attempt, credentialValue, credentialFramed).pipe(
2194
+ detach(connect(ctx, state, attempt, credentialValue, credentialFramed, principal).pipe(
2127
2195
  // The connection belongs to this request, without the first waiter's
2128
2196
  // call/credential/budget authority. Refresh coordinators stay separate.
2129
2197
  Effect.provideService(RequestScope, Context.get(viewServices(ctx), RequestScope)), Effect.onExit((exit) => Effect.sync(() => {
@@ -2137,7 +2205,7 @@ export function remoteMcp(id, opts) {
2137
2205
  }))));
2138
2206
  return attempt;
2139
2207
  };
2140
- const connect = (ctx, state, attempt, credentialValue, credentialFramed) => {
2208
+ const connect = (ctx, state, attempt, credentialValue, credentialFramed, principal) => {
2141
2209
  // Force reset, rotation, and teardown all abandon an attempt the same way:
2142
2210
  // they unpublish it and close the lease it holds, if it holds one yet.
2143
2211
  const owned = () => state.connecting === attempt && !isClosed(state);
@@ -2269,7 +2337,7 @@ export function remoteMcp(id, opts) {
2269
2337
  return client;
2270
2338
  };
2271
2339
  let c = makeClient();
2272
- let t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue);
2340
+ let t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
2273
2341
  if (!owned()) {
2274
2342
  held.transport = t;
2275
2343
  return yield* Effect.fail(scopeEndedError());
@@ -2295,7 +2363,7 @@ export function remoteMcp(id, opts) {
2295
2363
  handshakeAbort.signal.aborted)
2296
2364
  throw error;
2297
2365
  c = makeClient();
2298
- t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue);
2366
+ t = buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
2299
2367
  state.transport = held.transport = t;
2300
2368
  recordWireErrors(t, () => observeCatalogChange(ctx, id, connectionAbort.signal, cacheOwner));
2301
2369
  (yield authInvoke(c, "connect", [
@@ -2389,6 +2457,56 @@ export function remoteMcp(id, opts) {
2389
2457
  closeHalf(state);
2390
2458
  }
2391
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
+ }));
2392
2510
  // Built once from construction-time values: names, framing, and public
2393
2511
  // URLs only. A static header's value and a stored credential never reach
2394
2512
  // it, and a URL keeps origin and path: a CIMD document's query may carry a
@@ -2408,6 +2526,9 @@ export function remoteMcp(id, opts) {
2408
2526
  : isOauth
2409
2527
  ? {
2410
2528
  mode: "oauth",
2529
+ ...(authorizationParams.length
2530
+ ? { authorizationParamNames: authorizationParams.map(([name]) => name) }
2531
+ : {}),
2411
2532
  ...(oauthScope !== undefined ? { scope: oauthScope } : {}),
2412
2533
  ...(describedClientMetadataUrl !== undefined ? { clientMetadataUrl: describedClientMetadataUrl } : {}),
2413
2534
  }
@@ -2859,6 +2980,12 @@ export function remoteMcp(id, opts) {
2859
2980
  }));
2860
2981
  try {
2861
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
+ ]));
2862
2989
  return (yield authCall(report, [{ state: "ok" }]));
2863
2990
  }
2864
2991
  catch (err) {
@@ -2963,7 +3090,11 @@ export function remoteMcp(id, opts) {
2963
3090
  if (ctx.signal?.aborted)
2964
3091
  throw ctx.signal.reason;
2965
3092
  try {
3093
+ // The handshake runs first, so a 401 challenge's
3094
+ // `resource_metadata` starts consent as it always has.
2966
3095
  (yield authCall(ensureConnected, [ctx, state]));
3096
+ if (!(yield authCall(granted, [ctx])))
3097
+ return (yield authCall(startUnchallenged, [ctx]));
2967
3098
  return {
2968
3099
  state: "ok",
2969
3100
  message: "Already authorized \u2014 connection is healthy.",
@@ -3006,8 +3137,6 @@ export function remoteMcp(id, opts) {
3006
3137
  for (const value of Object.values(opts.auth.headers))
3007
3138
  secrets.header(value);
3008
3139
  }
3009
- for (const value of Object.values(extraHeaders ?? {}))
3010
- secrets.header(value);
3011
3140
  const state = entryFor(ctx);
3012
3141
  let active = activeContexts.get(state);
3013
3142
  if (!active) {
@@ -3141,5 +3270,7 @@ export function remoteMcp(id, opts) {
3141
3270
  if (!(yield authInvoke(provider, "tokens", [])))
3142
3271
  throw authRequiredError();
3143
3272
  })), 0), 0));
3273
+ if (isOauth)
3274
+ withStoredAuth(connector, (ctx) => newProvider(ctx).storedAuthorization());
3144
3275
  return connector;
3145
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)
@@ -0,0 +1,13 @@
1
+ import type { RequestToken } from "./request-token.js";
2
+ import { type GoogleServiceAccount } from "./providers/_shared/google/service-account.js";
3
+ export type { GoogleServiceAccount } from "./providers/_shared/google/service-account.js";
4
+ export interface GoogleServiceAccountTokenOptions {
5
+ /** The downloaded JSON key text, or its camelCase fields. Kept in deployment memory only. */
6
+ serviceAccount: GoogleServiceAccount | string;
7
+ /** Non-empty HTTPS OAuth scope URLs for the target API. */
8
+ scopes: readonly string[];
9
+ /** Optional Workspace user for domain-wide delegation; omitted means the account itself. */
10
+ subject?: string;
11
+ }
12
+ /** Mint Google OAuth access tokens for remoteMcp's shared request auth on Node or Workers. */
13
+ export declare function googleServiceAccountToken(options: GoogleServiceAccountTokenOptions): RequestToken;
package/dist/google.js ADDED
@@ -0,0 +1,42 @@
1
+ import { assertKnownOptions, ConfigError, keys, optionsOf } from "./config-schema.js";
2
+ import { delegatedToken, forgetDelegatedToken, parseServiceAccount, } from "./providers/_shared/google/service-account.js";
3
+ const OPTIONS = optionsOf()(keys("serviceAccount", "scopes", "subject"));
4
+ const ACCOUNT = optionsOf()(keys("clientEmail", "privateKey", "clientId"));
5
+ /** Mint Google OAuth access tokens for remoteMcp's shared request auth on Node or Workers. */
6
+ export function googleServiceAccountToken(options) {
7
+ const opts = assertKnownOptions(options, "googleServiceAccountToken", OPTIONS);
8
+ let input = opts.serviceAccount;
9
+ if (typeof input === "object" && input !== null) {
10
+ input = assertKnownOptions(input, "googleServiceAccountToken.serviceAccount", ACCOUNT);
11
+ }
12
+ let account;
13
+ try {
14
+ account = parseServiceAccount("googleServiceAccountToken", input);
15
+ }
16
+ catch {
17
+ // The legacy Workspace parser may quote a JSON credential's type. Never
18
+ // reflect arbitrary credential-file fields through this public helper.
19
+ throw new ConfigError("googleServiceAccountToken.serviceAccount requires a valid Google service-account JSON key or { clientEmail, privateKey, clientId? } with a PKCS#8 RSA key of at least 2048 bits.");
20
+ }
21
+ if (!Array.isArray(opts.scopes) || opts.scopes.length === 0 || !Array.from(opts.scopes).every(validScope)) {
22
+ throw new ConfigError("googleServiceAccountToken.scopes must be a non-empty array of HTTPS OAuth scope URLs.");
23
+ }
24
+ if (opts.subject !== undefined && (typeof opts.subject !== "string" || !/^[^\s@]+@[^\s@]+$/.test(opts.subject))) {
25
+ throw new ConfigError("googleServiceAccountToken.subject must be a Workspace email address when provided.");
26
+ }
27
+ const request = { account, subject: opts.subject, scopes: [...new Set(opts.scopes)] };
28
+ const token = async (ctx) => await delegatedToken(request, ctx);
29
+ token.invalidate = async (rejected) => await forgetDelegatedToken(request, rejected);
30
+ return token;
31
+ }
32
+ function validScope(value) {
33
+ if (typeof value !== "string" || /\s/.test(value))
34
+ return false;
35
+ try {
36
+ const url = new URL(value);
37
+ return url.protocol === "https:" && !url.username && !url.password && !url.hash;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
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