@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.
- package/CHANGELOG.md +23 -0
- package/bin/version.mjs +1 -1
- package/dist/auth/downstream-oauth.js +31 -12
- package/dist/auth/static-oauth.js +3 -2
- package/dist/auth/stored-observation.js +8 -0
- package/dist/call-admission.js +27 -14
- package/dist/connectors/api-connector.js +4 -0
- package/dist/connectors/option-shapes.js +5 -1
- package/dist/connectors/remote-mcp.d.ts +29 -3
- package/dist/connectors/remote-mcp.js +142 -22
- package/dist/execute.js +5 -5
- package/dist/executor-admission.js +27 -13
- package/dist/executors/quickjs.js +4 -1
- package/dist/index.d.ts +2 -1
- package/dist/invocation.js +6 -7
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +2 -0
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/request-principal.js +34 -0
- package/dist/request-token.d.ts +46 -0
- package/dist/routes/mcp.js +6 -6
- package/dist/routes/ui-config.js +38 -9
- package/dist/routes/ui-data.js +30 -5
- package/dist/routes/ui.js +10 -2
- package/dist/runtime/settlement.js +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +4 -1
- package/documentation/architecture.md +68 -25
- package/documentation/auth.md +129 -11
- package/documentation/code-mode.md +25 -5
- package/documentation/integrating.md +18 -4
- package/documentation/operator-tests.md +19 -0
- package/documentation/operator-ui.md +57 -46
- package/examples/worker/README.md +5 -1
- package/guides/connecting-agents.md +6 -0
- package/guides/safety-and-trust.md +1 -0
- package/package.json +1 -1
- 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,
|
|
1166
|
+
trackRemoteClientRequest(secrets, input, tracked);
|
|
1144
1167
|
else
|
|
1145
|
-
secrets.request(input,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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(
|
|
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
|
-
|
|
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
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
* this request's providers and run
|
|
1019
|
-
* which workerd refuses
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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:
|
|
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
|
|
263
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/dist/invocation.js
CHANGED
|
@@ -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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* this fiber
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|