@zackbart/connecta 0.40.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 +87 -0
- package/bin/version.mjs +1 -1
- 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/oauth-request-not-sent.js +4 -0
- package/dist/auth/static-oauth.js +51 -21
- package/dist/catalog-cache.js +62 -21
- 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 -18
- package/dist/connectors/remote-mcp/oauth.js +2 -2
- package/dist/connectors/remote-mcp/resources.js +5 -2
- package/dist/connectors/remote-mcp/transport.js +24 -6
- package/dist/connectors/remote-mcp.js +4 -4
- 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 +72 -33
- package/dist/membership.d.ts +1 -1
- package/dist/meta-tools.js +27 -13
- package/dist/module-contracts.d.ts +3 -1
- package/dist/native-tools.js +11 -4
- 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.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 +12 -0
- package/dist/runtime/request-reads.js +66 -14
- 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 +60 -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 +48 -1
- package/documentation/auth.md +116 -36
- package/documentation/code-mode.md +61 -46
- package/documentation/deploying.md +25 -1
- package/documentation/meta-tools.md +113 -11
- 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,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";
|
|
@@ -20,7 +22,6 @@ import { forgetProviderFailure, isKnownProviderFailure, isSettledWrite, keyProte
|
|
|
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());
|
|
@@ -303,7 +280,16 @@ export class InvocationService {
|
|
|
303
280
|
// retryable, and no advice below may tell the agent to retry it.
|
|
304
281
|
const unknownWrite = mayHaveWritten && classifyWriteOutcome({ ok: false, dispatched: true, answered, error }) === "unknown";
|
|
305
282
|
if (unknownWrite)
|
|
306
|
-
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
|
+
};
|
|
307
293
|
// The failure's advice to send it again was kept apart until now, and
|
|
308
294
|
// joins its message only where the outcome allows it: never on a write
|
|
309
295
|
// whose outcome is unknown, whatever redaction did to the message.
|
|
@@ -334,9 +320,22 @@ export class InvocationService {
|
|
|
334
320
|
error = { ...error, code: "downstream_oauth_required" };
|
|
335
321
|
}
|
|
336
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
|
+
};
|
|
337
330
|
case "input_required_unsupported":
|
|
338
331
|
if (context.processInputRequired)
|
|
339
|
-
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
|
+
};
|
|
340
339
|
// Resending could apply the write twice: reconcile first.
|
|
341
340
|
if (unknownWrite)
|
|
342
341
|
return {
|
|
@@ -407,8 +406,13 @@ export class InvocationService {
|
|
|
407
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.",
|
|
408
407
|
};
|
|
409
408
|
case "invalid_args":
|
|
410
|
-
if (
|
|
409
|
+
if (error.writeOutcome === "unknown")
|
|
411
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
|
+
};
|
|
412
416
|
return {
|
|
413
417
|
...error,
|
|
414
418
|
// An example comes only from the published schema, never a connector's copy of the
|
|
@@ -427,6 +431,27 @@ export class InvocationService {
|
|
|
427
431
|
}, "Inspect the current input shape if the validation findings are not sufficient."),
|
|
428
432
|
retry: `Correct the listed arguments and retry ` + `${target.connector.id}.${target.toolName}.`,
|
|
429
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
|
+
};
|
|
430
455
|
default:
|
|
431
456
|
return error;
|
|
432
457
|
}
|
|
@@ -446,6 +471,13 @@ export class InvocationService {
|
|
|
446
471
|
}
|
|
447
472
|
}
|
|
448
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
|
+
}
|
|
449
481
|
const target = resolved ?? activityTarget;
|
|
450
482
|
if (target && (error.code === "auth_required" || error.code === "downstream_oauth_required")) {
|
|
451
483
|
recordAuthFailure(this.catalog.requestScope, target.connector.id, (resolved !== undefined && !isWriteLike(resolved.definition) && resolved.classificationFresh === true) ||
|
|
@@ -562,7 +594,7 @@ export class InvocationService {
|
|
|
562
594
|
const connectorReadOnly = target.connector.readOnly === true;
|
|
563
595
|
const refusal = writeRefusal(target.definition.classification, context.source, context.readOnly || connectorReadOnly);
|
|
564
596
|
if (refusal === "read_only_endpoint") {
|
|
565
|
-
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.`);
|
|
566
598
|
}
|
|
567
599
|
if (refusal === "destructive_tool_requires_approval") {
|
|
568
600
|
return framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is a write. ${routePhrases(context).writeNeedsApproval}`);
|
|
@@ -807,6 +839,13 @@ export class InvocationService {
|
|
|
807
839
|
try {
|
|
808
840
|
const value = sentSecrets.redact(processed.value, "envelope");
|
|
809
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
|
+
}
|
|
810
849
|
const friction = context.activityFriction?.(value);
|
|
811
850
|
record("success", friction ? { friction } : {});
|
|
812
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?: {
|
package/dist/meta-tools.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { operationActivity } from "./operation-activity.js";
|
|
2
|
+
import { SkillLookupError } from "./skills.js";
|
|
3
|
+
import { skillLookupRecovery, utilityRecovery } from "./utility-recovery.js";
|
|
1
4
|
import { META_ROUTES } from "./agent-routes.js";
|
|
2
5
|
import { toolError } from "./tool-error.js";
|
|
3
6
|
import { upwardStep } from "./upward.js";
|
|
@@ -38,6 +41,9 @@ export function jsonResult(obj, text = JSON.stringify(obj)) {
|
|
|
38
41
|
function errorResult(message) {
|
|
39
42
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
40
43
|
}
|
|
44
|
+
function resultError(message) {
|
|
45
|
+
return toolError(utilityRecovery("invalid_args", message));
|
|
46
|
+
}
|
|
41
47
|
function discoveryErrorResult(error) {
|
|
42
48
|
return toolError({ code: error.code, message: error.message, retryable: false });
|
|
43
49
|
}
|
|
@@ -558,6 +564,7 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
558
564
|
}
|
|
559
565
|
const sentSecrets = sentSecretsForRequest(requestScope);
|
|
560
566
|
const skills = new SkillsRegistry(registry, baseUrl, {
|
|
567
|
+
readOnly: opts.readOnly,
|
|
561
568
|
requestScope,
|
|
562
569
|
requestSignal: opts.requestSignal,
|
|
563
570
|
probeTimeoutMs,
|
|
@@ -731,7 +738,7 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
731
738
|
};
|
|
732
739
|
});
|
|
733
740
|
});
|
|
734
|
-
|
|
741
|
+
const methods = {
|
|
735
742
|
skills: authMethod((args = {}) => authFlow(function* () {
|
|
736
743
|
try {
|
|
737
744
|
if (!args.name) {
|
|
@@ -751,7 +758,9 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
751
758
|
return { content: [{ type: "text", text }], structuredContent: { name: args.name, format: "text", text } };
|
|
752
759
|
}
|
|
753
760
|
catch (error) {
|
|
754
|
-
|
|
761
|
+
if (error instanceof SkillLookupError)
|
|
762
|
+
return toolError(skillLookupRecovery(error.message));
|
|
763
|
+
return toolError(utilityRecovery(error instanceof ConnectorCallError ? error.code : "unavailable", "Skills are unavailable.", error instanceof ConnectorCallError && error.retryable));
|
|
755
764
|
}
|
|
756
765
|
})),
|
|
757
766
|
searchTools: authMethod((args) => authFlow(function* () {
|
|
@@ -781,25 +790,21 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
781
790
|
// Defense for in-process callers too: guest paging validates these domains
|
|
782
791
|
// before calling here, while internal consumers may call readResult directly.
|
|
783
792
|
if (args.maxBytes !== undefined && !isValidMaxResultBytes(args.maxBytes)) {
|
|
784
|
-
return
|
|
793
|
+
return resultError(`Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
|
|
785
794
|
`>= ${MIN_MAX_RESULT_BYTES}. Omit it to use the deployment default.`);
|
|
786
795
|
}
|
|
787
796
|
if (args.offset !== undefined && !isValidResultOffset(args.offset)) {
|
|
788
|
-
return
|
|
797
|
+
return resultError(`Invalid offset ${args.offset}: must be a whole number of bytes ` +
|
|
789
798
|
`>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
|
|
790
799
|
}
|
|
791
800
|
if (args.page !== undefined &&
|
|
792
801
|
(!Number.isSafeInteger(args.page) || args.page < 0 || args.offset !== undefined)) {
|
|
793
|
-
return
|
|
802
|
+
return resultError("Use a whole page >= 0, without offset.");
|
|
794
803
|
}
|
|
795
804
|
const results = registry.resultsStorage();
|
|
796
805
|
const unavailableResult = () => ({
|
|
797
806
|
...jsonResult({
|
|
798
|
-
error:
|
|
799
|
-
code: "unavailable",
|
|
800
|
-
message: "Result paging storage is unavailable.",
|
|
801
|
-
retryable: true,
|
|
802
|
-
},
|
|
807
|
+
error: utilityRecovery("unavailable", "Result paging storage is unavailable.", true),
|
|
803
808
|
}),
|
|
804
809
|
isError: true,
|
|
805
810
|
});
|
|
@@ -825,9 +830,9 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
825
830
|
if (stored === false)
|
|
826
831
|
return unavailableResult();
|
|
827
832
|
if (stored === null) {
|
|
828
|
-
return
|
|
833
|
+
return toolError(utilityRecovery("not_found", `Unknown or expired result id "${boundedEchoText(args.id, 96)}"`));
|
|
829
834
|
}
|
|
830
|
-
const denied = () =>
|
|
835
|
+
const denied = () => toolError(utilityRecovery("not_found", `Unknown or expired result id "${boundedEchoText(args.id, 96)}"`));
|
|
831
836
|
const header = RESULT_ENVELOPE_V4_HEADER.exec(stored);
|
|
832
837
|
if (!header)
|
|
833
838
|
return denied();
|
|
@@ -941,7 +946,7 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
941
946
|
if (encoded === null) {
|
|
942
947
|
// A chunk expired or was evicted under its own header; the id can no
|
|
943
948
|
// longer serve this range, and inventing U+0000 filler would be worse.
|
|
944
|
-
return
|
|
949
|
+
return toolError(utilityRecovery("not_found", `Unknown or expired result id "${boundedEchoText(args.id, 96)}"`));
|
|
945
950
|
}
|
|
946
951
|
const chunkStart = index * chunkBytes;
|
|
947
952
|
// Both bounds are chunk-local. `from` inherits `start`'s three-byte
|
|
@@ -999,6 +1004,15 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
|
|
|
999
1004
|
}
|
|
1000
1005
|
})),
|
|
1001
1006
|
};
|
|
1007
|
+
if (!opts.activity?.recordOperation)
|
|
1008
|
+
return methods;
|
|
1009
|
+
const track = (name, method) => authMethod((...args) => operationActivity(opts.activity, registry, name, authCall(method, args)));
|
|
1010
|
+
return {
|
|
1011
|
+
...methods,
|
|
1012
|
+
skills: track("skill", methods.skills),
|
|
1013
|
+
searchTools: track("search", methods.searchTools),
|
|
1014
|
+
readResult: track("result", methods.readResult),
|
|
1015
|
+
};
|
|
1002
1016
|
}
|
|
1003
1017
|
// Each description states only its own tool's scope; the full routing rule
|
|
1004
1018
|
// lives once in the server instructions. A client that drops instructions
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConnectaBranding } from "./types.js";
|
|
2
|
-
import type { ActivityStore, ActivityReadGate, recordToolActivity, recordCatalogChangeActivity } from "./activity.js";
|
|
2
|
+
import type { ActivityStore, ActivityReadGate, recordToolActivity, recordCatalogChangeActivity, recordOperationActivity } from "./activity.js";
|
|
3
3
|
import type { RouteContext } from "./routes/shared.js";
|
|
4
4
|
/** Construction-time UI contract. Core never imports the implementation. */
|
|
5
5
|
export interface OperatorSurface {
|
|
@@ -10,6 +10,8 @@ export interface OperatorSurface {
|
|
|
10
10
|
}
|
|
11
11
|
/** Optional recording and reading callbacks supplied by /activity. */
|
|
12
12
|
export interface ActivityModule {
|
|
13
|
+
readonly operations?: boolean;
|
|
14
|
+
readonly recordOperation?: typeof recordOperationActivity;
|
|
13
15
|
readonly store: ActivityStore;
|
|
14
16
|
readonly deploymentId?: string;
|
|
15
17
|
readonly readGate?: ActivityReadGate;
|