@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
|
@@ -57,6 +57,8 @@ interface UiCredential {
|
|
|
57
57
|
notice?: string;
|
|
58
58
|
}
|
|
59
59
|
export interface UiConnector {
|
|
60
|
+
/** Complete catalog and status from this detail request, when supported. */
|
|
61
|
+
overlay?: OperatorConnectorOverlay;
|
|
60
62
|
id: string;
|
|
61
63
|
/** Downstream authentication owner. Older payload fixtures omit it as shared. */
|
|
62
64
|
authScope?: "shared" | "personal";
|
|
@@ -55,4 +55,4 @@ export declare const PLANNING_CENTER_MAX_RESPONSE_BYTES: number;
|
|
|
55
55
|
*/
|
|
56
56
|
export declare const PLANNING_CENTER_ADMISSION: ConnectorCallAdmissionPolicy;
|
|
57
57
|
/** Planning Center refuses a request without a descriptive one (HTTP 403). */
|
|
58
|
-
export declare const DEFAULT_USER_AGENT = "connecta/0.
|
|
58
|
+
export declare const DEFAULT_USER_AGENT = "connecta/0.37.0 (+https://github.com/zackbart/connecta)";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The admitted principal a request-auth resolver may receive (#1103).
|
|
2
|
+
//
|
|
3
|
+
// Only the caller core attached from the admitting authorization counts
|
|
4
|
+
// (`callerOf`, see connector-caller.ts). No argument, program or header can
|
|
5
|
+
// name it, and a context no request admitted has none. Nothing here
|
|
6
|
+
// invents a delegated identity: a caller that is not an admitted person, or a
|
|
7
|
+
// declared agent the deployment explicitly accepts, is refused before the
|
|
8
|
+
// resolver runs.
|
|
9
|
+
import { isAgentName } from "./agent-identity.js";
|
|
10
|
+
import { callerOf } from "./connector-caller.js";
|
|
11
|
+
import { validIdentityReference } from "./identity.js";
|
|
12
|
+
/**
|
|
13
|
+
* The principal this operation's admitted caller is. Anything else is refused
|
|
14
|
+
* through `refuse`, the connector's own `auth_required` error.
|
|
15
|
+
*/
|
|
16
|
+
export function requestPrincipalFor(policy, ctx, refuse) {
|
|
17
|
+
const caller = callerOf(ctx);
|
|
18
|
+
// An operator check, a context no request admitted, or an open deployment's anonymous caller.
|
|
19
|
+
if (!caller?.authenticated)
|
|
20
|
+
throw refuse("acts as the admitted caller, and this operation has none");
|
|
21
|
+
const { identity } = caller;
|
|
22
|
+
if (identity.agent !== undefined) {
|
|
23
|
+
if (policy !== "human-or-agent")
|
|
24
|
+
throw refuse("refuses agents unless deployment code accepts them");
|
|
25
|
+
if (!isAgentName(identity.agent) || identity.principal !== undefined || !caller.pool)
|
|
26
|
+
throw refuse("acts as the admitted caller, and refuses this one");
|
|
27
|
+
return Object.freeze({ kind: "agent", name: identity.agent, pool: caller.pool });
|
|
28
|
+
}
|
|
29
|
+
const principal = identity.principal;
|
|
30
|
+
if (!validIdentityReference(principal))
|
|
31
|
+
throw refuse("acts as the admitted caller, and refuses this one");
|
|
32
|
+
// Fixed key order: `JSON.stringify` of the frozen result is its partition key.
|
|
33
|
+
return Object.freeze({ kind: "human", namespace: principal.namespace, id: principal.id });
|
|
34
|
+
}
|
package/dist/request-token.d.ts
CHANGED
|
@@ -5,3 +5,49 @@ export interface RequestToken {
|
|
|
5
5
|
/** Drop only the rejected token. This hook never authorizes replay. */
|
|
6
6
|
invalidate?: (rejected: string, ctx: ConnectorContext) => Promise<void>;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* A person inbound auth admitted, as the `{ namespace, id }` a member
|
|
10
|
+
* reference resolves to. Never taken from arguments, programs or headers.
|
|
11
|
+
*/
|
|
12
|
+
export interface HumanRequestPrincipal {
|
|
13
|
+
readonly kind: "human";
|
|
14
|
+
readonly namespace: string;
|
|
15
|
+
readonly id: string;
|
|
16
|
+
}
|
|
17
|
+
/** A declared agent and the pool it was admitted through. */
|
|
18
|
+
export interface AgentRequestPrincipal {
|
|
19
|
+
readonly kind: "agent";
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly pool: string;
|
|
22
|
+
}
|
|
23
|
+
/** The admitted caller a principal-forwarding request resolver receives. */
|
|
24
|
+
export type RequestPrincipal = HumanRequestPrincipal | AgentRequestPrincipal;
|
|
25
|
+
/**
|
|
26
|
+
* Which admitted callers a principal-forwarding request resolver accepts.
|
|
27
|
+
* `"human"` refuses agents; `"human-or-agent"` also accepts declared agents.
|
|
28
|
+
*/
|
|
29
|
+
export type RequestPrincipalPolicy = "human" | "human-or-agent";
|
|
30
|
+
/**
|
|
31
|
+
* Opt-in principal forwarding for request auth: the resolver receives the
|
|
32
|
+
* admitted caller, and every other caller is refused before it runs.
|
|
33
|
+
*/
|
|
34
|
+
export type PrincipalForwarding = {
|
|
35
|
+
accept: "human";
|
|
36
|
+
token: PrincipalRequestToken<HumanRequestPrincipal>;
|
|
37
|
+
} | {
|
|
38
|
+
accept: "human-or-agent";
|
|
39
|
+
token: PrincipalRequestToken;
|
|
40
|
+
};
|
|
41
|
+
/** Per-resolution input for a principal-forwarding resolver. */
|
|
42
|
+
export interface RequestTokenInput<P extends RequestPrincipal = RequestPrincipal> {
|
|
43
|
+
readonly principal: P;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A request-local Bearer resolver that receives the admitted principal.
|
|
47
|
+
* Return the same token for the same principal while it is valid.
|
|
48
|
+
*/
|
|
49
|
+
export interface PrincipalRequestToken<P extends RequestPrincipal = RequestPrincipal> {
|
|
50
|
+
(ctx: ConnectorContext, input: RequestTokenInput<P>): Promise<string>;
|
|
51
|
+
/** Drop only the rejected token for this principal. This hook never authorizes replay. */
|
|
52
|
+
invalidate?: (rejected: string, ctx: ConnectorContext, input: RequestTokenInput<P>) => Promise<void>;
|
|
53
|
+
}
|
package/dist/routes/mcp.js
CHANGED
|
@@ -267,12 +267,12 @@ function scopedToBody(handler, signal) {
|
|
|
267
267
|
/**
|
|
268
268
|
* The request's admission permit, owned by its Scope.
|
|
269
269
|
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
270
|
+
* The pool is shared by every request in the isolate, and a queued request is
|
|
271
|
+
* handed its permit from inside the request whose body just ended. The queue
|
|
272
|
+
* wakes it through a Promise its own request created
|
|
273
|
+
* (src/runtime/settlement.ts), and this fiber awaits `acquire()`'s Promise, so
|
|
274
|
+
* it authorizes and serves this request in its own I/O context, never that
|
|
275
|
+
* one's, which workerd refuses.
|
|
276
276
|
*
|
|
277
277
|
* The wait stays interruptible, and a permit granted just as it was
|
|
278
278
|
* interrupted still arrives, later, to be released on arrival.
|
package/dist/routes/ui-config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { storedAuthObservation } from "../auth/stored-observation.js";
|
|
1
2
|
import { agentDescriptions } from "../agent-description.js";
|
|
2
3
|
import { readAgentAdmission } from "../agent-observation.js";
|
|
3
4
|
// `GET /ui/api/config`: operator contract v2 (#837, decisions/0006), the
|
|
@@ -25,6 +26,8 @@ import { workspaceSubjectOf } from "../workspace-subject.js";
|
|
|
25
26
|
import { authorized, refuse, scopeFor, serveOperator } from "./operator.js";
|
|
26
27
|
import { isMaintainer, isMember } from "../membership.js";
|
|
27
28
|
import { admitsMaintainer, mayManageConnector, privateJson, readsActivity } from "./shared.js";
|
|
29
|
+
const cachedMaintainers = new WeakSet();
|
|
30
|
+
const cachedMode = (context) => context.url.searchParams.get("live") === "cached";
|
|
28
31
|
const attempt = (operation) => Effect.tryPromise({ try: operation, catch: (error) => error });
|
|
29
32
|
const validName = (name) => recordedToolName({ name }) === name;
|
|
30
33
|
const classified = (tool) => tool.classification === "read" ? "read" : "write";
|
|
@@ -65,7 +68,7 @@ function probe(context, registry, connector, scope, work, fallback) {
|
|
|
65
68
|
*/
|
|
66
69
|
function bounded(context, read, fallback) {
|
|
67
70
|
return withDeadlineEffect(() => read(), {
|
|
68
|
-
timeoutMs: context.opts.config.discovery.probeTimeoutMs,
|
|
71
|
+
timeoutMs: cachedMaintainers.has(context) ? 500 : context.opts.config.discovery.probeTimeoutMs,
|
|
69
72
|
signal: context.request.signal,
|
|
70
73
|
timeoutError: new Error("Operator config read timed out."),
|
|
71
74
|
}).pipe(Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch(fallback));
|
|
@@ -141,7 +144,7 @@ const isTimestamp = (value) => /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v
|
|
|
141
144
|
* declared agent's `lastSeenAt` from the rows this maintainer may see. Agent
|
|
142
145
|
* rows are recorded under the access-token directory with the agent's name.
|
|
143
146
|
*/
|
|
144
|
-
function
|
|
147
|
+
export function scanOperatorActivity(context, authz, registry, rows, agents) {
|
|
145
148
|
const activity = context.opts.config.activity;
|
|
146
149
|
if (!activity?.store.list)
|
|
147
150
|
return Effect.succeed("unconfigured");
|
|
@@ -232,6 +235,12 @@ function sharedSource(context, connector, row) {
|
|
|
232
235
|
// Only a connector asking to be authorized has no grant; any other
|
|
233
236
|
// state, a credential prompt or an error included, has one. A grant
|
|
234
237
|
// carries no saved-at time of its own.
|
|
238
|
+
if (cachedMode(context)) {
|
|
239
|
+
const observe = storedAuthObservation(connector);
|
|
240
|
+
return observe
|
|
241
|
+
? bounded(context, () => attempt(() => observe(opts.registry.contextFor(connector.id, context.baseUrl, {}))).pipe(Effect.map((ok) => (ok ? { kind: "shared", source: "oauth", savedAt: null } : MISSING))), () => Effect.succeed(MISSING))
|
|
242
|
+
: Effect.succeed(MISSING);
|
|
243
|
+
}
|
|
235
244
|
return Effect.succeed(row?.status === "auth_required" ? MISSING : { kind: "shared", source: "oauth", savedAt: null });
|
|
236
245
|
}
|
|
237
246
|
if (described?.auth?.mode === "none" && described.source.kind === "remote-mcp") {
|
|
@@ -258,6 +267,13 @@ function connectedPeople(context, connector, people) {
|
|
|
258
267
|
? yield* bounded(context, () => attempt(() => vault.metadata(connector.id, principalKey)).pipe(Effect.map((metadata) => storedCredentialShape(credential, metadata?.fields ?? null).state === "valid")), () => Effect.succeed(false))
|
|
259
268
|
: false;
|
|
260
269
|
}
|
|
270
|
+
else if (cachedMode(context)) {
|
|
271
|
+
const registry = opts.registry.scoped({ connectorIds: [connector.id], principalKey });
|
|
272
|
+
const observe = storedAuthObservation(connector);
|
|
273
|
+
ok = observe
|
|
274
|
+
? yield* bounded(context, () => attempt(() => observe(registry.contextFor(connector.id, baseUrl, {}))), () => Effect.succeed(false))
|
|
275
|
+
: false;
|
|
276
|
+
}
|
|
261
277
|
else {
|
|
262
278
|
const registry = opts.registry.scoped({ connectorIds: [connector.id], principalKey });
|
|
263
279
|
const scope = {};
|
|
@@ -354,11 +370,14 @@ function maintainerRead(context, authz) {
|
|
|
354
370
|
for (const person of [...opts.maintainers, ...[...opts.pools.values()].flatMap(humansOf)])
|
|
355
371
|
people.set(referenceKey(person), person);
|
|
356
372
|
const requestActivity = operatorActivityContext(context, authz);
|
|
373
|
+
const cached = cachedMode(context);
|
|
357
374
|
const [rows, label, slots] = yield* Effect.all([
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
375
|
+
cached
|
|
376
|
+
? Effect.succeed([])
|
|
377
|
+
: Effect.forEach(visible, (c) => observe(context, registry, c, requestActivity), {
|
|
378
|
+
concurrency: resolveDiscoveryConcurrency(opts.config.discovery.concurrency),
|
|
379
|
+
}),
|
|
380
|
+
bounded(context, () => humanLabels(context, authz, [...people.values()]), () => Effect.succeed(() => null)),
|
|
362
381
|
// Every declared agent's slot, exactly as GET /ui/access-tokens lists
|
|
363
382
|
// it. Without them the agents list cannot be whole, so a failed read
|
|
364
383
|
// fails the view rather than dropping agents from it.
|
|
@@ -399,7 +418,7 @@ function maintainerRead(context, authz) {
|
|
|
399
418
|
token: tokenSlots.get(name) ?? null,
|
|
400
419
|
lastSeenAt: null,
|
|
401
420
|
}));
|
|
402
|
-
const activity = yield*
|
|
421
|
+
const activity = yield* scanOperatorActivity(context, authz, registry, rows, agents);
|
|
403
422
|
let admissionsVisible = readsActivity(opts, authz) && activity !== "forbidden";
|
|
404
423
|
// A configured read gate still applies when the history store has no list.
|
|
405
424
|
if (admissionsVisible && activity === "unconfigured" && opts.config.activity?.readGate) {
|
|
@@ -572,8 +591,18 @@ function memberRead(context, authz, pools) {
|
|
|
572
591
|
function configRead(context) {
|
|
573
592
|
return Effect.gen(function* () {
|
|
574
593
|
const authz = yield* authorized(context);
|
|
575
|
-
if (admitsMaintainer(context.opts, authz))
|
|
576
|
-
|
|
594
|
+
if (admitsMaintainer(context.opts, authz)) {
|
|
595
|
+
if (!cachedMode(context))
|
|
596
|
+
return yield* maintainerRead(context, authz);
|
|
597
|
+
cachedMaintainers.add(context);
|
|
598
|
+
// The entire metadata build stays below the browser admission deadline,
|
|
599
|
+
// even for a store that ignores cancellation or an unusually large roster.
|
|
600
|
+
return yield* withDeadlineEffect(() => maintainerRead(context, authz), {
|
|
601
|
+
timeoutMs: 5000,
|
|
602
|
+
signal: context.request.signal,
|
|
603
|
+
timeoutError: new Error("Operator metadata timed out."),
|
|
604
|
+
}).pipe(Effect.catch(() => refuse("operator metadata could not be read", 503)));
|
|
605
|
+
}
|
|
577
606
|
const pools = memberPools(context, authz);
|
|
578
607
|
if (pools.length > 0)
|
|
579
608
|
return yield* memberRead(context, authz, pools);
|
package/dist/routes/ui-data.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describedTools } from "../described.js";
|
|
1
2
|
// The Effect core of the operator payload: `buildUiData` in src/ui.ts is its
|
|
2
3
|
// Promise face, and the `/ui/connectors/<id>` route yields it directly.
|
|
3
4
|
//
|
|
@@ -12,7 +13,7 @@ import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentia
|
|
|
12
13
|
import { failureRecord, failureStatus, logFailure, recordedToolName, statusFailure } from "../operator-record.js";
|
|
13
14
|
import { closeScope } from "../runtime/connector-scope.js";
|
|
14
15
|
import { withDeadlineEffect } from "../runtime/run.js";
|
|
15
|
-
import { uiProblemFor
|
|
16
|
+
import { uiProblemFor } from "../ui.js";
|
|
16
17
|
import { CONNECTA_VERSION } from "../version.js";
|
|
17
18
|
const DETAIL_TIMEOUT_MS = 30_000;
|
|
18
19
|
const DETAIL_TIMED_OUT = "Connection details timed out. Retry this connection.";
|
|
@@ -63,11 +64,13 @@ export function uiData(registry, baseUrl, options) {
|
|
|
63
64
|
return yield* attempt(async () => (await registry.getTools(c.id, baseUrl, requestScope, {
|
|
64
65
|
signal,
|
|
65
66
|
...(options.defer ? { defer: options.defer } : {}),
|
|
66
|
-
}))
|
|
67
|
+
}))
|
|
68
|
+
.flatMap((t) => describedTools([t]))
|
|
69
|
+
.map((t) => ({
|
|
70
|
+
...t,
|
|
67
71
|
name: recordedToolName(t),
|
|
68
72
|
address: `${c.id}.${recordedToolName(t)}`,
|
|
69
|
-
|
|
70
|
-
safety: uiToolSafety(t),
|
|
73
|
+
classification: t.classification === "read" ? "read" : "write",
|
|
71
74
|
}))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false })),
|
|
72
75
|
// The registry owns the failed catalog observation; the page only
|
|
73
76
|
// needs to know there was one.
|
|
@@ -169,7 +172,21 @@ export function uiData(registry, baseUrl, options) {
|
|
|
169
172
|
status: status.state,
|
|
170
173
|
...(problem ? { problem } : {}),
|
|
171
174
|
toolCount: tools.length,
|
|
172
|
-
tools
|
|
175
|
+
tools: tools.map((tool) => ({
|
|
176
|
+
name: tool.name,
|
|
177
|
+
address: tool.address,
|
|
178
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
179
|
+
safety: tool.classification === "read" ? "runs_in_programs" : "needs_approval",
|
|
180
|
+
})),
|
|
181
|
+
overlay: {
|
|
182
|
+
id: c.id,
|
|
183
|
+
status: status.state,
|
|
184
|
+
tools,
|
|
185
|
+
...(problem ? { problem } : {}),
|
|
186
|
+
...(status.registrationPath ? { auth: { registrationPath: status.registrationPath } } : {}),
|
|
187
|
+
catalogAgeMs: registry.catalogAgeMs(c.id),
|
|
188
|
+
lastCall: null,
|
|
189
|
+
},
|
|
173
190
|
// Counts only, and only when a refresh in this runtime produced them.
|
|
174
191
|
// `Registry.withCatalogObservations` rebuilt the report through
|
|
175
192
|
...(status.catalogAccess ? { catalogAccess: status.catalogAccess } : {}),
|
|
@@ -193,6 +210,14 @@ export function uiData(registry, baseUrl, options) {
|
|
|
193
210
|
oauth: Boolean(c.startAuth && c.disconnectAuth),
|
|
194
211
|
toolCount: 0,
|
|
195
212
|
tools: [],
|
|
213
|
+
overlay: {
|
|
214
|
+
id: c.id,
|
|
215
|
+
status: "error",
|
|
216
|
+
problem: "connector_unavailable",
|
|
217
|
+
tools: [],
|
|
218
|
+
catalogAgeMs: registry.catalogAgeMs(c.id),
|
|
219
|
+
lastCall: null,
|
|
220
|
+
},
|
|
196
221
|
};
|
|
197
222
|
});
|
|
198
223
|
const row = (c) => Effect.suspend(() => {
|
package/dist/routes/ui.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withDeadlineEffect } from "../runtime/run.js";
|
|
1
2
|
import { operatorAsset } from "../operator-ui/assets.js";
|
|
2
3
|
import { operatorActivityContext } from "./activity-context.js";
|
|
3
4
|
import { isSafeHttpsUrl } from "../branding.js";
|
|
@@ -10,7 +11,7 @@ import { CONNECTA_FAVICON_ICO } from "../favicon.js";
|
|
|
10
11
|
import { CONNECTA_FAVICON_SVG, operatorPageForPath, renderUiHtml } from "../ui.js";
|
|
11
12
|
import { authorized, refuse, scopeFor, serveOperator, visibleRegistry, } from "./operator.js";
|
|
12
13
|
import { managesAgentTokens, mayManageConnector, privateJson, readsActivity } from "./shared.js";
|
|
13
|
-
import { routeUiConfig } from "./ui-config.js";
|
|
14
|
+
import { routeUiConfig, scanOperatorActivity } from "./ui-config.js";
|
|
14
15
|
import { uiData } from "./ui-data.js";
|
|
15
16
|
/**
|
|
16
17
|
* Headers that make an operator-supplied favicon body inert on this origin.
|
|
@@ -160,7 +161,14 @@ function connectorDetail(context, id) {
|
|
|
160
161
|
timeoutMs: opts.config.discovery.probeTimeoutMs,
|
|
161
162
|
signal: request.signal,
|
|
162
163
|
});
|
|
163
|
-
|
|
164
|
+
const detail = data.connectors[0];
|
|
165
|
+
if (detail.overlay)
|
|
166
|
+
yield* withDeadlineEffect(() => scanOperatorActivity(context, authz, registry, [detail.overlay], []), {
|
|
167
|
+
timeoutMs: 500,
|
|
168
|
+
signal: request.signal,
|
|
169
|
+
timeoutError: new Error("Activity observation timed out."),
|
|
170
|
+
}).pipe(Effect.catch(() => Effect.succeed("unavailable")));
|
|
171
|
+
return privateJson({ ...detail, permissions: permissions(connector) });
|
|
164
172
|
});
|
|
165
173
|
}
|
|
166
174
|
/** The summary: every visible connector as "loading", with no probe at all. */
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// One outcome that a fiber in one request waits on while another request may
|
|
2
|
+
// deliver it: a queued admission permit, a shared token refresh.
|
|
3
|
+
//
|
|
4
|
+
// Effect resumes a waiting fiber synchronously, inside whatever completes the
|
|
5
|
+
// Deferred it awaits, and runs it in the async context it suspended in: the
|
|
6
|
+
// fiber captured a `node:async_hooks` AsyncResource when it suspended and
|
|
7
|
+
// resumes through `runInAsyncScope`. On Workers that context belongs to the
|
|
8
|
+
// waiter's request. workerd refuses to enter it from another request ("Cannot
|
|
9
|
+
// call this AsyncLocalStorage bound function outside of the request in which
|
|
10
|
+
// it was created.") when the Worker's compatibility date is 2025-06-16 or
|
|
11
|
+
// later, in releases since September 2026. The refusal throws out of the call
|
|
12
|
+
// that completed the Deferred, failing the request that released a slot, and
|
|
13
|
+
// the waiter never resumes (#1106). Awaiting a Promise around that wait is no
|
|
14
|
+
// help: the fiber waiting on the Deferred inside the Promise is resumed in the
|
|
15
|
+
// completing request just the same.
|
|
16
|
+
//
|
|
17
|
+
// So nothing here resumes a fiber. `settle` records the outcome and resolves,
|
|
18
|
+
// for each waiter, a Promise that the waiter's own request created. workerd
|
|
19
|
+
// runs a Promise's reactions in the request that created it, whichever
|
|
20
|
+
// request resolves it; Node runs them one microtask later in the waiter's
|
|
21
|
+
// async context. The waiter resumes there. The record itself is synchronous,
|
|
22
|
+
// so a queue's arbitration reads the outcome at once, and a waiter that leaves
|
|
23
|
+
// before it wakes can still give back what it was handed.
|
|
24
|
+
import { Effect } from "effect";
|
|
25
|
+
export class Settlement {
|
|
26
|
+
exit;
|
|
27
|
+
wakers = new Set();
|
|
28
|
+
/** The recorded outcome; undefined until `settle` runs. */
|
|
29
|
+
get outcome() {
|
|
30
|
+
return this.exit;
|
|
31
|
+
}
|
|
32
|
+
/** Record the first outcome and wake each waiter in its own request. */
|
|
33
|
+
settle(exit) {
|
|
34
|
+
if (this.exit)
|
|
35
|
+
return false;
|
|
36
|
+
this.exit = exit;
|
|
37
|
+
const wakers = [...this.wakers];
|
|
38
|
+
this.wakers.clear();
|
|
39
|
+
for (const wake of wakers)
|
|
40
|
+
wake();
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
/** The outcome, waited for by a fiber that resumes in its own request. */
|
|
44
|
+
wait() {
|
|
45
|
+
return Effect.suspend(() => this.exit ??
|
|
46
|
+
Effect.callback((resume) => {
|
|
47
|
+
let wake;
|
|
48
|
+
// Created here, by the waiting fiber, so it belongs to the waiting
|
|
49
|
+
// request whichever request resolves it.
|
|
50
|
+
void new Promise((resolve) => {
|
|
51
|
+
wake = resolve;
|
|
52
|
+
}).then(() => resume(this.exit));
|
|
53
|
+
this.wakers.add(wake);
|
|
54
|
+
return Effect.sync(() => {
|
|
55
|
+
this.wakers.delete(wake);
|
|
56
|
+
});
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CONNECTA_VERSION = "0.
|
|
1
|
+
export declare const CONNECTA_VERSION = "0.37.0";
|
package/dist/version.js
CHANGED
package/dist/worker.js
CHANGED
|
@@ -45,10 +45,13 @@ function routeGuestImports(source) {
|
|
|
45
45
|
}
|
|
46
46
|
/** Loader-only upstream sandbox whose lease disposes handles at run end. */
|
|
47
47
|
export function workerExecutor(options) {
|
|
48
|
+
// One pool per executor, so per isolate. A queued run may wait out one
|
|
49
|
+
// default guest deadline: programs spend it awaiting host calls, and a
|
|
50
|
+
// five-second wait refused nearly every run queued behind one (#1106).
|
|
48
51
|
const admission = new AdmissionController(options.admission ?? {
|
|
49
52
|
concurrency: 2,
|
|
50
53
|
maxQueueSize: 8,
|
|
51
|
-
queueTimeoutMs:
|
|
54
|
+
queueTimeoutMs: 60_000,
|
|
52
55
|
retryAfterMs: 1_000,
|
|
53
56
|
});
|
|
54
57
|
const active = new Set();
|
|
@@ -1753,6 +1753,20 @@ Use the existing stateful fixture vendors and published-edge deployment adapters
|
|
|
1753
1753
|
Native transport parity, OAuth and write-provenance suites retain their
|
|
1754
1754
|
workerd coverage. The shared throwaway RSA key in `test/fixtures/rsa.ts` is
|
|
1755
1755
|
used by write-provenance and unknown-write-advice fixtures.
|
|
1756
|
+
- `test/executor-requests.test.ts` runs overlapping `execute_code` programs
|
|
1757
|
+
as separate workerd requests through the real Worker Loader executor, with
|
|
1758
|
+
real TCP host calls that read their own request's signal. Queued programs
|
|
1759
|
+
and the programs whose release admitted them return their own answers; queue
|
|
1760
|
+
refusals keep the retryable shape. Its fixture applies workerd's request
|
|
1761
|
+
binding of async-context frames, which the pinned runtime predates, through a
|
|
1762
|
+
signal each request owns, so it refuses another request's entry with or
|
|
1763
|
+
without an async-context token, as workerd does; pointing
|
|
1764
|
+
`MINIFLARE_WORKERD_PATH` at a newer workerd runs the native check.
|
|
1765
|
+
`test/executor-native.test.ts` shows that a slot granted just before
|
|
1766
|
+
`close()` never starts a program on the native Worker Loader.
|
|
1767
|
+
`test/request-context.node.test.ts` pins the same wake order and async context
|
|
1768
|
+
on Node for executor and call admission and QuickJS host calls, and gives back
|
|
1769
|
+
grants that shutdown overtook.
|
|
1756
1770
|
- The request, catalog, auxiliary-call and renewal lifetime
|
|
1757
1771
|
suites, plus `test/auth-admission-binding.test.ts`, exercise cancellation,
|
|
1758
1772
|
held native work and garbage collection. These test actual request ownership;
|
|
@@ -1786,7 +1800,16 @@ in-memory token caching and exact-token invalidation, with an absent subject
|
|
|
1786
1800
|
for service accounts acting as themselves. Workspace providers keep supplying
|
|
1787
1801
|
subjects and retain their delegated error guidance. `src/request-token.ts`
|
|
1788
1802
|
owns the callable resolver contract and optional HTTP-401 invalidation hook;
|
|
1789
|
-
`remoteMcp()` invokes it without replaying the failed operation.
|
|
1803
|
+
`remoteMcp()` invokes it without replaying the failed operation. With
|
|
1804
|
+
`auth.principal`, `src/request-principal.ts` reads the admitted caller only
|
|
1805
|
+
through `callerOf` (see `src/connector-caller.ts`), refuses callers the policy
|
|
1806
|
+
does not accept before resolving, and gives `remoteMcp()` a principal key that
|
|
1807
|
+
joins the credential digest, so no client crosses principals. A custom `fetch`
|
|
1808
|
+
replaces global fetch and the owned write sender in that connector's transport,
|
|
1809
|
+
behind the same redirect wrapper and a same-origin guard.
|
|
1810
|
+
`test/remote-mcp-principal-forwarding.test.ts` checks admission, forging,
|
|
1811
|
+
client replacement, transport confinement and secrecy on Node, and a real
|
|
1812
|
+
service binding on workerd.
|
|
1790
1813
|
`test/google-service-account-token.test.ts` exercises signing/cache behavior on
|
|
1791
1814
|
Node and workerd; `test/google-service-account-remote.node.test.ts` checks
|
|
1792
1815
|
real-socket token/MCP requests, invalidation and one physical attempt per write.
|
|
@@ -2026,11 +2049,13 @@ existing execute pipeline, using its request identity and call capabilities.
|
|
|
2026
2049
|
The executor seam creates no second request or call owner.
|
|
2027
2050
|
|
|
2028
2051
|
Both built-ins acquire their permits through native admission and return
|
|
2029
|
-
execute-once leases with synchronous, idempotent release.
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
`
|
|
2033
|
-
|
|
2052
|
+
execute-once leases with synchronous, idempotent release. Admission wakes a
|
|
2053
|
+
queued waiter through a Settlement, so it resumes in its own request
|
|
2054
|
+
([across requests](#across-requests)). A queued standalone execution also
|
|
2055
|
+
crosses `awaitExecutorLease`'s Promise handoff before host setup, and
|
|
2056
|
+
`execute_code` retains its public `acquire()` handoff. A cancelled queued
|
|
2057
|
+
handoff withdraws its waiter, and a late custom acquisition releases its
|
|
2058
|
+
abandoned lease.
|
|
2034
2059
|
|
|
2035
2060
|
The Worker SDK prepares source and dispatchers against an inert loader. The
|
|
2036
2061
|
native host then acquires the actual Loader handle and RPC entrypoint with
|
|
@@ -2046,7 +2071,9 @@ Both built-ins normalize provider leaf returns with `Promise.resolve`, retaining
|
|
|
2046
2071
|
direct JavaScript callers' synchronous value tolerance and original host
|
|
2047
2072
|
rejections. The published provider-function signature remains Promise-shaped.
|
|
2048
2073
|
|
|
2049
|
-
QuickJS owns host-call fibers in each active run's Scope
|
|
2074
|
+
QuickJS owns host-call fibers in each active run's Scope, started in the
|
|
2075
|
+
async context the run captured: a warm child's IPC events arrive in the
|
|
2076
|
+
context of the request that spawned it. Completion,
|
|
2050
2077
|
interruption, cancellation, recycling and shutdown revoke provider lookup and
|
|
2051
2078
|
interrupt those fibers; late SDK results and stale IPC cannot reply to another
|
|
2052
2079
|
run. Releasing a lease during readiness or crash backoff also cancels the old
|
|
@@ -2231,24 +2258,40 @@ request's abort handler also releases them idempotently if workerd discards the
|
|
|
2231
2258
|
pending continuation on disconnect. Membership cache
|
|
2232
2259
|
hits precede these bounds; see [organization admission](./auth.md#one-clerk-instance-one-organization-per-deployment).
|
|
2233
2260
|
|
|
2234
|
-
Asynchronous work shared across requests meets only through a
|
|
2235
|
-
request
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
inside the
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2261
|
+
Asynchronous work shared across requests meets only through a `Settlement`
|
|
2262
|
+
(`src/runtime/settlement.ts`) that one request settles, never through a fiber
|
|
2263
|
+
that outlives its request or a Deferred another request awaits. Two workerd
|
|
2264
|
+
rules follow, and both have bitten.
|
|
2265
|
+
|
|
2266
|
+
**No request resumes another request's fiber.** Deferred resumes a waiting
|
|
2267
|
+
fiber synchronously, inside the call that completed it, and Effect re-enters
|
|
2268
|
+
the async context the fiber suspended in through the `AsyncResource` it
|
|
2269
|
+
captured. On Workers that context belongs to the waiter's request. Since
|
|
2270
|
+
workerd's September 2026 releases, a Worker with compatibility date 2025-06-16
|
|
2271
|
+
or later (or `bind_asynclocalstorage_snapshot_to_request`) refuses that entry
|
|
2272
|
+
from any other request: "Cannot call this AsyncLocalStorage bound function
|
|
2273
|
+
outside of the request in which it was created." The throw escapes the
|
|
2274
|
+
completing call, so the request that released an admission slot failed with
|
|
2275
|
+
`executor_failed`, and the stranded waiter later timed out (#1106). Older
|
|
2276
|
+
runtimes ran the waiter in the completing request instead, where its own I/O
|
|
2277
|
+
failed with "Cannot perform I/O on behalf of a different request". Awaiting a
|
|
2278
|
+
Promise around the wait fixes neither: the fiber waiting inside that Promise is
|
|
2279
|
+
the one resumed. A Settlement records its outcome synchronously, for queue
|
|
2280
|
+
arbitration and late-grant cleanup, and wakes each waiter by resolving a
|
|
2281
|
+
Promise the waiter's own request created. workerd runs that Promise's reactions
|
|
2282
|
+
in the request that created it, and Node runs them a microtask later in the
|
|
2283
|
+
waiter's async context. That wake is a turn late, so whatever a waiter acts on
|
|
2284
|
+
it rechecks there: an admission queue gives back a grant its controller closed
|
|
2285
|
+
in between, while a grant that beat the waiter's own abort stays the waiter's to
|
|
2286
|
+
release, and a refresh waiter rechecks its own signal before using a flight's
|
|
2287
|
+
answer. Executor admission, `/mcp` request
|
|
2288
|
+
admission, connector call admission and OAuth refresh flights all queue through
|
|
2289
|
+
Settlements; Effect's `Semaphore` is not used across requests for the same
|
|
2290
|
+
reason, and because a same-tick newcomer can take a released permit ahead of a
|
|
2291
|
+
waiter that queued earlier. Events from shared native objects follow the same
|
|
2292
|
+
rule: a warm QuickJS child's IPC messages arrive in the async context of the
|
|
2293
|
+
request that spawned it, so host calls dispatch inside the running program's
|
|
2294
|
+
captured context.
|
|
2252
2295
|
|
|
2253
2296
|
**Never read another request's signal.** On workerd, reading `aborted` or
|
|
2254
2297
|
`reason`, or calling `abort()`, on a signal that belongs to another request
|