@zackbart/connecta 0.39.0 → 0.41.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 +115 -0
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/activity.d.ts +30 -4
- package/dist/activity.js +28 -2
- package/dist/agent-observation.js +24 -2
- package/dist/agent-routes.js +4 -41
- package/dist/auth/clerk.d.ts +6 -1
- package/dist/auth/clerk.js +16 -0
- package/dist/auth/cloudflare-access.js +49 -7
- package/dist/auth/downstream-oauth/consent.js +79 -29
- package/dist/auth/downstream-oauth/grant-store.js +58 -7
- package/dist/auth/downstream-oauth/refresh-records.js +6 -5
- package/dist/auth/downstream-oauth.js +12 -4
- package/dist/auth/native.js +10 -0
- package/dist/auth/oauth-request-not-sent.js +4 -0
- package/dist/auth/static-oauth.js +51 -21
- package/dist/catalog-cache.js +151 -37
- package/dist/catalog-freshness.js +13 -0
- package/dist/catalog-service.js +39 -12
- package/dist/catalog.js +103 -38
- package/dist/config.d.ts +10 -4
- package/dist/config.js +30 -7
- package/dist/connectors/remote-mcp/call.js +1 -1
- package/dist/connectors/remote-mcp/connection.js +52 -7
- package/dist/connectors/remote-mcp/oauth.js +6 -2
- package/dist/connectors/remote-mcp/resources.js +5 -2
- package/dist/connectors/remote-mcp/transport.js +37 -8
- package/dist/connectors/remote-mcp.js +8 -5
- package/dist/credential-contract.d.ts +3 -1
- package/dist/describe-config.d.ts +1 -0
- package/dist/describe-config.js +1 -0
- package/dist/downstream-input.js +11 -2
- package/dist/errors.d.ts +8 -1
- package/dist/errors.js +2 -0
- package/dist/execute.js +20 -7
- package/dist/executor-result.js +1 -1
- package/dist/executors/quickjs-child.js +1 -1
- package/dist/executors/quickjs-runtime.js +1 -1
- package/dist/executors/quickjs.js +1 -1
- package/dist/guarded-transport.js +2 -0
- package/dist/http/client.js +7 -0
- package/dist/http/hatch.js +2 -0
- package/dist/http/node-transport.js +110 -4
- package/dist/http/oauth.js +17 -6
- package/dist/index.js +10 -21
- package/dist/invocation-failure.js +28 -0
- package/dist/invocation.js +77 -35
- package/dist/membership.d.ts +1 -1
- package/dist/meta-tools.js +58 -36
- package/dist/module-contracts.d.ts +3 -1
- package/dist/native-tools.js +12 -5
- package/dist/node.d.ts +7 -0
- package/dist/node.js +82 -3
- package/dist/operation-activity.js +103 -0
- package/dist/operator-record.d.ts +8 -0
- package/dist/operator-record.js +15 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/provider-call-facts.js +6 -2
- package/dist/provider.d.ts +1 -1
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/breeze/index.js +1 -1
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/cloudflare/index.js +1 -1
- package/dist/providers/docs/index.js +1 -1
- package/dist/providers/drive/index.js +1 -1
- package/dist/providers/forms/index.js +1 -1
- package/dist/providers/github/index.js +1 -1
- package/dist/providers/gmail/index.js +1 -1
- package/dist/providers/granola/index.js +1 -1
- package/dist/providers/overflow/index.js +1 -1
- package/dist/providers/planning-center/index.js +1 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/quickbooks/api.js +28 -25
- package/dist/providers/quickbooks/index.js +1 -1
- package/dist/providers/quickbooks/lib/writes.js +55 -0
- package/dist/providers/quickbooks/safety.js +17 -5
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/index.js +1 -1
- package/dist/providers/slides/index.js +1 -1
- package/dist/providers/stripe/index.js +2 -2
- package/dist/providers/tithely/index.js +1 -1
- package/dist/providers/vercel/api.js +1 -0
- package/dist/registry.js +16 -4
- package/dist/resource-read.js +21 -2
- package/dist/route-names.js +44 -0
- package/dist/routes/activity-context.js +1 -0
- package/dist/routes/activity-disclosure.js +26 -4
- package/dist/routes/activity.js +2 -1
- package/dist/routes/mcp.js +32 -13
- package/dist/routes/ui-config.js +1 -1
- package/dist/routes/ui-data.js +3 -2
- package/dist/runtime/native-storage.js +15 -0
- package/dist/runtime/request-reads.js +87 -10
- package/dist/runtime/request-scope.js +2 -1
- package/dist/skill-origin.js +16 -0
- package/dist/skills.js +132 -43
- package/dist/storage/d1-driver.js +33 -5
- package/dist/storage/keys.js +7 -1
- package/dist/storage/sql-native.js +85 -3
- package/dist/storage/sql.js +6 -0
- package/dist/timing-trace.js +84 -0
- package/dist/types.d.ts +5 -0
- package/dist/url-safety.js +38 -20
- package/dist/utility-recovery.js +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +33 -8
- package/documentation/architecture.md +83 -2
- package/documentation/auth.md +132 -37
- package/documentation/code-mode.md +61 -46
- package/documentation/deploying.md +25 -1
- package/documentation/integrating.md +2 -2
- package/documentation/meta-tools.md +134 -15
- package/documentation/operating.md +86 -1
- package/documentation/operator-ui.md +51 -2
- package/documentation/providers.md +16 -14
- package/package.json +10 -8
- package/templates/node/package.json +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// The plain runtime slot keeps the HTTP kit and Node sender off the root
|
|
2
2
|
// and Worker graphs. /node installs its sender when listen() starts serving.
|
|
3
3
|
import { ConnectorCallError } from "./errors.js";
|
|
4
|
+
/** Internal request marker: only downstream-learned URL fetches set this. */
|
|
5
|
+
export const learnedDestination = Symbol("connecta.learnedDestination");
|
|
4
6
|
let owned;
|
|
5
7
|
/** Runtime configuration, never request data. Returns the previous slot for isolated fixtures. */
|
|
6
8
|
export function configureGuardedTransport(transport) {
|
package/dist/http/client.js
CHANGED
|
@@ -30,6 +30,12 @@ import { RedirectRefused, Refusal, RequestRefused, ResendRefused, ResponseTooLar
|
|
|
30
30
|
import { Classification, Dispatches, IdempotencyKey, Ledger, RequestFacts, Secrets, Signal, WriteCapacity, reserveWrite, writeRequestKey, } from "./services.js";
|
|
31
31
|
const decoder = new TextDecoder();
|
|
32
32
|
const responseEntries = new WeakMap();
|
|
33
|
+
// Private request identity for middleware observing a rejected response. Never
|
|
34
|
+
// attach headers or body to the failure or its payload-free ledger entry.
|
|
35
|
+
const failedRequests = new WeakMap();
|
|
36
|
+
export function failedResponseRequest(failure) {
|
|
37
|
+
return failedRequests.get(failure);
|
|
38
|
+
}
|
|
33
39
|
/** The core's physical request, also available to direct HttpClient middleware. */
|
|
34
40
|
export function responseRequest(response) {
|
|
35
41
|
return responseEntries.get(response);
|
|
@@ -216,6 +222,7 @@ export function guardedClient(options) {
|
|
|
216
222
|
const refused = (failure) => {
|
|
217
223
|
dispatch.failure = failure;
|
|
218
224
|
responseEntries.set(failure, entry);
|
|
225
|
+
failedRequests.set(failure, request);
|
|
219
226
|
return Effect.fail(failure);
|
|
220
227
|
};
|
|
221
228
|
const response = yield* Effect.tryPromise({
|
package/dist/http/hatch.js
CHANGED
|
@@ -1171,6 +1171,8 @@ export function hatch(options) {
|
|
|
1171
1171
|
const send = options.binding
|
|
1172
1172
|
? options.binding.send(call(), sendOptions)
|
|
1173
1173
|
: connection.sendPath(request, sendOptions);
|
|
1174
|
+
// Both send paths share the binding result contract. Keep its optional
|
|
1175
|
+
// metadata typed even when the plain HTTP branch omits those properties.
|
|
1174
1176
|
const answer = yield* send.pipe(Effect.catch((failure) => Effect.gen(function* () {
|
|
1175
1177
|
if (!key || args.idempotencyKey)
|
|
1176
1178
|
return yield* Effect.fail(failure);
|
|
@@ -1,9 +1,68 @@
|
|
|
1
1
|
// Node-only: owned undici dispatcher and Node stream adapters, reached via /node.
|
|
2
|
+
import { Resolver } from "node:dns/promises";
|
|
3
|
+
import { learnedDestination } from "../guarded-transport.js";
|
|
4
|
+
import { learnedUrlRefusal } from "../url-safety.js";
|
|
5
|
+
import { OAuthRequestNotSentError } from "../auth/oauth-request-not-sent.js";
|
|
2
6
|
import { Readable } from "node:stream";
|
|
3
7
|
import { isIP } from "node:net";
|
|
4
8
|
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
|
|
5
9
|
import { Client, ProxyAgent } from "undici";
|
|
6
10
|
import { ConnectorCallError } from "../errors.js";
|
|
11
|
+
/** Each lookup owns its DNS channels; cancellation closes them before returning. */
|
|
12
|
+
async function resolveAddresses(hostname, signal) {
|
|
13
|
+
const resolver = new Resolver();
|
|
14
|
+
const cancel = () => resolver.cancel();
|
|
15
|
+
signal.throwIfAborted();
|
|
16
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
17
|
+
try {
|
|
18
|
+
const results = await Promise.allSettled([resolver.resolve4(hostname), resolver.resolve6(hostname)]);
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
for (const result of results) {
|
|
21
|
+
if (result.status === "rejected" && !["ENODATA", "ENOTFOUND"].includes(result.reason?.code))
|
|
22
|
+
throw result.reason;
|
|
23
|
+
}
|
|
24
|
+
return results.flatMap((result, index) => result.status === "fulfilled" ? result.value.map((address) => ({ address, family: index === 0 ? 4 : 6 })) : []);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
signal.removeEventListener("abort", cancel);
|
|
28
|
+
resolver.cancel();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Observe even a misbehaving injected resolver, without holding the request open. */
|
|
32
|
+
async function boundedLookup(lookup, hostname, requestSignal) {
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const abort = () => controller.abort(requestSignal?.reason);
|
|
35
|
+
const timer = setTimeout(() => controller.abort(new Error("DNS resolution timed out.")), 10_000);
|
|
36
|
+
requestSignal?.addEventListener("abort", abort, { once: true });
|
|
37
|
+
let onAbort;
|
|
38
|
+
let resolution;
|
|
39
|
+
try {
|
|
40
|
+
if (requestSignal?.aborted)
|
|
41
|
+
abort();
|
|
42
|
+
controller.signal.throwIfAborted();
|
|
43
|
+
resolution = lookup(hostname, controller.signal);
|
|
44
|
+
return await Promise.race([
|
|
45
|
+
resolution,
|
|
46
|
+
new Promise((_, reject) => {
|
|
47
|
+
onAbort = () => reject(controller.signal.reason);
|
|
48
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
49
|
+
if (controller.signal.aborted)
|
|
50
|
+
onAbort();
|
|
51
|
+
}),
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
requestSignal?.removeEventListener("abort", abort);
|
|
57
|
+
if (onAbort)
|
|
58
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
59
|
+
controller.abort();
|
|
60
|
+
// Drain the owned resolver after cancelling its channels (INV-7). Test
|
|
61
|
+
// injections may ignore cancellation, but cannot hold send() open.
|
|
62
|
+
if (lookup === resolveAddresses)
|
|
63
|
+
await resolution?.catch(() => { });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
7
66
|
/** Pull-based bridge: Node 22's Readable.toWeb can enqueue after Web cancellation. */
|
|
8
67
|
function responseBody(body, signal) {
|
|
9
68
|
const chunks = body[Symbol.asyncIterator]();
|
|
@@ -43,7 +102,7 @@ function responseBody(body, signal) {
|
|
|
43
102
|
* HTTP/2 REFUSED_STREAM and pipeline recovery paths, for every method.
|
|
44
103
|
* The response/abort owns the client until the body ends or is cancelled.
|
|
45
104
|
*/
|
|
46
|
-
export function createNodeGuardedTransport(proxy) {
|
|
105
|
+
export function createNodeGuardedTransport(proxy, lookup = resolveAddresses) {
|
|
47
106
|
let proxyUri;
|
|
48
107
|
let proxyToken;
|
|
49
108
|
if (proxy !== undefined) {
|
|
@@ -68,7 +127,41 @@ export function createNodeGuardedTransport(proxy) {
|
|
|
68
127
|
return {
|
|
69
128
|
delaysDispatch: true,
|
|
70
129
|
async send(url, init, beforeDispatch = () => { }) {
|
|
130
|
+
const configured = init[learnedDestination];
|
|
131
|
+
let pinned;
|
|
132
|
+
if (configured && url.origin !== configured.origin) {
|
|
133
|
+
const refuse = () => new OAuthRequestNotSentError("connector_call_failed", "The learned URL resolved to an unsafe destination; nothing was sent.", { retryable: false });
|
|
134
|
+
if (learnedUrlRefusal(configured, url))
|
|
135
|
+
throw refuse();
|
|
136
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
137
|
+
let addresses;
|
|
138
|
+
try {
|
|
139
|
+
addresses = isIP(hostname)
|
|
140
|
+
? [{ address: hostname, family: isIP(hostname) }]
|
|
141
|
+
: await boundedLookup(lookup, hostname, init.signal);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
init.signal?.throwIfAborted();
|
|
145
|
+
throw refuse();
|
|
146
|
+
}
|
|
147
|
+
init.signal?.throwIfAborted();
|
|
148
|
+
if (addresses.length === 0 ||
|
|
149
|
+
addresses.some(({ address, family }) => {
|
|
150
|
+
if (!isIP(address) || isIP(address) !== family)
|
|
151
|
+
return true;
|
|
152
|
+
const resolved = new URL(url);
|
|
153
|
+
resolved.hostname = family === 6 ? `[${address}]` : address;
|
|
154
|
+
return learnedUrlRefusal(configured, resolved) !== undefined;
|
|
155
|
+
}))
|
|
156
|
+
throw refuse();
|
|
157
|
+
pinned = addresses[0];
|
|
158
|
+
}
|
|
159
|
+
const destination = new URL(url);
|
|
160
|
+
if (pinned && proxyUri)
|
|
161
|
+
destination.hostname = pinned.family === 6 ? `[${pinned.address}]` : pinned.address;
|
|
71
162
|
const framed = new Request(url, init);
|
|
163
|
+
if (pinned)
|
|
164
|
+
framed.headers.set("host", url.host);
|
|
72
165
|
if (!framed.headers.has("user-agent"))
|
|
73
166
|
framed.headers.set("user-agent", "connecta");
|
|
74
167
|
let dispatched = false;
|
|
@@ -86,6 +179,7 @@ export function createNodeGuardedTransport(proxy) {
|
|
|
86
179
|
uri: proxyUri,
|
|
87
180
|
...(proxyToken ? { token: proxyToken } : {}),
|
|
88
181
|
proxyTunnel: true,
|
|
182
|
+
...(pinned ? { requestTls: { servername: isIP(url.hostname) ? "" : url.hostname } } : {}),
|
|
89
183
|
...clientOptions,
|
|
90
184
|
interceptors: { Agent: [], Client: [] },
|
|
91
185
|
// Neither side of CONNECT uses a Pool, interceptor or global dispatcher.
|
|
@@ -99,7 +193,7 @@ export function createNodeGuardedTransport(proxy) {
|
|
|
99
193
|
// ProxyAgent 6 explicitly sets SNI to the proxy hostname.
|
|
100
194
|
// Node 26 rejects IP SNI. Omit it for literal IPs; tls.connect
|
|
101
195
|
// still checks the certificate against its connection host.
|
|
102
|
-
connect({ ...parameters, ...(name
|
|
196
|
+
connect({ ...parameters, ...(name ? { servername: isIP(name.replace(/^\[|\]$/g, "")) ? "" : name } : {}) }, callback);
|
|
103
197
|
},
|
|
104
198
|
}));
|
|
105
199
|
},
|
|
@@ -135,7 +229,19 @@ export function createNodeGuardedTransport(proxy) {
|
|
|
135
229
|
});
|
|
136
230
|
},
|
|
137
231
|
})
|
|
138
|
-
: new Client(url.origin,
|
|
232
|
+
: new Client(url.origin, {
|
|
233
|
+
...clientOptions,
|
|
234
|
+
...(pinned
|
|
235
|
+
? {
|
|
236
|
+
connect: {
|
|
237
|
+
// No second resolver can replace the checked address. The Client
|
|
238
|
+
// retains the URL hostname for SNI and certificate verification.
|
|
239
|
+
lookup: (_hostname, _options, callback) => callback(null, pinned.address, pinned.family),
|
|
240
|
+
autoSelectFamily: false,
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
: {}),
|
|
244
|
+
});
|
|
139
245
|
const destroy = () => Promise.allSettled([tunnelClient?.destroy(), client.destroy()]);
|
|
140
246
|
const abort = () => {
|
|
141
247
|
void destroy();
|
|
@@ -146,7 +252,7 @@ export function createNodeGuardedTransport(proxy) {
|
|
|
146
252
|
if (!proxyUri)
|
|
147
253
|
dispatch();
|
|
148
254
|
const answer = await client.request({
|
|
149
|
-
origin:
|
|
255
|
+
origin: destination.origin,
|
|
150
256
|
path: `${url.pathname}${url.search}`,
|
|
151
257
|
method: framed.method,
|
|
152
258
|
headers: Object.fromEntries(framed.headers),
|
package/dist/http/oauth.js
CHANGED
|
@@ -30,7 +30,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse";
|
|
|
30
30
|
import { readRequest, renewedAfterUnauthorized } from "../provider-call.js";
|
|
31
31
|
import { renewableGrantFor } from "../auth/renewable-grant.js";
|
|
32
32
|
import { ConnectorCallError } from "../errors.js";
|
|
33
|
-
import { responseRequest } from "./client.js";
|
|
33
|
+
import { failedResponseRequest, responseRequest } from "./client.js";
|
|
34
34
|
import { Refusal, RequestRefused } from "./errors.js";
|
|
35
35
|
import { Dispatches, Signal, duringCall } from "./services.js";
|
|
36
36
|
/** The calling owner's renewable token, as the operations a transport needs from it. */
|
|
@@ -53,6 +53,9 @@ export function grantFromOAuth(oauth) {
|
|
|
53
53
|
return {
|
|
54
54
|
token: use(grant ? () => grant.token() : missing),
|
|
55
55
|
refresh: (stale) => use(grant ? () => grant.refresh(stale) : missing),
|
|
56
|
+
...(grant?.observe
|
|
57
|
+
? { observe: (token, outcome) => use(() => grant.observe(token, outcome)) }
|
|
58
|
+
: {}),
|
|
56
59
|
rejected: use(grant ? () => grant.rejected() : missing),
|
|
57
60
|
};
|
|
58
61
|
}
|
|
@@ -80,12 +83,21 @@ export function refreshOnce(guarded, options) {
|
|
|
80
83
|
return yield* Effect.fail(refusal);
|
|
81
84
|
});
|
|
82
85
|
const execute = (request) => Effect.gen(function* () {
|
|
83
|
-
const
|
|
86
|
+
const grant = yield* Grant;
|
|
87
|
+
const observeOversized = (failure) => {
|
|
88
|
+
const sent = failedResponseRequest(failure);
|
|
89
|
+
return responseRequest(failure)?.status === 401 && sent && grant.observe
|
|
90
|
+
? grant.observe(sentToken(sent), "refused")
|
|
91
|
+
: Effect.void;
|
|
92
|
+
};
|
|
93
|
+
const response = yield* inner.execute(request).pipe(Effect.tapErrorTag("ResponseTooLarge", observeOversized), Effect.catchTag("ResponseTooLarge", (failure) => {
|
|
84
94
|
const entry = responseRequest(failure);
|
|
85
95
|
return options?.challengeMaxBytes !== undefined && entry?.status === 401
|
|
86
96
|
? challengeFailure(entry)
|
|
87
97
|
: Effect.fail(failure);
|
|
88
98
|
}));
|
|
99
|
+
if (grant.observe && (response.status === 401 || (response.status >= 200 && response.status < 300)))
|
|
100
|
+
yield* grant.observe(sentToken(response.request), response.status === 401 ? "refused" : "accepted");
|
|
89
101
|
if (response.status !== 401)
|
|
90
102
|
return response;
|
|
91
103
|
// Read under the ceiling now, so nothing is left open while renewing.
|
|
@@ -94,7 +106,6 @@ export function refreshOnce(guarded, options) {
|
|
|
94
106
|
.pipe(Effect.catchTag("ResponseTooLarge", (failure) => options?.challengeMaxBytes !== undefined
|
|
95
107
|
? challengeFailure(responseRequest(response))
|
|
96
108
|
: Effect.fail(failure)));
|
|
97
|
-
const grant = yield* Grant;
|
|
98
109
|
const stale = sentToken(response.request);
|
|
99
110
|
const entry = responseRequest(response);
|
|
100
111
|
if (!readRequest(entry ?? {}) && options?.writeRenewal === "discard") {
|
|
@@ -135,11 +146,11 @@ export function refreshOnce(guarded, options) {
|
|
|
135
146
|
return yield* Effect.fail(new RequestRefused({ message: "This call has ended; no renewal work may continue." }));
|
|
136
147
|
renewedAfterUnauthorized(entry, stale, fresh);
|
|
137
148
|
// `authenticate` runs again on the replay and reads the renewed token.
|
|
138
|
-
const replay = yield* inner
|
|
139
|
-
.execute(request)
|
|
140
|
-
.pipe(Effect.catchTag("ResponseTooLarge", (failure) => options?.challengeMaxBytes !== undefined && responseRequest(failure)?.status === 401
|
|
149
|
+
const replay = yield* inner.execute(request).pipe(Effect.tapErrorTag("ResponseTooLarge", observeOversized), Effect.catchTag("ResponseTooLarge", (failure) => options?.challengeMaxBytes !== undefined && responseRequest(failure)?.status === 401
|
|
141
150
|
? grant.rejected
|
|
142
151
|
: Effect.fail(failure)));
|
|
152
|
+
if (grant.observe && (replay.status === 401 || (replay.status >= 200 && replay.status < 300)))
|
|
153
|
+
yield* grant.observe(sentToken(replay.request), replay.status === 401 ? "refused" : "accepted");
|
|
143
154
|
if (replay.status !== 401)
|
|
144
155
|
return replay;
|
|
145
156
|
if (options?.replayRejection === "answer") {
|
package/dist/index.js
CHANGED
|
@@ -92,14 +92,16 @@ function warnInsecureConfig(config) {
|
|
|
92
92
|
: "") +
|
|
93
93
|
"Configure Clerk, Cloudflare Access on Workers, or accessTokens(storage) to gate access.");
|
|
94
94
|
}
|
|
95
|
-
// Unset publicUrl
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
"
|
|
95
|
+
// Unset publicUrl: everything built from the deployment's own URL follows the
|
|
96
|
+
// inbound request, whose Host (and X-Forwarded-Proto on Node) the caller
|
|
97
|
+
// chooses. Clerk without any publicUrl has already refused construction.
|
|
98
|
+
if (!config.publicUrl && (oauthConnectors.length > 0 || inboundAuth.length > 0)) {
|
|
99
|
+
logger.warn("[connecta] publicUrl is unset: the URLs this deployment hands out, including " +
|
|
100
|
+
"protected-resource metadata, authorization handoffs and the downstream OAuth " +
|
|
101
|
+
"redirect_uri, are derived per-request from the inbound Host header (and " +
|
|
102
|
+
"X-Forwarded-Proto on Node). A caller who controls those can point them at " +
|
|
103
|
+
"their own host and, with OAuth connectors, capture the authorization code. " +
|
|
104
|
+
"Set `publicUrl` to a fixed https origin.");
|
|
103
105
|
}
|
|
104
106
|
// Branding URLs that failed their scheme gate. Rendering silently falls back
|
|
105
107
|
// (a bad URL must not take the page down), so this warning is the only way an
|
|
@@ -156,19 +158,6 @@ function warnInsecureConfig(config) {
|
|
|
156
158
|
`action and POST /ui/credentials/${connector.id}/test answers 400 ` +
|
|
157
159
|
"until the matching hook is implemented.");
|
|
158
160
|
}
|
|
159
|
-
// A vault written before seal/open existed encrypts credentials but cannot
|
|
160
|
-
// seal downstream OAuth state, so tokens stay plaintext at rest beside them.
|
|
161
|
-
const vault = config.vault;
|
|
162
|
-
if (vault && (typeof vault.seal !== "function" || typeof vault.open !== "function")) {
|
|
163
|
-
for (const connector of config.connectors) {
|
|
164
|
-
if (!connector.startAuth)
|
|
165
|
-
continue;
|
|
166
|
-
logger.warn(`[connecta] connector "${connector.id}" stores its downstream OAuth ` +
|
|
167
|
-
"tokens, client registration, and PKCE verifier as plaintext: the " +
|
|
168
|
-
"configured vault implements no `seal`/`open`. Use " +
|
|
169
|
-
"encryptedCredentialVault(...) or add both methods to seal them.");
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
161
|
// OAuth connectors whose callback cannot perform a state/CSRF check. The
|
|
173
162
|
// public route refuses every callback for these connectors rather than hand
|
|
174
163
|
// an unverified code to finishAuth, so this warning explains why auth cannot
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { keepErrorName } from "./redaction-identity.js";
|
|
2
|
+
export class InvocationFailure extends Error {
|
|
3
|
+
details;
|
|
4
|
+
code;
|
|
5
|
+
retryable;
|
|
6
|
+
retryAfterMs;
|
|
7
|
+
connector;
|
|
8
|
+
operation;
|
|
9
|
+
recovery;
|
|
10
|
+
nextAction;
|
|
11
|
+
retry;
|
|
12
|
+
data;
|
|
13
|
+
constructor(details) {
|
|
14
|
+
super(details.message);
|
|
15
|
+
this.details = details;
|
|
16
|
+
this.name = "InvocationFailure";
|
|
17
|
+
this.code = details.code;
|
|
18
|
+
this.retryable = details.retryable;
|
|
19
|
+
this.retryAfterMs = details.retryAfterMs;
|
|
20
|
+
this.connector = details.connector;
|
|
21
|
+
this.operation = details.operation;
|
|
22
|
+
this.recovery = details.recovery;
|
|
23
|
+
this.nextAction = details.nextAction;
|
|
24
|
+
this.retry = details.retry;
|
|
25
|
+
this.data = details;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
keepErrorName(InvocationFailure, "InvocationFailure");
|
package/dist/invocation.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { utilityRecovery } from "./utility-recovery.js";
|
|
2
|
+
import { traceFor } from "./timing-trace.js";
|
|
1
3
|
import { routePhrases } from "./agent-routes.js";
|
|
2
4
|
import { authCall, registerAuthMethod } from "./auth/native.js";
|
|
3
5
|
import { withAuthorizationHandoffEffect } from "./authorization-handoff.js";
|
|
@@ -16,11 +18,10 @@ import { recoveryRecord, recordRecoveryFacts } from "./call-recovery.js";
|
|
|
16
18
|
import { callFromContext, contextView, viewServices, Credentials } from "./runtime/call.js";
|
|
17
19
|
import { chargeCallBudget } from "./runtime/call-admission.js";
|
|
18
20
|
import { callEntry } from "./runtime/call-entry.js";
|
|
19
|
-
import { forgetProviderFailure, isKnownProviderFailure, isSettledWrite, keyProtectsCall, knownCallFailure, onlyAuxiliaryRequests, knownProviderFailure, openLogicalCall, } from "./provider-call.js";
|
|
21
|
+
import { forgetProviderFailure, isKnownProviderFailure, isSettledWrite, keyProtectsCall, knownCallFailure, refusalRequest, onlyAuxiliaryRequests, knownProviderFailure, openLogicalCall, } from "./provider-call.js";
|
|
20
22
|
import { classifyWriteOutcome, settleWrite, settleWriteResult } from "./write-outcome.js";
|
|
21
23
|
import { classificationDigest, recordAuthFailure, recordCallEntry, replayClassificationDigest, resolveInvocationAuthEffect, } from "./invocation-auth.js";
|
|
22
24
|
import { downstreamContinuation, recordDownstreamArgumentEcho, isDownstreamInputResult, assertDownstreamOutputSafe, } from "./downstream-input-context.js";
|
|
23
|
-
import { keepErrorName } from "./redaction-identity.js";
|
|
24
25
|
function defined(values) {
|
|
25
26
|
return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
|
|
26
27
|
}
|
|
@@ -115,33 +116,7 @@ function answeredFailure(error, write, call) {
|
|
|
115
116
|
return error instanceof DownstreamToolError || isKnownProviderFailure(error);
|
|
116
117
|
return knownCallFailure(call, error);
|
|
117
118
|
}
|
|
118
|
-
export
|
|
119
|
-
details;
|
|
120
|
-
code;
|
|
121
|
-
retryable;
|
|
122
|
-
retryAfterMs;
|
|
123
|
-
connector;
|
|
124
|
-
operation;
|
|
125
|
-
recovery;
|
|
126
|
-
nextAction;
|
|
127
|
-
retry;
|
|
128
|
-
data;
|
|
129
|
-
constructor(details) {
|
|
130
|
-
super(details.message);
|
|
131
|
-
this.details = details;
|
|
132
|
-
this.name = "InvocationFailure";
|
|
133
|
-
this.code = details.code;
|
|
134
|
-
this.retryable = details.retryable;
|
|
135
|
-
this.retryAfterMs = details.retryAfterMs;
|
|
136
|
-
this.connector = details.connector;
|
|
137
|
-
this.operation = details.operation;
|
|
138
|
-
this.recovery = details.recovery;
|
|
139
|
-
this.nextAction = details.nextAction;
|
|
140
|
-
this.retry = details.retry;
|
|
141
|
-
this.data = details;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
keepErrorName(InvocationFailure, "InvocationFailure");
|
|
119
|
+
export { InvocationFailure } from "./invocation-failure.js";
|
|
145
120
|
const withoutExample = ({ example: _example, ...stated }) => stated;
|
|
146
121
|
/**
|
|
147
122
|
* Shared downstream invocation engine. Address/catalog resolution is delegated
|
|
@@ -244,7 +219,9 @@ export class InvocationService {
|
|
|
244
219
|
};
|
|
245
220
|
const enrich = (error, target, settled) => {
|
|
246
221
|
if (!target)
|
|
247
|
-
return error
|
|
222
|
+
return ["unavailable", "catalog_lookup_failed", "unsupported_capability"].includes(error.code)
|
|
223
|
+
? { ...error, nextAction: error.nextAction ?? utilityRecovery(error.code, error.message).nextAction }
|
|
224
|
+
: error;
|
|
248
225
|
const recovery = recoveryState.facts;
|
|
249
226
|
const enteredWrite = dispatchedToConnector &&
|
|
250
227
|
((resolved !== undefined && resolved.definition.classification !== "read") || sentSourceWrite());
|
|
@@ -288,10 +265,13 @@ export class InvocationService {
|
|
|
288
265
|
}
|
|
289
266
|
if (mayHaveWritten &&
|
|
290
267
|
(error.code === "auth_required" || error.code === "downstream_oauth_required") &&
|
|
291
|
-
!logicalCall?.sent.some((request) => request.dispatched)
|
|
268
|
+
!logicalCall?.sent.some((request) => request.dispatched) &&
|
|
269
|
+
!(answered && refusalRequest(error)?.dispatched === false)) {
|
|
292
270
|
// An empty resource ledger cannot settle this auth failure. Keep
|
|
293
271
|
// actual resource-refusal provenance, but withdraw an empty ledger's
|
|
294
272
|
// known mark before composing advice and counting program writes.
|
|
273
|
+
// A boundary-owned physical pre-dispatch refusal is positive proof;
|
|
274
|
+
// its private provenance cannot be supplied by error fields.
|
|
295
275
|
answered = false;
|
|
296
276
|
forgetProviderFailure(error);
|
|
297
277
|
}
|
|
@@ -300,7 +280,16 @@ export class InvocationService {
|
|
|
300
280
|
// retryable, and no advice below may tell the agent to retry it.
|
|
301
281
|
const unknownWrite = mayHaveWritten && classifyWriteOutcome({ ok: false, dispatched: true, answered, error }) === "unknown";
|
|
302
282
|
if (unknownWrite)
|
|
303
|
-
error = {
|
|
283
|
+
error = {
|
|
284
|
+
...error,
|
|
285
|
+
retryable: false,
|
|
286
|
+
writeOutcome: "unknown",
|
|
287
|
+
...(!recovery && error.retry === undefined
|
|
288
|
+
? {
|
|
289
|
+
retry: "No safe replay action exists. Re-read the target to reconcile whether the write took effect.",
|
|
290
|
+
}
|
|
291
|
+
: {}),
|
|
292
|
+
};
|
|
304
293
|
// The failure's advice to send it again was kept apart until now, and
|
|
305
294
|
// joins its message only where the outcome allows it: never on a write
|
|
306
295
|
// whose outcome is unknown, whatever redaction did to the message.
|
|
@@ -331,9 +320,22 @@ export class InvocationService {
|
|
|
331
320
|
error = { ...error, code: "downstream_oauth_required" };
|
|
332
321
|
}
|
|
333
322
|
switch (error.code) {
|
|
323
|
+
case "unsupported_capability":
|
|
324
|
+
return unknownWrite
|
|
325
|
+
? error
|
|
326
|
+
: {
|
|
327
|
+
...error,
|
|
328
|
+
nextAction: utilityRecovery(error.code, "The requested capability is unsupported.").nextAction,
|
|
329
|
+
};
|
|
334
330
|
case "input_required_unsupported":
|
|
335
331
|
if (context.processInputRequired)
|
|
336
|
-
return
|
|
332
|
+
return unknownWrite
|
|
333
|
+
? error
|
|
334
|
+
: {
|
|
335
|
+
...error,
|
|
336
|
+
nextAction: error.nextAction ??
|
|
337
|
+
utilityRecovery(error.code, "The requested input capability is unsupported.").nextAction,
|
|
338
|
+
};
|
|
337
339
|
// Resending could apply the write twice: reconcile first.
|
|
338
340
|
if (unknownWrite)
|
|
339
341
|
return {
|
|
@@ -404,8 +406,13 @@ export class InvocationService {
|
|
|
404
406
|
retry: "Ask the provider's resource owner or administrator to grant the required permission or scope, then retry. Repeating the call or reconnecting alone will not grant access.",
|
|
405
407
|
};
|
|
406
408
|
case "invalid_args":
|
|
407
|
-
if (
|
|
409
|
+
if (error.writeOutcome === "unknown")
|
|
408
410
|
return error;
|
|
411
|
+
if (!error.validation)
|
|
412
|
+
return {
|
|
413
|
+
...error,
|
|
414
|
+
nextAction: this.catalog.searchRecovery({ query: target.toolName, connector: target.connector.id }, "Inspect the current input schema and correct the arguments before a fresh call."),
|
|
415
|
+
};
|
|
409
416
|
return {
|
|
410
417
|
...error,
|
|
411
418
|
// An example comes only from the published schema, never a connector's copy of the
|
|
@@ -424,6 +431,27 @@ export class InvocationService {
|
|
|
424
431
|
}, "Inspect the current input shape if the validation findings are not sufficient."),
|
|
425
432
|
retry: `Correct the listed arguments and retry ` + `${target.connector.id}.${target.toolName}.`,
|
|
426
433
|
};
|
|
434
|
+
case "not_found":
|
|
435
|
+
if (unknownWrite)
|
|
436
|
+
return error;
|
|
437
|
+
return {
|
|
438
|
+
...error,
|
|
439
|
+
retry: "No automatic retry action exists. Verify the resource id, account and visibility with a read before a fresh call.",
|
|
440
|
+
};
|
|
441
|
+
case "unavailable":
|
|
442
|
+
case "catalog_lookup_failed":
|
|
443
|
+
if (unknownWrite)
|
|
444
|
+
return error;
|
|
445
|
+
// The continuation nonce is spent. Let the input boundary supply
|
|
446
|
+
// its fresh-call action instead of generic availability advice.
|
|
447
|
+
if (context.source !== "execute_code" &&
|
|
448
|
+
downstreamContinuation(this.catalog.requestScope, target.connector.id, `${target.connector.id}.${target.toolName}`))
|
|
449
|
+
return error;
|
|
450
|
+
return {
|
|
451
|
+
...error,
|
|
452
|
+
nextAction: error.nextAction ?? utilityRecovery(error.code, error.message).nextAction,
|
|
453
|
+
retry: "No immediate repair action exists on this endpoint. Ask the operator to check connector availability; a read can be tried again after recovery.",
|
|
454
|
+
};
|
|
427
455
|
default:
|
|
428
456
|
return error;
|
|
429
457
|
}
|
|
@@ -443,6 +471,13 @@ export class InvocationService {
|
|
|
443
471
|
}
|
|
444
472
|
}
|
|
445
473
|
const diagnostics = timing();
|
|
474
|
+
const trace = traceFor(this.catalog.requestScope);
|
|
475
|
+
if (trace) {
|
|
476
|
+
trace.record("catalog", diagnostics.catalogMs);
|
|
477
|
+
trace.record("admission", diagnostics.admissionMs);
|
|
478
|
+
trace.record("connector", diagnostics.connectorMs);
|
|
479
|
+
trace.record("result", diagnostics.resultProcessingMs, resultBytes ?? 0);
|
|
480
|
+
}
|
|
446
481
|
const target = resolved ?? activityTarget;
|
|
447
482
|
if (target && (error.code === "auth_required" || error.code === "downstream_oauth_required")) {
|
|
448
483
|
recordAuthFailure(this.catalog.requestScope, target.connector.id, (resolved !== undefined && !isWriteLike(resolved.definition) && resolved.classificationFresh === true) ||
|
|
@@ -559,7 +594,7 @@ export class InvocationService {
|
|
|
559
594
|
const connectorReadOnly = target.connector.readOnly === true;
|
|
560
595
|
const refusal = writeRefusal(target.definition.classification, context.source, context.readOnly || connectorReadOnly);
|
|
561
596
|
if (refusal === "read_only_endpoint") {
|
|
562
|
-
return framingError("read_only_endpoint", `Tool "${canonicalAddress}" is a write, and ${connectorReadOnly ? `connector "${target.connector.id}"` : "this endpoint"} is read-only.
|
|
597
|
+
return framingError("read_only_endpoint", `Tool "${canonicalAddress}" is a write, and ${connectorReadOnly ? `connector "${target.connector.id}"` : "this endpoint"} is read-only. No write route exists ${connectorReadOnly ? "for this connection" : "on this endpoint"}. Ask the operator for a writable connection if writes are required.`);
|
|
563
598
|
}
|
|
564
599
|
if (refusal === "destructive_tool_requires_approval") {
|
|
565
600
|
return framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is a write. ${routePhrases(context).writeNeedsApproval}`);
|
|
@@ -804,6 +839,13 @@ export class InvocationService {
|
|
|
804
839
|
try {
|
|
805
840
|
const value = sentSecrets.redact(processed.value, "envelope");
|
|
806
841
|
const diagnostics = timing();
|
|
842
|
+
const trace = traceFor(this.catalog.requestScope);
|
|
843
|
+
if (trace) {
|
|
844
|
+
trace.record("catalog", diagnostics.catalogMs);
|
|
845
|
+
trace.record("admission", diagnostics.admissionMs);
|
|
846
|
+
trace.record("connector", diagnostics.connectorMs);
|
|
847
|
+
trace.record("result", diagnostics.resultProcessingMs, resultBytes ?? 0);
|
|
848
|
+
}
|
|
807
849
|
const friction = context.activityFriction?.(value);
|
|
808
850
|
record("success", friction ? { friction } : {});
|
|
809
851
|
return {
|
package/dist/membership.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface ResolvedPool {
|
|
|
26
26
|
members: readonly Member[];
|
|
27
27
|
/** Trimmed `owner`: the Workspace address agents in this pool act as. */
|
|
28
28
|
owner?: string;
|
|
29
|
-
/** Trimmed `description`,
|
|
29
|
+
/** Trimmed `description`, placed before this pool's MCP routing instructions. */
|
|
30
30
|
description?: string;
|
|
31
31
|
/** Present for a native pool; absent is the six meta-tools. */
|
|
32
32
|
native?: {
|