@zackbart/connecta 0.41.0 → 0.42.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 +61 -0
- package/bin/connecta.mjs +26 -17
- package/bin/meta-tool-names.d.mts +3 -1
- package/bin/meta-tool-names.mjs +4 -1
- package/bin/version.mjs +1 -1
- package/dist/activity.d.ts +2 -1
- package/dist/activity.js +5 -5
- package/dist/advertised-schema.js +15 -0
- package/dist/agent-routes.js +31 -5
- package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
- package/dist/auth/downstream-oauth.js +3 -0
- package/dist/auth/static-oauth.js +26 -18
- package/dist/auth-elicitation.js +118 -61
- package/dist/authorization-handoff.js +36 -3
- package/dist/catalog-cache.js +292 -24
- package/dist/catalog-freshness.js +12 -0
- package/dist/catalog-service.js +373 -70
- package/dist/catalog.js +147 -58
- package/dist/config-defaults.d.ts +2 -1
- package/dist/config-defaults.js +4 -1
- package/dist/config.d.ts +43 -17
- package/dist/config.js +26 -10
- package/dist/connectors/remote-mcp/catalog.js +52 -28
- package/dist/connectors/remote-mcp/connection.js +62 -5
- package/dist/connectors/remote-mcp.js +10 -1
- package/dist/describe-config.d.ts +3 -2
- package/dist/discovery-health.js +26 -11
- package/dist/errors.d.ts +52 -3
- package/dist/errors.js +86 -3
- package/dist/execute.js +476 -200
- package/dist/executor-admission.js +2 -0
- package/dist/executor-contract.js +6 -0
- package/dist/executor-result.js +2 -2
- package/dist/executors/quickjs-child.js +13 -4
- package/dist/executors/quickjs-runtime.d.ts +2 -0
- package/dist/executors/quickjs-runtime.js +36 -19
- package/dist/executors/quickjs.js +23 -6
- package/dist/guest-functions.js +5 -4
- package/dist/guest-runtime.js +37 -1
- package/dist/guest-types.d.ts +18 -0
- package/dist/http/client.js +7 -2
- package/dist/http/errors.js +9 -4
- package/dist/http/fields.js +17 -5
- package/dist/http/hatch.js +9 -3
- package/dist/http/paging.js +1 -1
- package/dist/http/provider.js +2 -6
- package/dist/http/vendor.js +6 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -0
- package/dist/invocation.js +98 -34
- package/dist/meta-output.js +24 -18
- package/dist/meta-tool-names.d.ts +1 -1
- package/dist/meta-tool-names.js +1 -1
- package/dist/meta-tools.js +259 -60
- package/dist/native-recovery.js +4 -1
- package/dist/native-tools.js +166 -51
- package/dist/node.js +3 -1
- package/dist/oauth-handoff.js +53 -43
- package/dist/operation-activity.js +2 -1
- package/dist/operation-vocabulary.js +6 -0
- package/dist/operator-record.d.ts +1 -1
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/program-result.js +2 -0
- package/dist/program-source.js +1 -1
- package/dist/program-writes.js +112 -19
- package/dist/provider.d.ts +0 -13
- package/dist/provider.js +0 -3
- package/dist/providers/_shared/google/kit.js +2 -2
- package/dist/providers/breeze/api.js +2 -2
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/ccb/api.js +2 -2
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +2 -2
- package/dist/providers/cloudflare/index.js +2 -0
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/forms/index.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/github/api.js +98 -6
- package/dist/providers/github/index.js +7 -2
- package/dist/providers/github/lib/auth.d.ts +3 -1
- package/dist/providers/github/lib/auth.js +43 -12
- package/dist/providers/github/lib/discovery.js +11 -1
- package/dist/providers/github/lib/operations.js +8 -0
- package/dist/providers/github/safety.js +39 -0
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +12 -5
- package/dist/providers/gmail/index.js +1 -0
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/index.js +2 -0
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/index.js +11 -5
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/index.js +2 -5
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/index.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/slides/index.js +2 -2
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/index.js +1 -0
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +2 -2
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/registry.d.ts +36 -4
- package/dist/registry.js +127 -27
- package/dist/result-preview.js +210 -0
- package/dist/result-seal.js +168 -0
- package/dist/route-names.js +13 -4
- package/dist/routes/connect.js +21 -13
- package/dist/routes/mcp.js +55 -26
- package/dist/routes/shared.d.ts +6 -0
- package/dist/runtime/call.js +15 -10
- package/dist/runtime/request-scope.js +36 -1
- package/dist/runtime/run.js +4 -1
- package/dist/sent-secrets.d.ts +20 -0
- package/dist/sent-secrets.js +49 -1
- package/dist/skills.js +100 -33
- package/dist/storage/keys.js +9 -6
- package/dist/storage/sql-native.js +1 -1
- package/dist/tool-safety.d.ts +33 -0
- package/dist/tool-safety.js +51 -15
- package/dist/types.d.ts +31 -0
- package/dist/usage-guide.js +14 -13
- package/dist/validate.js +29 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +28 -8
- package/documentation/architecture.md +104 -42
- package/documentation/auth.md +46 -19
- package/documentation/code-mode.md +378 -116
- package/documentation/deploying.md +10 -9
- package/documentation/hosted-mcp-migration.md +9 -3
- package/documentation/integrating.md +27 -5
- package/documentation/meta-tools.md +655 -153
- package/documentation/operating.md +5 -3
- package/documentation/providers.md +18 -6
- package/examples/worker/README.md +3 -3
- package/guides/how-it-works.md +2 -1
- package/guides/safety-and-trust.md +1 -3
- package/package.json +1 -1
- package/templates/node/AGENTS.md +3 -3
- package/templates/node/README.md +1 -1
- package/templates/node/package.json +1 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// A program's retained results are unreadable to any other request by default. Chunk 0,
|
|
2
|
+
// which holds the header a reader needs, stays in this memory-only ledger while the run
|
|
3
|
+
// lasts, so every way a run can end leaves nothing a reader can open unless sealing wrote
|
|
4
|
+
// that chunk. Sealing waits until the run can no longer add to its secret set, and only
|
|
5
|
+
// then writes the headers of entries that pass that set. Stored bytes never change, so an
|
|
6
|
+
// offset issued for an entry stays valid. A reclaimed transfer leaves this ledger first and
|
|
7
|
+
// gets no header. Sealing costs no extra writes, and reads stored bytes back only for
|
|
8
|
+
// entries the set grew past.
|
|
9
|
+
import { Duration, Effect } from "effect";
|
|
10
|
+
import { authStorage } from "./auth/native.js";
|
|
11
|
+
import { PendingWork } from "./runtime/request-scope.js";
|
|
12
|
+
import { withDeadlineEffect } from "./runtime/run.js";
|
|
13
|
+
import { resultKeys } from "./storage/keys.js";
|
|
14
|
+
// A BOM is payload here, as it is on every page of the same bytes.
|
|
15
|
+
const dec = new TextDecoder("utf-8", { ignoreBOM: true, fatal: false });
|
|
16
|
+
export const RESULT_ENVELOPE_V4 = "connecta-result-v4:";
|
|
17
|
+
/** Stored for an entry sealing did not publish, so its id can say why. Its binding and no bytes. */
|
|
18
|
+
const RESULT_REFUSED_V4 = "connecta-result-refused-v4:";
|
|
19
|
+
const SEAL_CONCURRENCY = 8;
|
|
20
|
+
/** Kept back from sealing's time for the markers and cleanup of unpublished entries. */
|
|
21
|
+
const REFUSAL_RESERVE_MS = 1000;
|
|
22
|
+
const envelope = (entry) => `${RESULT_ENVELOPE_V4}${entry.binding}:${entry.total}:${entry.chunkBytes}:${entry.cap}:${entry.first}`;
|
|
23
|
+
/** One run's unsealed results. It lives and dies with the run's request (INV-7). */
|
|
24
|
+
export class HeldResults {
|
|
25
|
+
secrets;
|
|
26
|
+
credentialReads;
|
|
27
|
+
entries = new Map();
|
|
28
|
+
hostCalls = new PendingWork();
|
|
29
|
+
closed = false;
|
|
30
|
+
constructor(secrets,
|
|
31
|
+
/** The request's credential reads, which outlast the host calls that started them. */
|
|
32
|
+
credentialReads) {
|
|
33
|
+
this.secrets = secrets;
|
|
34
|
+
this.credentialReads = credentialReads;
|
|
35
|
+
}
|
|
36
|
+
/** False once sealing began: a late entry would never become readable, and a late host call never starts. */
|
|
37
|
+
accepts() {
|
|
38
|
+
return !this.closed;
|
|
39
|
+
}
|
|
40
|
+
add(entry) {
|
|
41
|
+
if (this.closed)
|
|
42
|
+
return false;
|
|
43
|
+
this.entries.set(entry.id, entry);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
/** Forget an entry whose stash is being given back, so sealing never writes its header.
|
|
47
|
+
* False once sealing began, when the entry is no longer this ledger's to give up. */
|
|
48
|
+
discard(id) {
|
|
49
|
+
return !this.closed && this.entries.delete(id);
|
|
50
|
+
}
|
|
51
|
+
/** Chunk 0 as storage will hold it, for paging inside the run that stashed it. */
|
|
52
|
+
stored(id) {
|
|
53
|
+
const entry = this.entries.get(id);
|
|
54
|
+
return entry && envelope(entry);
|
|
55
|
+
}
|
|
56
|
+
/** A guest host call; sealing waits for it to settle. */
|
|
57
|
+
track(work) {
|
|
58
|
+
const done = this.hostCalls.begin();
|
|
59
|
+
void work.then(done, done);
|
|
60
|
+
}
|
|
61
|
+
/** Stop accepting work and hand over what the run retained. */
|
|
62
|
+
close() {
|
|
63
|
+
this.closed = true;
|
|
64
|
+
const entries = [...this.entries.values()];
|
|
65
|
+
this.entries.clear();
|
|
66
|
+
return entries;
|
|
67
|
+
}
|
|
68
|
+
/** Succeeds once nothing the run started can add to the secret set. */
|
|
69
|
+
settled = Effect.suspend(() => this.hostCalls.idle).pipe(Effect.andThen(Effect.suspend(() => this.credentialReads.idle)), Effect.repeat({ until: () => !this.hostCalls.pending && !this.credentialReads.pending }), Effect.asVoid);
|
|
70
|
+
}
|
|
71
|
+
function bytesOf(encoded, total) {
|
|
72
|
+
const bytes = new Uint8Array(total);
|
|
73
|
+
let at = 0;
|
|
74
|
+
for (const part of encoded) {
|
|
75
|
+
const binary = atob(part);
|
|
76
|
+
for (let index = 0; index < binary.length; index++)
|
|
77
|
+
bytes[at++] = binary.charCodeAt(index);
|
|
78
|
+
}
|
|
79
|
+
return bytes;
|
|
80
|
+
}
|
|
81
|
+
/** The checks a result takes on its way into the stash, repeated over the whole stored value.
|
|
82
|
+
* Structured redaction joins text blocks, so a form split across two of them is found. */
|
|
83
|
+
function passesSet(secrets, text, format) {
|
|
84
|
+
if (format === "json") {
|
|
85
|
+
try {
|
|
86
|
+
if (secrets.changes(JSON.parse(text)))
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// A value JSON renders as text, such as `undefined`, takes the text check alone.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return secrets.text(text) === text;
|
|
94
|
+
}
|
|
95
|
+
const SEALED = { refused: 0, refusedWrite: false };
|
|
96
|
+
/**
|
|
97
|
+
* Publish what can be published. Never fails. Nothing is published until the run's work
|
|
98
|
+
* has settled, and anything still pending when the budget ends leaves every entry
|
|
99
|
+
* unpublished, which is the refusal. Markers only improve what a later page is told, so
|
|
100
|
+
* a failed marker, a timeout or a cancellation changes nothing a reader can get.
|
|
101
|
+
*/
|
|
102
|
+
export function sealHeldResults(held, storage,
|
|
103
|
+
/** The absolute time the run's response must be on its way. */
|
|
104
|
+
answerBy, signal) {
|
|
105
|
+
return Effect.suspend(() => {
|
|
106
|
+
const secrets = held.secrets;
|
|
107
|
+
const entries = held.close();
|
|
108
|
+
if (entries.length === 0)
|
|
109
|
+
return Effect.succeed(SEALED);
|
|
110
|
+
const published = new Set();
|
|
111
|
+
const key = (entry, index) => resultKeys.chunk(entry.id, index);
|
|
112
|
+
const passes = (entry) => Effect.gen(function* () {
|
|
113
|
+
const parts = [entry.first];
|
|
114
|
+
for (let index = 1; index < entry.chunks; index++) {
|
|
115
|
+
const chunk = yield* authStorage(storage, "get", [key(entry, index)]);
|
|
116
|
+
// Expired or evicted under a held header: nothing left to check, so nothing to offer.
|
|
117
|
+
if (chunk === null)
|
|
118
|
+
return false;
|
|
119
|
+
parts.push(chunk);
|
|
120
|
+
}
|
|
121
|
+
const text = dec.decode(bytesOf(parts, entry.total));
|
|
122
|
+
return passesSet(secrets, text, entry.format);
|
|
123
|
+
});
|
|
124
|
+
const seal = Effect.gen(function* () {
|
|
125
|
+
yield* held.settled;
|
|
126
|
+
const stable = secrets.revision;
|
|
127
|
+
yield* Effect.forEach(entries, (entry) => Effect.gen(function* () {
|
|
128
|
+
if (entry.passed !== stable && (entry.binary || !(yield* passes(entry))))
|
|
129
|
+
return;
|
|
130
|
+
// Nothing the run started is left, so this holds. If it ever fails, stop.
|
|
131
|
+
if (secrets.revision !== stable)
|
|
132
|
+
return;
|
|
133
|
+
yield* authStorage(storage, "set", [key(entry, 0), envelope(entry), { expiresAtMs: entry.expiresAtMs }]);
|
|
134
|
+
published.add(entry);
|
|
135
|
+
}).pipe(Effect.ignore), { concurrency: SEAL_CONCURRENCY, discard: true });
|
|
136
|
+
});
|
|
137
|
+
// A header write that commits after its timeout can replace a marker, so a marker is no
|
|
138
|
+
// publication fence. That write only ever carries bytes this seal already checked.
|
|
139
|
+
const refuse = (entry) => Effect.gen(function* () {
|
|
140
|
+
yield* authStorage(storage, "set", [
|
|
141
|
+
key(entry, 0),
|
|
142
|
+
`${RESULT_REFUSED_V4}${entry.binding}:`,
|
|
143
|
+
{ expiresAtMs: entry.expiresAtMs },
|
|
144
|
+
]);
|
|
145
|
+
for (let index = 1; index < entry.chunks; index++) {
|
|
146
|
+
yield* authStorage(storage, "delete", [key(entry, index)]);
|
|
147
|
+
}
|
|
148
|
+
}).pipe(Effect.ignore);
|
|
149
|
+
const remainingMs = Math.max(1, answerBy - Date.now());
|
|
150
|
+
const reserveMs = Math.min(REFUSAL_RESERVE_MS, Math.floor(remainingMs / 4));
|
|
151
|
+
return withDeadlineEffect(() => seal, {
|
|
152
|
+
timeoutMs: Math.max(1, remainingMs - reserveMs),
|
|
153
|
+
...(signal ? { signal } : {}),
|
|
154
|
+
timeoutError: new Error("Sealing ran out of time."),
|
|
155
|
+
}).pipe(Effect.ignore, Effect.flatMap(() => {
|
|
156
|
+
const refused = entries.filter((entry) => !published.has(entry));
|
|
157
|
+
const outcome = refused.length
|
|
158
|
+
? { refused: refused.length, refusedWrite: refused.some((entry) => entry.write) }
|
|
159
|
+
: SEALED;
|
|
160
|
+
if (refused.length === 0 || signal?.aborted)
|
|
161
|
+
return Effect.succeed(outcome);
|
|
162
|
+
return Effect.forEach(refused, refuse, { concurrency: SEAL_CONCURRENCY, discard: true }).pipe(Effect.timeoutOrElse({
|
|
163
|
+
duration: Duration.millis(Math.max(1, answerBy - Date.now())),
|
|
164
|
+
orElse: () => Effect.void,
|
|
165
|
+
}), Effect.as(outcome));
|
|
166
|
+
}));
|
|
167
|
+
});
|
|
168
|
+
}
|
package/dist/route-names.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `meta`: the six meta-tools. `
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
2
|
+
* `meta`: the six meta-tools. `read-route`: those plus `execute_read_code`, which
|
|
3
|
+
* then takes read follow-ups, so they cost no write approval. `code`: in-program
|
|
4
|
+
* wording, selected only by the eval code arm. `native` and `native-code`: native
|
|
5
|
+
* pools without and with `execute_code`.
|
|
5
6
|
*/
|
|
6
7
|
const NAMES = {
|
|
7
8
|
meta: Object.freeze({
|
|
@@ -12,6 +13,14 @@ const NAMES = {
|
|
|
12
13
|
authorize: "authorize_connector",
|
|
13
14
|
skills: "skills",
|
|
14
15
|
}),
|
|
16
|
+
"read-route": Object.freeze({
|
|
17
|
+
search: "search_tools",
|
|
18
|
+
read: "call_tool",
|
|
19
|
+
write: "call_destructive_tool",
|
|
20
|
+
program: "execute_read_code",
|
|
21
|
+
authorize: "authorize_connector",
|
|
22
|
+
skills: "skills",
|
|
23
|
+
}),
|
|
15
24
|
code: Object.freeze({
|
|
16
25
|
search: "connecta.search inside execute_code",
|
|
17
26
|
read: "connecta.call inside execute_code",
|
|
@@ -37,7 +46,7 @@ const NAMES = {
|
|
|
37
46
|
skills: "skills",
|
|
38
47
|
}),
|
|
39
48
|
};
|
|
40
|
-
/** The
|
|
49
|
+
/** The meta surface's names: the default wherever no other surface applies. */
|
|
41
50
|
export const META_ROUTES = NAMES.meta;
|
|
42
51
|
export function routeNames(surface) {
|
|
43
52
|
return NAMES[surface];
|
package/dist/routes/connect.js
CHANGED
|
@@ -5,7 +5,7 @@ import { escapeHtml, renderPage } from "../branding.js";
|
|
|
5
5
|
import { htmlSecurityHeaders } from "../html-security.js";
|
|
6
6
|
import { closeConnectorScope } from "../connector-scope.js";
|
|
7
7
|
import { drainOAuthStartResets } from "../auth/oauth-start-reset.js";
|
|
8
|
-
import { finishOAuthConnectStart,
|
|
8
|
+
import { finishOAuthConnectStart, claimOAuthConnectStart, oauthConnectUnavailable, verifyOAuthHandoff, } from "../oauth-handoff.js";
|
|
9
9
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
10
10
|
import { runEdge, withDeadlineEffect } from "../runtime/run.js";
|
|
11
11
|
import { admitsMaintainer, authorizeUiIdentity, mayManageConnector, privateJson, withSessionCookies, } from "./shared.js";
|
|
@@ -66,6 +66,9 @@ export function routeConnect(context) {
|
|
|
66
66
|
async function connect(context) {
|
|
67
67
|
const { opts, request, baseUrl, runtimeContext } = context;
|
|
68
68
|
const refuse = (error, status = 403) => privateJson({ error }, { status });
|
|
69
|
+
const invalidLink = () => refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
|
|
70
|
+
// Spends nothing, so this same link works once the other visit settles.
|
|
71
|
+
const starting = () => refuse("This connection is starting in another visit. Reload this page in a moment. If this stays, start a new connection request.", 409);
|
|
69
72
|
if (request.method !== "GET")
|
|
70
73
|
return privateJson({ error: "method not allowed" }, { status: 405, headers: { Allow: "GET" } });
|
|
71
74
|
const unavailable = oauthConnectUnavailable(opts);
|
|
@@ -74,7 +77,7 @@ async function connect(context) {
|
|
|
74
77
|
const id = context.path.slice("/connect/".length);
|
|
75
78
|
const handoff = await verifyOAuthHandoff(opts, baseUrl, id, context.url.searchParams.get("h"));
|
|
76
79
|
if (!handoff)
|
|
77
|
-
return
|
|
80
|
+
return invalidLink();
|
|
78
81
|
const authPage = () => {
|
|
79
82
|
const target = new URL(`/connectors/${id}`, baseUrl);
|
|
80
83
|
target.searchParams.set("h", context.url.searchParams.get("h"));
|
|
@@ -124,10 +127,11 @@ async function connect(context) {
|
|
|
124
127
|
}
|
|
125
128
|
if (target.origin !== new URL(baseUrl).origin || target.username || target.password)
|
|
126
129
|
return refuse("Credential management URL is unavailable", 503);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
const claim = await claimOAuthConnectStart(opts, handoff);
|
|
131
|
+
if (claim === "pending")
|
|
132
|
+
return starting();
|
|
133
|
+
if (claim === "refused" || !(await finishOAuthConnectStart(opts, handoff, "started")))
|
|
134
|
+
return invalidLink();
|
|
131
135
|
// Credential entry stays in the authenticated operator UI on this origin.
|
|
132
136
|
return withSessionCookies(new Response(null, {
|
|
133
137
|
status: 302,
|
|
@@ -149,16 +153,20 @@ async function connect(context) {
|
|
|
149
153
|
const timeoutError = new ConnectorCallError("timeout", "OAuth authorization start timed out");
|
|
150
154
|
let claimed = false;
|
|
151
155
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
156
|
+
const claim = await claimOAuthConnectStart(opts, handoff);
|
|
157
|
+
if (claim === "pending")
|
|
158
|
+
return starting();
|
|
159
|
+
if (claim === "refused")
|
|
160
|
+
return invalidLink();
|
|
154
161
|
claimed = true;
|
|
162
|
+
const force = claim === "reset";
|
|
155
163
|
const status = await runEdge(withDeadlineEffect((signal) => Effect.tryPromise({
|
|
156
164
|
try: async () => {
|
|
157
165
|
ctx = registry.contextFor(id, baseUrl, scope, {
|
|
158
166
|
signal,
|
|
159
167
|
...(context.defer ? { defer: context.defer } : {}),
|
|
160
168
|
});
|
|
161
|
-
const started = await connector.startAuth(ctx, { force
|
|
169
|
+
const started = await connector.startAuth(ctx, { force });
|
|
162
170
|
if (signal.aborted)
|
|
163
171
|
throw signal.reason;
|
|
164
172
|
if (started.authorizationUrl) {
|
|
@@ -181,11 +189,11 @@ async function connect(context) {
|
|
|
181
189
|
catch: (error) => error,
|
|
182
190
|
}), { timeoutMs: START_TIMEOUT_MS, signal: request.signal, timeoutError }));
|
|
183
191
|
await drainOAuthStartResets(scope);
|
|
184
|
-
if (
|
|
192
|
+
if (force || (!status.authorizationReused && status.state !== "ok"))
|
|
185
193
|
await registry.invalidateStored(id);
|
|
186
194
|
if (status.state === "ok") {
|
|
187
195
|
if (!(await finishOAuthConnectStart(opts, handoff, "started")))
|
|
188
|
-
return
|
|
196
|
+
return invalidLink();
|
|
189
197
|
return withSessionCookies(new Response("This connector is already connected.", { headers: { "Cache-Control": "no-store" } }), authz.sessionCookies);
|
|
190
198
|
}
|
|
191
199
|
if (status.state !== "auth_required" || !status.authorizationUrl) {
|
|
@@ -194,7 +202,7 @@ async function connect(context) {
|
|
|
194
202
|
// state only, since a plugin's `startAuth` returns whatever it likes.
|
|
195
203
|
logFailure(opts.config.logger, "OAuth start failed", failureRecord({
|
|
196
204
|
connector: id,
|
|
197
|
-
mode:
|
|
205
|
+
mode: force ? "restart" : "continue",
|
|
198
206
|
state: status.state,
|
|
199
207
|
}));
|
|
200
208
|
return refuse(status.state === "auth_required"
|
|
@@ -205,7 +213,7 @@ async function connect(context) {
|
|
|
205
213
|
if (!["https:", "http:"].includes(target.protocol) || target.username || target.password)
|
|
206
214
|
return refuse("OAuth authorization returned no safe URL", 502);
|
|
207
215
|
if (!(await finishOAuthConnectStart(opts, handoff, "started")))
|
|
208
|
-
return
|
|
216
|
+
return invalidLink();
|
|
209
217
|
return withSessionCookies(new Response(null, { status: 302, headers: { Location: target.href, "Cache-Control": "no-store" } }), authz.sessionCookies);
|
|
210
218
|
}
|
|
211
219
|
catch (error) {
|
package/dist/routes/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { startTimingTrace } from "../timing-trace.js";
|
|
2
|
-
import { routeNames } from "../agent-routes.js";
|
|
2
|
+
import { META_ROUTES, routeNames } from "../agent-routes.js";
|
|
3
3
|
import { upwardStep } from "../upward.js";
|
|
4
4
|
import { bindDiscoveryEndpoint } from "../discovery-health.js";
|
|
5
5
|
import { authCall, authInvoke, authFlow, authMethod } from "../auth/native.js";
|
|
@@ -9,15 +9,15 @@ import { observeAgentAdmission } from "../agent-observation.js";
|
|
|
9
9
|
import { bindActivityRequest } from "../activity-request.js";
|
|
10
10
|
import { closeScope } from "../runtime/connector-scope.js";
|
|
11
11
|
import { executeLimits } from "../config.js";
|
|
12
|
-
import { oauthConnectUrl, oauthConnectLink,
|
|
12
|
+
import { oauthConnectUrl, oauthConnectLink, oauthConnectLinkStarted, claimAuthRetry, oauthConnectUnavailable, } from "../oauth-handoff.js";
|
|
13
13
|
import { AuthElicitation } from "../auth-elicitation.js";
|
|
14
14
|
import { classifyInboundRequest, createMcpHandler, isJSONRPCErrorResponse, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
|
|
15
15
|
import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
|
|
16
16
|
import { registerExecuteTool } from "../execute.js";
|
|
17
17
|
import { ExecutorAdmissionError } from "../executor-admission.js";
|
|
18
18
|
import { registerMetaTools } from "../meta-tools.js";
|
|
19
|
-
import { endpointInstructions } from "../skills.js";
|
|
20
|
-
import { NATIVE_TOOLS_LIST_TTL_MS, NativeSurface, nativeRequestHint } from "../native-tools.js";
|
|
19
|
+
import { ENDPOINT_EXTENSION, endpointExtension, endpointInstructions } from "../skills.js";
|
|
20
|
+
import { NATIVE_DEGRADED_TOOLS_LIST_TTL_MS, NATIVE_TOOLS_LIST_TTL_MS, NativeSurface, nativeRequestHint, } from "../native-tools.js";
|
|
21
21
|
import { registerSkills } from "./skills.js";
|
|
22
22
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
23
23
|
import { redactAgentOutput, sentSecretsForRequest } from "../sent-secrets.js";
|
|
@@ -322,7 +322,7 @@ function toolkitRetired(logger, sentSecrets) {
|
|
|
322
322
|
},
|
|
323
323
|
});
|
|
324
324
|
}
|
|
325
|
-
function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, readOnly = false, pool, principalActor) {
|
|
325
|
+
function serveMcp(request, requestSignal, requestStartedAt, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, readOnly = false, pool, principalActor, agentEndpoint = false) {
|
|
326
326
|
const sentSecrets = sentSecretsForRequest(requestScope);
|
|
327
327
|
// Every McpServer the request builds is fresh and closes with its scope.
|
|
328
328
|
// The modern handler tears its own down after the exchange; the legacy
|
|
@@ -330,7 +330,14 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
330
330
|
const servers = [];
|
|
331
331
|
// A native pool lists its downstream tools directly (pools.<name>.surface).
|
|
332
332
|
const native = pool !== undefined ? opts.pools.get(pool)?.native : undefined;
|
|
333
|
+
// Agent endpoints keep six tools until Eve is tested against the seventh. The
|
|
334
|
+
// eval arms that render other route names measure their own fixed surfaces.
|
|
335
|
+
const readRoute = opts.config.execute.readRoute && !native && !readOnly && !agentEndpoint && opts.routes === META_ROUTES;
|
|
333
336
|
const createServer = authMethod((hint) => authFlow(function* () {
|
|
337
|
+
// A native pool's text uses the native contract's wording.
|
|
338
|
+
const routes = native ? routeNames(native.executeCode ? "native-code" : "native") : opts.routes;
|
|
339
|
+
// Direct-call and handoff text names the program route read follow-ups should take.
|
|
340
|
+
const directRoutes = readRoute ? routeNames("read-route") : routes;
|
|
334
341
|
const authElicitation = new AuthElicitation({
|
|
335
342
|
vault: opts.config.vault,
|
|
336
343
|
publicUrl: opts.config.publicUrl,
|
|
@@ -338,41 +345,49 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
338
345
|
principal: principalKey,
|
|
339
346
|
registry,
|
|
340
347
|
canManage: canManageAuth,
|
|
341
|
-
connectLink: authMethod((id, force,
|
|
348
|
+
connectLink: authMethod((id, force, flow) => authCall(oauthConnectLink, [
|
|
342
349
|
opts,
|
|
343
350
|
opts.config.publicUrl ?? baseUrl,
|
|
344
351
|
id,
|
|
345
352
|
principalKey,
|
|
346
353
|
force,
|
|
347
354
|
Boolean(opts.config.vault?.seal && opts.config.vault.open),
|
|
348
|
-
|
|
355
|
+
flow,
|
|
349
356
|
])),
|
|
350
|
-
|
|
351
|
-
closeLinks: authMethod((id, nonces) => authCall(closeOAuthConnectLinks, [opts, baseUrl, id, nonces])),
|
|
357
|
+
linkStarted: authMethod((id, nonce) => authCall(oauthConnectLinkStarted, [opts, baseUrl, id, nonce])),
|
|
352
358
|
claimRetry: authMethod((id, nonce) => authCall(claimAuthRetry, [opts, baseUrl, id, nonce])),
|
|
353
359
|
unavailable: oauthConnectUnavailable(opts),
|
|
354
360
|
credentialUi: Boolean(opts.config.ui && opts.config.vault),
|
|
361
|
+
credentialHandoffUrl: opts.config.ui?.credentialHandoffUrl(baseUrl),
|
|
362
|
+
routes: directRoutes,
|
|
355
363
|
requestSignal,
|
|
356
364
|
requestScope,
|
|
357
365
|
defer: runtimeContext?.waitUntil?.bind(runtimeContext),
|
|
358
366
|
});
|
|
367
|
+
// Read as this request's response is built, so a degraded native listing can shorten it.
|
|
368
|
+
const toolsListHint = { ttlMs: native ? NATIVE_TOOLS_LIST_TTL_MS : 3_600_000, cacheScope: "private" };
|
|
369
|
+
const connectors = registry.listConnectors();
|
|
370
|
+
const effectiveReadOnly = readOnly || (connectors.length > 0 && connectors.every((connector) => connector.readOnly === true));
|
|
359
371
|
const server = new McpServer(opts.config.serverInfo, {
|
|
360
372
|
// A request-local server cannot publish catalog changes. Set this before
|
|
361
373
|
// tool registration, whose SDK default otherwise advertises listChanged.
|
|
362
|
-
capabilities: {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
374
|
+
capabilities: {
|
|
375
|
+
tools: { listChanged: false },
|
|
376
|
+
extensions: {
|
|
377
|
+
"io.modelcontextprotocol/skills": {},
|
|
378
|
+
[ENDPOINT_EXTENSION]: endpointExtension(connectors, native !== undefined, native?.executeCode ?? true, effectiveReadOnly),
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
// Instructions reflect the admitted pool, its connector roster and the effective write
|
|
382
|
+
// policy. `registry` is this caller's admitted view, the one execute_code's inventory
|
|
383
|
+
// reads, so a person at plain /mcp never sees an id outside their pool. Every cache hint
|
|
384
|
+
// below is private, so a client keeps what it learned for this caller and endpoint URL.
|
|
385
|
+
instructions: endpointInstructions(pool !== undefined ? opts.pools.get(pool)?.description : undefined, native, effectiveReadOnly, connectors.map((connector) => connector.id), readRoute),
|
|
368
386
|
inputRequired: { legacyShim: false },
|
|
369
387
|
requestState: { verify: (state, context) => authElicitation.verify(state, context) },
|
|
370
388
|
cacheHints: {
|
|
371
389
|
"server/discover": { ttlMs: 3_600_000, cacheScope: "private" },
|
|
372
|
-
"tools/list":
|
|
373
|
-
ttlMs: native ? NATIVE_TOOLS_LIST_TTL_MS : 3_600_000,
|
|
374
|
-
cacheScope: "private",
|
|
375
|
-
},
|
|
390
|
+
"tools/list": toolsListHint,
|
|
376
391
|
},
|
|
377
392
|
});
|
|
378
393
|
servers.push(server);
|
|
@@ -400,12 +415,15 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
400
415
|
probeTimeoutMs: opts.config.discovery.probeTimeoutMs,
|
|
401
416
|
...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
|
|
402
417
|
});
|
|
403
|
-
const diagnostics = native
|
|
404
|
-
|
|
405
|
-
|
|
418
|
+
const diagnostics = native
|
|
419
|
+
? { ...native, catalogErrors: [], staleCatalogs: [] }
|
|
420
|
+
: undefined;
|
|
421
|
+
// The program tool this request lists and its reads may offer: the read route, where listed.
|
|
422
|
+
const programTool = !native || native.executeCode ? directRoutes.program : undefined;
|
|
406
423
|
const nativeRoute = registerMetaTools(server, registry, {
|
|
407
424
|
native: diagnostics,
|
|
408
|
-
|
|
425
|
+
programTool,
|
|
426
|
+
routes: directRoutes,
|
|
409
427
|
authElicitation,
|
|
410
428
|
client,
|
|
411
429
|
baseUrl,
|
|
@@ -417,19 +435,23 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
417
435
|
credentialHandoffUrl: opts.config.ui?.credentialHandoffUrl(baseUrl),
|
|
418
436
|
...(activity ? { activity } : {}),
|
|
419
437
|
defaultToolTimeoutMs: opts.config.calls.defaultTimeoutMs ?? opts.config.execute.hostCallTimeoutMs,
|
|
438
|
+
responseCeilingMs: opts.config.execute.watchdogMs,
|
|
439
|
+
requestStartedAt,
|
|
420
440
|
maxProgramResultChars: opts.config.execute.maxResultChars,
|
|
441
|
+
maxProgramHostCalls: opts.config.execute.maxHostCalls,
|
|
421
442
|
probeTimeoutMs: opts.config.discovery.probeTimeoutMs,
|
|
422
443
|
discoveryConcurrency: opts.config.discovery.concurrency,
|
|
423
444
|
requestSignal,
|
|
424
445
|
...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
|
|
425
446
|
});
|
|
426
|
-
if (
|
|
447
|
+
if (programTool) {
|
|
427
448
|
registerExecuteTool(server, registry, {
|
|
428
449
|
authElicitation,
|
|
429
450
|
client,
|
|
430
451
|
baseUrl,
|
|
431
452
|
requestScope,
|
|
432
453
|
executor: opts.executor,
|
|
454
|
+
requestStartedAt,
|
|
433
455
|
defaultToolTimeoutMs: opts.config.calls.defaultTimeoutMs ?? opts.config.execute.hostCallTimeoutMs,
|
|
434
456
|
logger: opts.config.logger,
|
|
435
457
|
...(activity ? { activity } : {}),
|
|
@@ -439,6 +461,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
439
461
|
readOnly,
|
|
440
462
|
native: native !== undefined,
|
|
441
463
|
routes,
|
|
464
|
+
readRoute,
|
|
442
465
|
});
|
|
443
466
|
}
|
|
444
467
|
if (native && nativeRoute && hint) {
|
|
@@ -448,6 +471,10 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
448
471
|
route: nativeRoute,
|
|
449
472
|
bind: (context) => authElicitation.bind(context),
|
|
450
473
|
catalogErrors: diagnostics.catalogErrors,
|
|
474
|
+
staleCatalogs: diagnostics.staleCatalogs,
|
|
475
|
+
onDegradedListing: () => {
|
|
476
|
+
toolsListHint.ttlMs = NATIVE_DEGRADED_TOOLS_LIST_TTL_MS;
|
|
477
|
+
},
|
|
451
478
|
logger: opts.config.logger,
|
|
452
479
|
requestScope,
|
|
453
480
|
requestSignal,
|
|
@@ -665,6 +692,8 @@ export function createMcpRoute(opts) {
|
|
|
665
692
|
// The Node adapter has already buffered up to its own pre-auth cap
|
|
666
693
|
// (`listen`'s `maxBodyBytes`) before a request reaches core.
|
|
667
694
|
const startedAt = Date.now();
|
|
695
|
+
// The response ceiling counts from here; a future stamp could only extend it.
|
|
696
|
+
const arrivedAt = Math.min(context.runtimeContext?.requestArrivedAt ?? startedAt, startedAt);
|
|
668
697
|
const maxDurationMs = opts.requestAdmission.maxDurationMs;
|
|
669
698
|
const listenHeader = request.method === "POST" && request.headers.get("Mcp-Method") === "subscriptions/listen";
|
|
670
699
|
const refuseAdmission = (error) => {
|
|
@@ -942,7 +971,7 @@ export function createMcpRoute(opts) {
|
|
|
942
971
|
if (new URL(request.url).searchParams.has("toolkit")) {
|
|
943
972
|
return cors(toolkitRetired(opts.config.logger, sentSecrets));
|
|
944
973
|
}
|
|
945
|
-
return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
|
|
974
|
+
return cors(yield* serveMcp(localRequest, localAbort.signal, arrivedAt, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
|
|
946
975
|
const connector = scopedRegistry.getConnector(id);
|
|
947
976
|
return Boolean(connector && mayManageConnector(opts, authz, connector));
|
|
948
977
|
}, authz.principalKey, runtimeContext, readOnly, scope.pool, authz.identity.principal
|
|
@@ -951,7 +980,7 @@ export function createMcpRoute(opts) {
|
|
|
951
980
|
id: authz.identity.principal.id,
|
|
952
981
|
namespace: authz.identity.principal.namespace,
|
|
953
982
|
}
|
|
954
|
-
: undefined));
|
|
983
|
+
: undefined, agentEndpoint));
|
|
955
984
|
});
|
|
956
985
|
if (remainingMs === undefined)
|
|
957
986
|
return yield* handled;
|
package/dist/routes/shared.d.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface ServerOptions {
|
|
|
34
34
|
}
|
|
35
35
|
export interface RuntimeExecutionContext extends InboundAuthRuntimeContext {
|
|
36
36
|
waitUntil(promise: Promise<unknown>): void;
|
|
37
|
+
/**
|
|
38
|
+
* When the Node adapter received this request's headers, before it buffered
|
|
39
|
+
* the body. It comes from the host, never from anything the request carries.
|
|
40
|
+
* Workers read the body lazily, so the route's own clock already counts upload.
|
|
41
|
+
*/
|
|
42
|
+
readonly requestArrivedAt?: number;
|
|
37
43
|
}
|
|
38
44
|
export interface RouteContext {
|
|
39
45
|
request: Request;
|
package/dist/runtime/call.js
CHANGED
|
@@ -359,17 +359,22 @@ export function wrapViewCredentials(view) {
|
|
|
359
359
|
return;
|
|
360
360
|
entry.credentialWrapped = true;
|
|
361
361
|
const access = view.credential;
|
|
362
|
+
// A read outlives a caller that stopped waiting and registers when it lands, so the
|
|
363
|
+
// request counts it until then: a sealing run must not publish ahead of that.
|
|
364
|
+
const read = async (value, values) => {
|
|
365
|
+
const done = requestOf(entry.request).credentialReads.begin();
|
|
366
|
+
try {
|
|
367
|
+
const result = await value();
|
|
368
|
+
registerCredentialValues(viewSecrets(view), values(result));
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
done();
|
|
373
|
+
}
|
|
374
|
+
};
|
|
362
375
|
view.credential = {
|
|
363
|
-
get: (field) => settleOnClose(view,
|
|
364
|
-
|
|
365
|
-
registerCredentialValues(viewSecrets(view), value === null ? null : { [field ?? "value"]: value });
|
|
366
|
-
return value;
|
|
367
|
-
}, true),
|
|
368
|
-
getAll: () => settleOnClose(view, async () => {
|
|
369
|
-
const values = await access.getAll();
|
|
370
|
-
registerCredentialValues(viewSecrets(view), values);
|
|
371
|
-
return values;
|
|
372
|
-
}, true),
|
|
376
|
+
get: (field) => settleOnClose(view, () => read(() => access.get(field), (value) => (value === null ? null : { [field ?? "value"]: value })), true),
|
|
377
|
+
getAll: () => settleOnClose(view, () => read(() => access.getAll(), (values) => values), true),
|
|
373
378
|
};
|
|
374
379
|
}
|
|
375
380
|
/** Credentials for request-owned transports use the operation, never a closed call. */
|
|
@@ -1,9 +1,42 @@
|
|
|
1
1
|
import { traceFor } from "../timing-trace.js";
|
|
2
2
|
// One owner per existing upstream request identity. Facts survive revocation;
|
|
3
3
|
// retaining them never reconstitutes a call capability or a live operation.
|
|
4
|
-
import { Context, Exit, Scope } from "effect";
|
|
4
|
+
import { Context, Effect, Exit, Scope } from "effect";
|
|
5
5
|
import { RequestReads } from "./request-reads.js";
|
|
6
6
|
import { detach } from "./run.js";
|
|
7
|
+
/** Work that can still add to a secret set. A sealing run waits until none is left. */
|
|
8
|
+
export class PendingWork {
|
|
9
|
+
active = 0;
|
|
10
|
+
waiting = new Set();
|
|
11
|
+
get pending() {
|
|
12
|
+
return this.active > 0;
|
|
13
|
+
}
|
|
14
|
+
/** Returns the function that ends this piece of work; calling it twice counts once. */
|
|
15
|
+
begin() {
|
|
16
|
+
this.active++;
|
|
17
|
+
let done = false;
|
|
18
|
+
return () => {
|
|
19
|
+
if (done)
|
|
20
|
+
return;
|
|
21
|
+
done = true;
|
|
22
|
+
if (--this.active > 0)
|
|
23
|
+
return;
|
|
24
|
+
for (const wake of this.waiting)
|
|
25
|
+
wake();
|
|
26
|
+
this.waiting.clear();
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Succeeds when nothing is pending. Interrupting it drops the wait. */
|
|
30
|
+
idle = Effect.callback((resume) => {
|
|
31
|
+
if (this.active === 0)
|
|
32
|
+
return resume(Effect.void);
|
|
33
|
+
const wake = () => resume(Effect.void);
|
|
34
|
+
this.waiting.add(wake);
|
|
35
|
+
return Effect.sync(() => {
|
|
36
|
+
this.waiting.delete(wake);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
7
40
|
export class RequestOwner {
|
|
8
41
|
identity;
|
|
9
42
|
open = true;
|
|
@@ -14,6 +47,8 @@ export class RequestOwner {
|
|
|
14
47
|
calls = new Set();
|
|
15
48
|
signals = new WeakMap();
|
|
16
49
|
scope = Scope.makeUnsafe();
|
|
50
|
+
/** Credential reads whose values have yet to join this request's secret set. */
|
|
51
|
+
credentialReads = new PendingWork();
|
|
17
52
|
// Initialize secrets only when data is sent. Metadata-only UI imports must
|
|
18
53
|
// not retain the payload matcher merely to revoke a request.
|
|
19
54
|
retained = {
|
package/dist/runtime/run.js
CHANGED
|
@@ -128,7 +128,10 @@ export function withDeadlineEffect(operation, options) {
|
|
|
128
128
|
fromSignal(controller.signal),
|
|
129
129
|
];
|
|
130
130
|
if (options.timeoutMs !== undefined) {
|
|
131
|
-
contenders.push(Effect.sleep(Duration.millis(options.timeoutMs)).pipe(Effect.andThen(Effect.
|
|
131
|
+
contenders.push(Effect.sleep(Duration.millis(options.timeoutMs)).pipe(Effect.andThen(Effect.suspend(() => {
|
|
132
|
+
const more = options.extendMs?.() ?? 0;
|
|
133
|
+
return more > 0 ? Effect.sleep(Duration.millis(more)) : Effect.void;
|
|
134
|
+
})), Effect.andThen(Effect.sync(() => controller.abort(options.timeoutError))), Effect.andThen(Effect.never)));
|
|
132
135
|
}
|
|
133
136
|
return Effect.raceAllFirst(contenders).pipe(Effect.ensuring(Effect.sync(() => {
|
|
134
137
|
controller.abort();
|