@zackbart/connecta 0.26.3 → 0.28.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/AGENTS.md +11 -11
- package/CHANGELOG.md +128 -1
- package/README.md +15 -19
- package/bin/connecta.mjs +10 -6
- package/dist/access-tokens.js +13 -2
- package/dist/activity.d.ts +5 -4
- package/dist/activity.js +1 -10
- package/dist/artifacts/connector.d.ts +3 -0
- package/dist/artifacts/connector.js +6 -0
- package/dist/artifacts/document.js +6 -0
- package/dist/artifacts/json.js +10 -0
- package/dist/artifacts/module.js +7 -1
- package/dist/artifacts/operations.d.ts +3 -1
- package/dist/artifacts/operations.js +69 -11
- package/dist/artifacts/refresh.d.ts +2 -0
- package/dist/artifacts/refresh.js +3 -2
- package/dist/artifacts/routes.js +4 -15
- package/dist/artifacts/validate.js +2 -4
- package/dist/auth/downstream-oauth.js +95 -36
- package/dist/catalog-service.js +1 -1
- package/dist/connector-scope.d.ts +2 -0
- package/dist/connector-scope.js +27 -0
- package/dist/connectors/api.js +8 -2
- package/dist/connectors/remote-mcp.js +57 -17
- package/dist/credentials.js +2 -1
- package/dist/execute.js +107 -368
- package/dist/executors/quickjs.js +1 -1
- package/dist/exempt-writes.js +162 -0
- package/dist/index.d.ts +12 -31
- package/dist/index.js +15 -47
- package/dist/invocation.js +6 -19
- package/dist/meta-tools.js +1 -2
- package/dist/oauth-partition.js +32 -0
- package/dist/operator-ui/generated.js +1 -1
- package/dist/operator-ui/model.d.ts +3 -3
- package/dist/operator-ui/view.js +6 -4
- package/dist/providers/notion.js +4 -1
- package/dist/providers/vercel.js +13 -9
- package/dist/registry.d.ts +1 -0
- package/dist/registry.js +58 -25
- package/dist/routes/credentials.js +16 -8
- package/dist/routes/mcp.js +5 -21
- package/dist/routes/oauth.js +70 -64
- package/dist/routes/shared.d.ts +1 -6
- package/dist/runtime/run.js +7 -1
- package/dist/server.js +0 -4
- package/dist/skills.js +8 -23
- package/dist/storage/file.js +57 -20
- package/dist/storage/memory.js +8 -1
- package/dist/tool-safety.d.ts +1 -1
- package/dist/tool-safety.js +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +51 -14
- package/documentation/auth.md +7 -4
- package/documentation/code-mode.md +91 -194
- package/documentation/meta-tools.md +38 -41
- package/ethos.md +6 -7
- package/examples/worker/README.md +14 -17
- package/examples/worker/src/d1-activity-row.ts +4 -1
- package/examples/worker/src/d1-storage.ts +27 -5
- package/examples/worker/src/index.ts +1 -9
- package/package.json +4 -4
- package/templates/node/AGENTS.md +4 -3
- package/templates/node/README.md +3 -4
- package/templates/node/package.json +2 -2
- package/dist/resumable.js +0 -1291
- package/dist/run-journal.js +0 -347
|
@@ -7,10 +7,10 @@ import type { CatalogAccessObservation, CatalogDriftReport } from "../types.js";
|
|
|
7
7
|
*
|
|
8
8
|
* - `runs_in_programs` — explicitly read-only, so `execute_code` may call it.
|
|
9
9
|
* - `exempt` — not read-only, but the deployment's `execute.approval` lets a
|
|
10
|
-
* program call it without
|
|
10
|
+
* program call it without asking (#566). Still approval-required
|
|
11
11
|
* everywhere else.
|
|
12
|
-
* - `needs_approval` — everything else: a program
|
|
13
|
-
* `
|
|
12
|
+
* - `needs_approval` — everything else: a program refuses it, and it crosses
|
|
13
|
+
* `call_destructive_tool`, where the host asks.
|
|
14
14
|
*
|
|
15
15
|
* The badge renders from a table keyed by this union, so each value is one
|
|
16
16
|
* entry, not a branch.
|
package/dist/operator-ui/view.js
CHANGED
|
@@ -413,12 +413,12 @@ export const TOOL_SAFETY_BADGE = {
|
|
|
413
413
|
exempt: {
|
|
414
414
|
label: "exempt from approval",
|
|
415
415
|
tone: "neutral",
|
|
416
|
-
title: "Not read-only, but this deployment's config lets programs call it without
|
|
416
|
+
title: "Not read-only, but this deployment's config lets programs call it without asking. Each call still counts against the write budget and appears in activity; call_tool still refuses it.",
|
|
417
417
|
},
|
|
418
418
|
needs_approval: {
|
|
419
419
|
label: "asks for approval",
|
|
420
420
|
tone: "warn",
|
|
421
|
-
title: "Not explicitly read-only:
|
|
421
|
+
title: "Not explicitly read-only: programs refuse it, and it runs through call_destructive_tool, where the host asks first.",
|
|
422
422
|
},
|
|
423
423
|
};
|
|
424
424
|
/**
|
|
@@ -608,7 +608,8 @@ export function activitySummary(events) {
|
|
|
608
608
|
return `${events.length} loaded call${events.length === 1 ? "" : "s"} · ${tools} tool${tools === 1 ? "" : "s"}`;
|
|
609
609
|
}
|
|
610
610
|
// A pause and an approval are neither success nor failure: each gets its own
|
|
611
|
-
// class, and the stylesheet paints neither as an error.
|
|
611
|
+
// class, and the stylesheet paints neither as an error. Nothing has emitted
|
|
612
|
+
// either since issue #672 removed program pauses, but older rows carry them.
|
|
612
613
|
const ACTIVITY_OUTCOMES = [
|
|
613
614
|
"success",
|
|
614
615
|
"error",
|
|
@@ -626,7 +627,7 @@ const OUTCOME_BADGE = {
|
|
|
626
627
|
error: { label: "Failed", tone: "danger" },
|
|
627
628
|
timeout: { label: "Timed out", tone: "danger" },
|
|
628
629
|
cancelled: { label: "Cancelled", tone: "neutral" },
|
|
629
|
-
paused: { label: "
|
|
630
|
+
paused: { label: "Paused for approval", tone: "warn" },
|
|
630
631
|
approved: { label: "Approved", tone: "ok" },
|
|
631
632
|
};
|
|
632
633
|
export function activityOutcomeBadge(outcome) {
|
|
@@ -636,6 +637,7 @@ const SOURCE_LABELS = {
|
|
|
636
637
|
execute_code: "In a program",
|
|
637
638
|
call_tool: "Direct call",
|
|
638
639
|
call_destructive_tool: "Direct call, approved by the host",
|
|
640
|
+
// History only: resume_execution left with program pauses (#672).
|
|
639
641
|
resume_execution: "Resumed program",
|
|
640
642
|
batch_call: "Batch call",
|
|
641
643
|
};
|
package/dist/providers/notion.js
CHANGED
|
@@ -147,7 +147,10 @@ async function notionRequest(ctx, request) {
|
|
|
147
147
|
if (!response.ok) {
|
|
148
148
|
throw notionFailure(response.status, payload, response.headers);
|
|
149
149
|
}
|
|
150
|
-
|
|
150
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
151
|
+
throw new ConnectorCallError("connector_call_failed", "Notion returned an unreadable or non-object JSON response for a successful status. The request was sent, but its result could not be read; do not repeat a write to recover its result.", { retryable: false });
|
|
152
|
+
}
|
|
153
|
+
return payload;
|
|
151
154
|
});
|
|
152
155
|
}
|
|
153
156
|
// Projections. Notion wraps every property in a discriminated object, every
|
package/dist/providers/vercel.js
CHANGED
|
@@ -83,7 +83,7 @@ function vercelFailure(status, headers, payload) {
|
|
|
83
83
|
retryable: false,
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
|
-
function parseBody(text, contentType) {
|
|
86
|
+
function parseBody(text, contentType, strict = false) {
|
|
87
87
|
if (!text)
|
|
88
88
|
return undefined;
|
|
89
89
|
try {
|
|
@@ -91,6 +91,8 @@ function parseBody(text, contentType) {
|
|
|
91
91
|
}
|
|
92
92
|
catch {
|
|
93
93
|
if (contentType?.includes("stream+json") || contentType?.includes("ndjson")) {
|
|
94
|
+
if (strict)
|
|
95
|
+
return parseStreamRows(text);
|
|
94
96
|
const rows = [];
|
|
95
97
|
for (const line of text.split("\n")) {
|
|
96
98
|
if (!line.trim())
|
|
@@ -104,6 +106,9 @@ function parseBody(text, contentType) {
|
|
|
104
106
|
}
|
|
105
107
|
return rows;
|
|
106
108
|
}
|
|
109
|
+
if (strict) {
|
|
110
|
+
throw new ConnectorCallError("connector_call_failed", "Vercel returned a malformed successful response.", { retryable: false });
|
|
111
|
+
}
|
|
107
112
|
return text;
|
|
108
113
|
}
|
|
109
114
|
}
|
|
@@ -144,14 +149,13 @@ function vercelTransport(baseUrl) {
|
|
|
144
149
|
},
|
|
145
150
|
});
|
|
146
151
|
}
|
|
147
|
-
async function callVercel(send, request, ctx,
|
|
152
|
+
async function callVercel(send, request, ctx, success = {}) {
|
|
148
153
|
return await send(request, ctx, async (response) => {
|
|
149
154
|
const text = await response.text();
|
|
150
|
-
const payload = parseBody(text, response.headers.get("content-type"));
|
|
151
155
|
if (!response.ok) {
|
|
152
|
-
throw vercelFailure(response.status, response.headers,
|
|
156
|
+
throw vercelFailure(response.status, response.headers, parseBody(text, response.headers.get("content-type")));
|
|
153
157
|
}
|
|
154
|
-
return
|
|
158
|
+
return success.parse ? success.parse(text) : parseBody(text, response.headers.get("content-type"), !success.raw);
|
|
155
159
|
});
|
|
156
160
|
}
|
|
157
161
|
function teamQuery(args, defaultTeamId) {
|
|
@@ -553,7 +557,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
|
|
|
553
557
|
required: ["result"],
|
|
554
558
|
},
|
|
555
559
|
handler: async (args, ctx) => ({
|
|
556
|
-
result: (await callVercel(send, { method: "GET", ...rawRequest(args, defaultTeamId) }, ctx)) ?? null,
|
|
560
|
+
result: (await callVercel(send, { method: "GET", ...rawRequest(args, defaultTeamId) }, ctx, { raw: true })) ?? null,
|
|
557
561
|
}),
|
|
558
562
|
},
|
|
559
563
|
{
|
|
@@ -588,7 +592,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
|
|
|
588
592
|
method: args["method"],
|
|
589
593
|
...rawRequest(args, defaultTeamId),
|
|
590
594
|
...(args["body"] !== undefined ? { body: args["body"] } : {}),
|
|
591
|
-
}, ctx)) ?? null,
|
|
595
|
+
}, ctx, { raw: true })) ?? null,
|
|
592
596
|
}),
|
|
593
597
|
},
|
|
594
598
|
{
|
|
@@ -641,7 +645,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
|
|
|
641
645
|
"Content-Type": args["contentType"],
|
|
642
646
|
},
|
|
643
647
|
rawBody: uploadBody(args),
|
|
644
|
-
}, ctx)) ?? null,
|
|
648
|
+
}, ctx, { raw: true })) ?? null,
|
|
645
649
|
}),
|
|
646
650
|
},
|
|
647
651
|
{
|
|
@@ -839,7 +843,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
|
|
|
839
843
|
method: "GET",
|
|
840
844
|
path: `/v1/projects/${encodeURIComponent(args["projectId"])}/deployments/${encodeURIComponent(args["deploymentId"])}/runtime-logs`,
|
|
841
845
|
query: team(args), headers: { Accept: "application/stream+json" },
|
|
842
|
-
}, { ...ctx, signal }, parseStreamRows), {
|
|
846
|
+
}, { ...ctx, signal }, { parse: parseStreamRows }), {
|
|
843
847
|
timeoutMs: RUNTIME_LOG_TIMEOUT_MS,
|
|
844
848
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
845
849
|
timeoutError: new ConnectorCallError("unavailable", `Vercel's runtime-log stream stayed open past this connector's ${RUNTIME_LOG_TIMEOUT_MS / 1_000}-second bound. Retry for a fresh snapshot.`),
|
package/dist/registry.d.ts
CHANGED
|
@@ -171,6 +171,7 @@ export declare class Registry implements RegistryView {
|
|
|
171
171
|
private resultStashBytes;
|
|
172
172
|
private readonly configuredConnectors;
|
|
173
173
|
private readonly personalRegistries;
|
|
174
|
+
private readonly oauthPartition;
|
|
174
175
|
private callAdmissionClosed;
|
|
175
176
|
/** Bounded FIFO of absent grants already warned about. */
|
|
176
177
|
private readonly warnedAbsentGrants;
|
package/dist/registry.js
CHANGED
|
@@ -8,6 +8,7 @@ import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTE
|
|
|
8
8
|
import { ObservedOutputSchemas } from "./result-shapes.js";
|
|
9
9
|
import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary, } from "./skills.js";
|
|
10
10
|
import { attachOAuthSealer, vaultOAuthSealer } from "./oauth-sealing.js";
|
|
11
|
+
import { attachOAuthPartition, oauthPartitionIdle } from "./oauth-partition.js";
|
|
11
12
|
import { attachCaller } from "./connector-caller.js";
|
|
12
13
|
import { closeScopeOnExit } from "./runtime/connector-scope.js";
|
|
13
14
|
import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
@@ -164,6 +165,7 @@ export class Registry {
|
|
|
164
165
|
resultStashBytes = 0;
|
|
165
166
|
configuredConnectors;
|
|
166
167
|
personalRegistries = new Map();
|
|
168
|
+
oauthPartition = {};
|
|
167
169
|
callAdmissionClosed = false;
|
|
168
170
|
/** Bounded FIFO of absent grants already warned about. */
|
|
169
171
|
warnedAbsentGrants = new Set();
|
|
@@ -226,9 +228,12 @@ export class Registry {
|
|
|
226
228
|
// principal starts with no generations and no mutation queue, so a
|
|
227
229
|
// refresh the evicted registry finishes later would persist a listing
|
|
228
230
|
// that a credential change on the replacement had just deleted.
|
|
231
|
+
// OAuth status/start work bypasses both gates, and accepted rotations
|
|
232
|
+
// may still be saving after their caller leaves. Preserve its partition.
|
|
229
233
|
const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()) &&
|
|
230
234
|
candidate.catalogRefreshes.size === 0 &&
|
|
231
|
-
candidate.catalogMutations.size === 0
|
|
235
|
+
candidate.catalogMutations.size === 0 &&
|
|
236
|
+
oauthPartitionIdle(candidate.oauthPartition));
|
|
232
237
|
if (!idle) {
|
|
233
238
|
throw new Error("Personal connector capacity is exhausted; retry after calls, rolling budgets, and catalog refreshes drain.");
|
|
234
239
|
}
|
|
@@ -287,11 +292,24 @@ export class Registry {
|
|
|
287
292
|
}
|
|
288
293
|
async storeOAuthHandoff(connectorId, state, principalKey) {
|
|
289
294
|
const key = this.oauthHandoffKey(connectorId, await sha256Hex(state));
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
295
|
+
for (let attempt = 0; attempt < 32; attempt++) {
|
|
296
|
+
const existing = await this.opts.storage.get(key);
|
|
297
|
+
if (existing && existing !== principalKey) {
|
|
298
|
+
throw new Error(`Connector "${connectorId}" reused one OAuth state across principals`);
|
|
299
|
+
}
|
|
300
|
+
const expiry = { ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS };
|
|
301
|
+
if (this.opts.storage.compareAndSet) {
|
|
302
|
+
// Bind ownership atomically, including renewal of the same owner's
|
|
303
|
+
// handoff. Two owners reading a miss must not overwrite each other.
|
|
304
|
+
if (!await this.opts.storage.compareAndSet(key, existing, principalKey, expiry))
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
await this.opts.storage.set(key, principalKey, expiry);
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
293
311
|
}
|
|
294
|
-
|
|
312
|
+
throw new Error(`OAuth handoff for "${connectorId}" is busy; retry authorization`);
|
|
295
313
|
}
|
|
296
314
|
async oauthCallbackView(connectorId, state) {
|
|
297
315
|
const connector = this.connectors.get(connectorId);
|
|
@@ -389,7 +407,10 @@ export class Registry {
|
|
|
389
407
|
return values;
|
|
390
408
|
};
|
|
391
409
|
credentialAccess = {
|
|
392
|
-
get: async (field = "value") =>
|
|
410
|
+
get: async (field = "value") => {
|
|
411
|
+
const values = await readValues();
|
|
412
|
+
return values && Object.hasOwn(values, field) ? values[field] : null;
|
|
413
|
+
},
|
|
393
414
|
getAll: readValues,
|
|
394
415
|
};
|
|
395
416
|
}
|
|
@@ -401,6 +422,7 @@ export class Registry {
|
|
|
401
422
|
requestScope,
|
|
402
423
|
...callOptions,
|
|
403
424
|
};
|
|
425
|
+
attachOAuthPartition(context, this.oauthPartition);
|
|
404
426
|
// Downstream OAuth state is sealed under the vault key, bound to this
|
|
405
427
|
// connector and owner. The sealer rides beside the context, not on it.
|
|
406
428
|
return this.opts.credentialVault
|
|
@@ -818,8 +840,12 @@ export class Registry {
|
|
|
818
840
|
const turn = Deferred.makeUnsafe();
|
|
819
841
|
this.catalogMutations.set(id, turn);
|
|
820
842
|
try {
|
|
821
|
-
if (previous)
|
|
822
|
-
|
|
843
|
+
if (previous) {
|
|
844
|
+
// A cross-request Deferred alone looks hung to workerd. Keep a timer
|
|
845
|
+
// owned by this request until the preceding mutation hands over its
|
|
846
|
+
// turn; the race interrupts the timer as soon as the wait settles.
|
|
847
|
+
await runEdge(Effect.raceFirst(Deferred.await(previous), Effect.forever(Effect.sleep(Duration.seconds(1)))));
|
|
848
|
+
}
|
|
823
849
|
await runOnPartition(operation, this.opts);
|
|
824
850
|
}
|
|
825
851
|
finally {
|
|
@@ -841,14 +867,8 @@ export class Registry {
|
|
|
841
867
|
// observation, but must not overwrite a newer same-generation refresh.
|
|
842
868
|
// Blocking callers do not set this flag and retain their prior publication
|
|
843
869
|
// behavior when an inbound abort races a completed listing.
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
// The caller that began this refresh may still use its result, but a
|
|
847
|
-
// credential/OAuth change that landed while listTools was in flight, or a
|
|
848
|
-
// flight abandoned while it listed, means the listing must not enter
|
|
849
|
-
// either shared cache layer.
|
|
850
|
-
if (!this.mayPublish(id, generation, flight))
|
|
851
|
-
return tools;
|
|
870
|
+
// Every returned listing must pass the ceilings, even when invalidation
|
|
871
|
+
// or abandonment means it can no longer enter either shared cache layer.
|
|
852
872
|
if (tools.length > MAX_CATALOG_TOOLS) {
|
|
853
873
|
const message = `Connector "${id}" returned ${tools.length} tools, over the ` +
|
|
854
874
|
`${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
|
|
@@ -857,10 +877,6 @@ export class Registry {
|
|
|
857
877
|
}
|
|
858
878
|
const previous = this.cache.get(id);
|
|
859
879
|
const snapshot = await snapshotCatalog(tools);
|
|
860
|
-
if (!this.mayPublish(id, generation, flight) ||
|
|
861
|
-
(skipPublicationWhenAborted && ctx.signal?.aborted)) {
|
|
862
|
-
return tools;
|
|
863
|
-
}
|
|
864
880
|
if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
|
|
865
881
|
const message = `Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
|
|
866
882
|
`serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
|
|
@@ -868,6 +884,13 @@ export class Registry {
|
|
|
868
884
|
this.opts.logger.warn(`[connecta] ${message}`);
|
|
869
885
|
throw new Error(message);
|
|
870
886
|
}
|
|
887
|
+
// The caller that began this refresh may still use its bounded result,
|
|
888
|
+
// but credential changes, abandoned flights, and deferred cancellation
|
|
889
|
+
// prevent publication. Recheck after snapshotting, which is asynchronous.
|
|
890
|
+
if (!this.mayPublish(id, generation, flight) ||
|
|
891
|
+
(skipPublicationWhenAborted && ctx.signal?.aborted)) {
|
|
892
|
+
return tools;
|
|
893
|
+
}
|
|
871
894
|
const now = Date.now();
|
|
872
895
|
const catalogChanged = !previous || previous.fingerprint !== snapshot.fingerprint;
|
|
873
896
|
const shouldPersist = catalogChanged ||
|
|
@@ -1296,23 +1319,33 @@ class ScopedRegistryView {
|
|
|
1296
1319
|
allowed;
|
|
1297
1320
|
scope;
|
|
1298
1321
|
maxResultBytes;
|
|
1299
|
-
|
|
1322
|
+
/** An evicted view's admission controller must remain closed. */
|
|
1323
|
+
admissionPersonal;
|
|
1324
|
+
get personal() {
|
|
1325
|
+
return this.scope.principalKey
|
|
1326
|
+
? this.root.personalRegistry(this.scope.principalKey)
|
|
1327
|
+
: undefined;
|
|
1328
|
+
}
|
|
1300
1329
|
constructor(root, allowed, scope) {
|
|
1301
1330
|
this.root = root;
|
|
1302
1331
|
this.allowed = allowed;
|
|
1303
1332
|
this.scope = scope;
|
|
1304
1333
|
this.maxResultBytes = root.maxResultBytes;
|
|
1305
|
-
|
|
1334
|
+
// Check capacity at construction, then resolve the current registry on
|
|
1335
|
+
// use. A retained view must not revive one evicted while it was idle.
|
|
1336
|
+
this.admissionPersonal = scope.principalKey
|
|
1306
1337
|
? root.personalRegistry(scope.principalKey)
|
|
1307
1338
|
: undefined;
|
|
1308
1339
|
}
|
|
1309
|
-
registryFor(id) {
|
|
1340
|
+
registryFor(id, admission = false) {
|
|
1310
1341
|
if (!this.allowed.has(id))
|
|
1311
1342
|
return undefined;
|
|
1312
1343
|
const connector = this.root.getConnector(id);
|
|
1313
1344
|
if (!connector)
|
|
1314
1345
|
return undefined;
|
|
1315
|
-
|
|
1346
|
+
if (connector.authScope !== "personal")
|
|
1347
|
+
return this.root;
|
|
1348
|
+
return admission ? this.admissionPersonal : this.personal;
|
|
1316
1349
|
}
|
|
1317
1350
|
listConnectors() {
|
|
1318
1351
|
return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
|
|
@@ -1358,7 +1391,7 @@ class ScopedRegistryView {
|
|
|
1358
1391
|
return attachCaller(registry.contextFor(...args), this.scope.caller);
|
|
1359
1392
|
}
|
|
1360
1393
|
admitCall(...args) {
|
|
1361
|
-
const registry = this.registryFor(args[0]);
|
|
1394
|
+
const registry = this.registryFor(args[0], true);
|
|
1362
1395
|
if (!registry) {
|
|
1363
1396
|
return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
|
|
1364
1397
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
import { claimConnectorScopeCleanup, closeConnectorScope } from "../connector-scope.js";
|
|
2
3
|
import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape, } from "../credential-rules.js";
|
|
3
4
|
import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
|
|
4
5
|
import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
|
|
@@ -164,14 +165,21 @@ function credentialRequest(context, connectorId, action) {
|
|
|
164
165
|
}
|
|
165
166
|
const storedValues = values;
|
|
166
167
|
const ctx = registry.contextFor(connectorId, baseUrl);
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
168
|
+
const releaseCleanup = claimConnectorScopeCleanup(ctx, connectorId);
|
|
169
|
+
try {
|
|
170
|
+
const result = mode === "multiple"
|
|
171
|
+
? await connector.testCredentials(storedValues, ctx)
|
|
172
|
+
: await connector.testCredential(
|
|
173
|
+
// The single-value shape check above guarantees this key.
|
|
174
|
+
storedValues.value, ctx);
|
|
175
|
+
const ok = result?.ok === true;
|
|
176
|
+
logged(ok, result?.message);
|
|
177
|
+
return privateJson({ ok });
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
releaseCleanup();
|
|
181
|
+
await closeConnectorScope(connector, ctx, context.defer);
|
|
182
|
+
}
|
|
175
183
|
},
|
|
176
184
|
catch: (error) => error,
|
|
177
185
|
}).pipe(Effect.catch((error) => {
|
package/dist/routes/mcp.js
CHANGED
|
@@ -4,9 +4,8 @@ import { notFoundResponse } from "../branding.js";
|
|
|
4
4
|
import { registerExecuteTool } from "../execute.js";
|
|
5
5
|
import { ExecutorAdmissionError, } from "../executor-admission.js";
|
|
6
6
|
import { registerMetaTools } from "../meta-tools.js";
|
|
7
|
-
import { registerResumeTool } from "../resumable.js";
|
|
8
7
|
import { intersectAccess } from "../connector-access.js";
|
|
9
|
-
import {
|
|
8
|
+
import { CONNECTA_INSTRUCTIONS } from "../skills.js";
|
|
10
9
|
import { msg } from "../errors.js";
|
|
11
10
|
import { detach } from "../runtime/run.js";
|
|
12
11
|
import { authorize, loggableValue, mayManageConnector, validateAuthPermissions, } from "./shared.js";
|
|
@@ -218,19 +217,14 @@ function toolkitRetired(logger) {
|
|
|
218
217
|
},
|
|
219
218
|
});
|
|
220
219
|
}
|
|
221
|
-
function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canManageAuth,
|
|
222
|
-
// A paused run belongs to the endpoint it started on: resuming it through
|
|
223
|
-
// another pool's endpoint finds nothing, as another subject would.
|
|
224
|
-
const resumable = opts.resumable
|
|
225
|
-
? { ...opts.resumable, pool: poolName ?? null, approval: opts.approval }
|
|
226
|
-
: undefined;
|
|
220
|
+
function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canManageAuth, runtimeContext) {
|
|
227
221
|
// Every McpServer the request builds is fresh and closes with its scope.
|
|
228
222
|
// The modern handler tears its own down after the exchange; the legacy
|
|
229
223
|
// transport never does, and neither may stay wired to an ended request.
|
|
230
224
|
const servers = [];
|
|
231
225
|
const createServer = () => {
|
|
232
226
|
const server = new McpServer(opts.serverInfo, {
|
|
233
|
-
instructions:
|
|
227
|
+
instructions: CONNECTA_INSTRUCTIONS,
|
|
234
228
|
cacheHints: {
|
|
235
229
|
"tools/list": {
|
|
236
230
|
ttlMs: 3_600_000,
|
|
@@ -274,7 +268,7 @@ function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canMan
|
|
|
274
268
|
: {}),
|
|
275
269
|
approval: opts.approval,
|
|
276
270
|
});
|
|
277
|
-
|
|
271
|
+
registerExecuteTool(server, registry, {
|
|
278
272
|
baseUrl,
|
|
279
273
|
executor: opts.executor,
|
|
280
274
|
logger: opts.logger,
|
|
@@ -304,19 +298,9 @@ function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canMan
|
|
|
304
298
|
...(opts.watchdogMs !== undefined
|
|
305
299
|
? { watchdogMs: opts.watchdogMs }
|
|
306
300
|
: {}),
|
|
307
|
-
resumable,
|
|
308
301
|
approval: opts.approval,
|
|
309
302
|
maxWrites: opts.maxWrites,
|
|
310
303
|
});
|
|
311
|
-
// Always registered, so the surface is the same eight tools on every
|
|
312
|
-
// deployment; without resumable writes it answers that there is nothing
|
|
313
|
-
// to resume.
|
|
314
|
-
registerResumeTool(server, registry, {
|
|
315
|
-
runner,
|
|
316
|
-
settings: resumable,
|
|
317
|
-
...(activity ? { activity } : {}),
|
|
318
|
-
requestSignal,
|
|
319
|
-
});
|
|
320
304
|
servers.push(server);
|
|
321
305
|
return server;
|
|
322
306
|
};
|
|
@@ -527,7 +511,7 @@ export function createMcpRoute(opts) {
|
|
|
527
511
|
if (new URL(request.url).searchParams.has("toolkit")) {
|
|
528
512
|
return cors(toolkitRetired(opts.logger));
|
|
529
513
|
}
|
|
530
|
-
return cors(yield* serveMcp(localRequest, localAbort.signal, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); },
|
|
514
|
+
return cors(yield* serveMcp(localRequest, localAbort.signal, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext));
|
|
531
515
|
});
|
|
532
516
|
if (remainingMs === undefined)
|
|
533
517
|
return yield* handled;
|
package/dist/routes/oauth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
import { closeConnectorScope } from "../connector-scope.js";
|
|
2
3
|
import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
|
|
3
4
|
import { escapeHtml, renderPage, resolveBranding, STATUS_ICONS } from "../branding.js";
|
|
4
5
|
import { oauthCallbackOutcome, providerErrorReason, } from "../oauth-callback-outcome.js";
|
|
@@ -127,79 +128,84 @@ async function finishOAuthCallback(context) {
|
|
|
127
128
|
await equalizeRefusalCost(connectorContext);
|
|
128
129
|
return refused();
|
|
129
130
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
131
|
+
try {
|
|
132
|
+
const expectedPrincipalKey = callbackTarget?.principalKey;
|
|
133
|
+
// A browser returning from consent normally has no MCP Authorization
|
|
134
|
+
// header. An interactive bearer provider therefore answers 401 here; state
|
|
135
|
+
// and the saved state-to-principal handoff still prove ownership below.
|
|
136
|
+
// Rejecting 401 would break that callback. A 403 is an explicit denial.
|
|
137
|
+
const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.auth, "OAuth callback", context.runtimeContext, opts.identity);
|
|
138
|
+
if (browserIdentity.ok) {
|
|
139
|
+
try {
|
|
140
|
+
validateAuthPermissions(browserIdentity, opts.registry);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return refused();
|
|
144
|
+
}
|
|
145
|
+
if (!mayManageConnector(browserIdentity, connector) || (expectedPrincipalKey && browserIdentity.principalKey !== expectedPrincipalKey))
|
|
146
|
+
return refused();
|
|
139
147
|
}
|
|
140
|
-
|
|
148
|
+
else if (browserIdentity.response.status === 403 && opts.auth.some(provider => provider.interactiveOperator)) {
|
|
141
149
|
return refused();
|
|
142
150
|
}
|
|
143
|
-
|
|
151
|
+
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
152
|
+
// the `state` matches the flow connecta started BEFORE exchanging the code.
|
|
153
|
+
if (!connector.verifyState) {
|
|
154
|
+
await equalizeRefusalCost(connectorContext);
|
|
155
|
+
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
156
|
+
`${loggableValue(id)} with 400: it implements finishAuth but no ` +
|
|
157
|
+
"verifyState, so connecta cannot establish that it started this flow. " +
|
|
158
|
+
"No authorization code was exchanged. Implement verifyState before " +
|
|
159
|
+
"trying again.");
|
|
144
160
|
return refused();
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return refused();
|
|
148
|
-
}
|
|
149
|
-
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
150
|
-
// the `state` matches the flow connecta started BEFORE exchanging the code.
|
|
151
|
-
if (!connector.verifyState) {
|
|
152
|
-
await equalizeRefusalCost(connectorContext);
|
|
153
|
-
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
154
|
-
`${loggableValue(id)} with 400: it implements finishAuth but no ` +
|
|
155
|
-
"verifyState, so connecta cannot establish that it started this flow. " +
|
|
156
|
-
"No authorization code was exchanged. Implement verifyState before " +
|
|
157
|
-
"trying again.");
|
|
158
|
-
return refused();
|
|
159
|
-
}
|
|
160
|
-
let stateMatches;
|
|
161
|
-
try {
|
|
162
|
-
stateMatches = await connector.verifyState(state, connectorContext);
|
|
163
|
-
}
|
|
164
|
-
catch (err) {
|
|
165
|
-
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
166
|
-
`${loggableValue(id)} with 400: verifyState threw ` +
|
|
167
|
-
`${loggableValue(msg(err))}. No authorization code was exchanged. ` +
|
|
168
|
-
"Re-run authorization from connecta and check the verifier if it " +
|
|
169
|
-
"fails again.");
|
|
170
|
-
return refused();
|
|
171
|
-
}
|
|
172
|
-
if (!stateMatches) {
|
|
173
|
-
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
174
|
-
`${loggableValue(id)} with 400: ` +
|
|
175
|
-
(state === null
|
|
176
|
-
? "the state parameter was missing"
|
|
177
|
-
: "the state did not match the pending authorization flow") +
|
|
178
|
-
". No authorization code was exchanged. Re-run authorization from " +
|
|
179
|
-
"connecta and try again.");
|
|
180
|
-
return refused();
|
|
181
|
-
}
|
|
182
|
-
if (connector.authScope === "personal") {
|
|
161
|
+
}
|
|
162
|
+
let stateMatches;
|
|
183
163
|
try {
|
|
184
|
-
await
|
|
164
|
+
stateMatches = await connector.verifyState(state, connectorContext);
|
|
185
165
|
}
|
|
186
166
|
catch (err) {
|
|
187
167
|
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
188
|
-
`${loggableValue(id)} with
|
|
189
|
-
|
|
190
|
-
|
|
168
|
+
`${loggableValue(id)} with 400: verifyState threw ` +
|
|
169
|
+
`${loggableValue(msg(err))}. No authorization code was exchanged. ` +
|
|
170
|
+
"Re-run authorization from connecta and check the verifier if it " +
|
|
171
|
+
"fails again.");
|
|
172
|
+
return refused();
|
|
173
|
+
}
|
|
174
|
+
if (!stateMatches) {
|
|
175
|
+
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
176
|
+
`${loggableValue(id)} with 400: ` +
|
|
177
|
+
(state === null
|
|
178
|
+
? "the state parameter was missing"
|
|
179
|
+
: "the state did not match the pending authorization flow") +
|
|
180
|
+
". No authorization code was exchanged. Re-run authorization from " +
|
|
181
|
+
"connecta and try again.");
|
|
182
|
+
return refused();
|
|
183
|
+
}
|
|
184
|
+
if (connector.authScope === "personal") {
|
|
185
|
+
try {
|
|
186
|
+
await opts.registry.clearOAuthHandoff(id, state);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
190
|
+
`${loggableValue(id)} with 500: its principal handoff could not be ` +
|
|
191
|
+
`consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
|
|
192
|
+
return html("handoff_failed", opts, connector);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await connector.finishAuth(code, connectorContext, url.searchParams);
|
|
197
|
+
await callbackRegistry.invalidateStored(id);
|
|
198
|
+
return html("connected", opts, connector);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
// The page names the reason and nothing else; what the exchange threw can
|
|
202
|
+
// quote a token endpoint's body, so it goes only to the operator log.
|
|
203
|
+
opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
|
|
204
|
+
`with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
|
|
205
|
+
return html("exchange_failed", opts, connector);
|
|
191
206
|
}
|
|
192
207
|
}
|
|
193
|
-
|
|
194
|
-
await connector
|
|
195
|
-
await callbackRegistry.invalidateStored(id);
|
|
196
|
-
return html("connected", opts, connector);
|
|
197
|
-
}
|
|
198
|
-
catch (err) {
|
|
199
|
-
// The page names the reason and nothing else; what the exchange threw can
|
|
200
|
-
// quote a token endpoint's body, so it goes only to the operator log.
|
|
201
|
-
opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
|
|
202
|
-
`with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
|
|
203
|
-
return html("exchange_failed", opts, connector);
|
|
208
|
+
finally {
|
|
209
|
+
await closeConnectorScope(connector, connectorContext, context.defer);
|
|
204
210
|
}
|
|
205
211
|
}
|
package/dist/routes/shared.d.ts
CHANGED
|
@@ -41,14 +41,9 @@ export interface ServerOptions {
|
|
|
41
41
|
hostCallTimeoutMs?: number | undefined;
|
|
42
42
|
/** Hard ceiling on one execute_code run, outside the sandbox. Default 120_000. */
|
|
43
43
|
watchdogMs?: number | undefined;
|
|
44
|
-
/** Resumable writes' settings; absent when they are off. */
|
|
45
|
-
resumable?: {
|
|
46
|
-
maxWrites: number;
|
|
47
|
-
ttlSeconds: number;
|
|
48
|
-
} | undefined;
|
|
49
44
|
/** Config approval exemptions (`execute.approval`), resolved at construction. */
|
|
50
45
|
approval?: ApprovalPolicy | undefined;
|
|
51
|
-
/**
|
|
46
|
+
/** Exempt writes one program may send (`execute.maxWrites`). */
|
|
52
47
|
maxWrites?: number | undefined;
|
|
53
48
|
/** Required sandbox backing the execute_code meta-tool. */
|
|
54
49
|
executor: Executor;
|
package/dist/runtime/run.js
CHANGED
|
@@ -34,7 +34,9 @@ function setMicrotask(task) {
|
|
|
34
34
|
export const microtaskScheduler = new Scheduler.MixedScheduler("async", setMicrotask);
|
|
35
35
|
const runExit = Effect.runPromiseExitWith(Context.make(Scheduler.Scheduler, microtaskScheduler));
|
|
36
36
|
function abortReason(signal) {
|
|
37
|
-
return signal
|
|
37
|
+
return signal && signal.reason !== undefined
|
|
38
|
+
? signal.reason
|
|
39
|
+
: new DOMException("aborted", "AbortError");
|
|
38
40
|
}
|
|
39
41
|
/**
|
|
40
42
|
* Turn a failed Exit back into the value a Promise caller would have seen.
|
|
@@ -136,6 +138,10 @@ export function fromSignal(signal) {
|
|
|
136
138
|
*/
|
|
137
139
|
export function withDeadlineEffect(operation, options) {
|
|
138
140
|
return Effect.suspend(() => {
|
|
141
|
+
// Do not dispatch work that already lost its caller. The operation is the
|
|
142
|
+
// first race contender and can finish before the abort contender starts.
|
|
143
|
+
if (options.signal?.aborted)
|
|
144
|
+
return Effect.fail(abortReason(options.signal));
|
|
139
145
|
const controller = new AbortController();
|
|
140
146
|
const forwardAbort = () => controller.abort(options.signal?.reason);
|
|
141
147
|
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|