@zackbart/connecta 0.24.2 → 0.24.3
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 +141 -0
- package/dist/auth/bearer.js +2 -0
- package/dist/auth/downstream-oauth.d.ts +12 -1
- package/dist/auth/downstream-oauth.js +147 -35
- package/dist/call-admission.d.ts +4 -0
- package/dist/call-admission.js +26 -0
- package/dist/catalog-drift.js +9 -4
- package/dist/catalog-service.d.ts +2 -0
- package/dist/catalog-service.js +25 -8
- package/dist/catalog.d.ts +2 -0
- package/dist/catalog.js +246 -121
- package/dist/connectors/api.js +11 -1
- package/dist/connectors/guarded-fetch.d.ts +1 -1
- package/dist/connectors/guarded-fetch.js +27 -20
- package/dist/connectors/remote-mcp.js +84 -53
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +58 -0
- package/dist/execute.js +85 -23
- package/dist/executor-result.js +3 -1
- package/dist/executors/quickjs-child.js +5 -1
- package/dist/executors/quickjs-protocol.d.ts +4 -0
- package/dist/executors/quickjs-runtime.d.ts +1 -1
- package/dist/executors/quickjs-runtime.js +38 -21
- package/dist/executors/quickjs.js +68 -27
- package/dist/index.d.ts +14 -0
- package/dist/index.js +24 -3
- package/dist/invocation.js +134 -93
- package/dist/mcp-result.js +3 -2
- package/dist/meta-tools.js +118 -39
- package/dist/registry.d.ts +14 -2
- package/dist/registry.js +87 -13
- package/dist/routes/mcp.d.ts +4 -1
- package/dist/routes/mcp.js +84 -13
- package/dist/routes/oauth.js +4 -0
- package/dist/routes/shared.d.ts +1 -0
- package/dist/routes/shared.js +4 -4
- package/dist/server.js +15 -3
- package/dist/skills.js +6 -5
- package/dist/storage/file.d.ts +6 -2
- package/dist/storage/file.js +312 -34
- package/dist/storage/memory.js +12 -1
- package/dist/validate.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +22 -6
- package/documentation/auth.md +42 -9
- package/documentation/call-admission.md +24 -8
- package/documentation/code-mode.md +34 -22
- package/documentation/connectors.md +47 -5
- package/documentation/meta-tools.md +74 -6
- package/documentation/operations.md +19 -19
- package/documentation/provider-conventions.md +7 -0
- package/documentation/request-admission.md +38 -4
- package/documentation/storage-and-credentials.md +54 -1
- package/documentation/upgrading.md +18 -4
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Client, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
1
|
+
import { Client, ProtocolError, SdkHttpError, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
2
2
|
import { KvOAuthProvider, OAuthRefreshCoordinator, } from "../auth/downstream-oauth.js";
|
|
3
3
|
import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
|
|
4
|
-
import { ConnectorCallError, msg } from "../errors.js";
|
|
4
|
+
import { boundedEchoText, ConnectorCallError, msg, unavailableCallError, } from "../errors.js";
|
|
5
5
|
import { CONNECTA_VERSION } from "../version.js";
|
|
6
6
|
/**
|
|
7
7
|
* Apply a maintained provider's slot copy and header framing to credential
|
|
@@ -157,6 +157,28 @@ async function terminateSession(transport, logger, connectorId) {
|
|
|
157
157
|
});
|
|
158
158
|
});
|
|
159
159
|
}
|
|
160
|
+
/** Classify protocol/status facts; provider prose never decides retryability. */
|
|
161
|
+
function downstreamCallError(error) {
|
|
162
|
+
if (error instanceof ProtocolError && error.code === -32602) {
|
|
163
|
+
return new ConnectorCallError("invalid_args", boundedEchoText(error.message));
|
|
164
|
+
}
|
|
165
|
+
if (error instanceof SdkHttpError && error.status >= 400 && error.status < 500) {
|
|
166
|
+
let message = error.message;
|
|
167
|
+
if (typeof error.data.text === "string") {
|
|
168
|
+
try {
|
|
169
|
+
const body = JSON.parse(error.data.text);
|
|
170
|
+
const detail = body?.message ?? body?.error?.message ?? body?.error_description;
|
|
171
|
+
if (typeof detail === "string")
|
|
172
|
+
message = detail;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Non-JSON refusals still retain a bounded diagnostic.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return new ConnectorCallError(error.status === 429 ? "rate_limited" : error.status === 408 ? "timeout" : "connector_call_failed", boundedEchoText(message));
|
|
179
|
+
}
|
|
180
|
+
return error;
|
|
181
|
+
}
|
|
160
182
|
const encoder = new TextEncoder();
|
|
161
183
|
/** Base64 of a UTF-8 string, Web-API only so the core still runs on workerd. */
|
|
162
184
|
function base64Utf8(value) {
|
|
@@ -266,10 +288,18 @@ export function redirectSafeFetch(connectorId, policy = "none", baseFetch = fetc
|
|
|
266
288
|
const seen = new Set([current.href]);
|
|
267
289
|
let hops = 0;
|
|
268
290
|
while (true) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
291
|
+
let response;
|
|
292
|
+
try {
|
|
293
|
+
response = await baseFetch(current, {
|
|
294
|
+
...init,
|
|
295
|
+
redirect: "manual",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (cause) {
|
|
299
|
+
if (cause instanceof ConnectorCallError)
|
|
300
|
+
throw cause;
|
|
301
|
+
throw unavailableCallError(cause, current.href);
|
|
302
|
+
}
|
|
273
303
|
if (!REDIRECT_STATUSES.has(response.status))
|
|
274
304
|
return response;
|
|
275
305
|
const location = response.headers.get("location");
|
|
@@ -549,19 +579,38 @@ export function remoteMcp(id, opts) {
|
|
|
549
579
|
state.credentialDigest = null;
|
|
550
580
|
// `closed` is deliberately not cleared — see ConnectionState.
|
|
551
581
|
};
|
|
552
|
-
|
|
582
|
+
// The context has no deferred-work hook. Detached exits start this bounded
|
|
583
|
+
// best-effort tail immediately; closeScope awaits its own tail so the core
|
|
584
|
+
// can pass it to the runtime's deferred channel.
|
|
585
|
+
const closingSessions = new WeakMap();
|
|
586
|
+
const closeConnection = (client, transport, logger) => {
|
|
587
|
+
const previous = transport && closingSessions.get(transport);
|
|
588
|
+
if (previous)
|
|
589
|
+
return previous;
|
|
590
|
+
const closing = (async () => {
|
|
591
|
+
try {
|
|
592
|
+
if (transport)
|
|
593
|
+
await terminateSession(transport, logger, id);
|
|
594
|
+
if (client)
|
|
595
|
+
await client.close();
|
|
596
|
+
else
|
|
597
|
+
await transport?.close();
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// Local close cannot replace the operation's result.
|
|
601
|
+
}
|
|
602
|
+
})();
|
|
603
|
+
// A connect can acquire a session after an early close. Deduplicate only
|
|
604
|
+
// once that session exists, so its late abandonment still sends DELETE.
|
|
605
|
+
if (transport?.sessionId)
|
|
606
|
+
closingSessions.set(transport, closing);
|
|
607
|
+
return closing;
|
|
608
|
+
};
|
|
609
|
+
const closeHalf = (state, ctx) => {
|
|
553
610
|
const client = state.client;
|
|
554
611
|
const transport = state.transport;
|
|
555
612
|
reset(state);
|
|
556
|
-
|
|
557
|
-
if (client)
|
|
558
|
-
await client.close();
|
|
559
|
-
else
|
|
560
|
-
await transport?.close();
|
|
561
|
-
}
|
|
562
|
-
catch {
|
|
563
|
-
// The discarded state remains authoritative if local close fails.
|
|
564
|
-
}
|
|
613
|
+
void closeConnection(client, transport, ctx.logger);
|
|
565
614
|
};
|
|
566
615
|
const ensureConnected = async (ctx, state) => {
|
|
567
616
|
// A 401 after connect is a verdict for the whole request scope, not merely
|
|
@@ -581,7 +630,7 @@ export function remoteMcp(id, opts) {
|
|
|
581
630
|
if (provider.isOperatorDisconnectedGeneration(oauthGeneration)) {
|
|
582
631
|
const connecting = state.connecting;
|
|
583
632
|
void connecting?.catch(() => { });
|
|
584
|
-
|
|
633
|
+
closeHalf(state, ctx);
|
|
585
634
|
throw operatorDisconnectedError();
|
|
586
635
|
}
|
|
587
636
|
}
|
|
@@ -592,7 +641,7 @@ export function remoteMcp(id, opts) {
|
|
|
592
641
|
if (state.closed)
|
|
593
642
|
throw scopeEndedError();
|
|
594
643
|
if (oauthGeneration !== state.connectedGeneration) {
|
|
595
|
-
|
|
644
|
+
closeHalf(state, ctx);
|
|
596
645
|
}
|
|
597
646
|
}
|
|
598
647
|
// The static-credential counterpart of the epoch read above, and
|
|
@@ -618,7 +667,7 @@ export function remoteMcp(id, opts) {
|
|
|
618
667
|
throw scopeEndedError();
|
|
619
668
|
const connecting = state.connecting;
|
|
620
669
|
void connecting?.catch(() => { });
|
|
621
|
-
|
|
670
|
+
closeHalf(state, ctx);
|
|
622
671
|
}
|
|
623
672
|
}
|
|
624
673
|
if (state.closed)
|
|
@@ -629,13 +678,8 @@ export function remoteMcp(id, opts) {
|
|
|
629
678
|
let attempt;
|
|
630
679
|
attempt = (async () => {
|
|
631
680
|
const ownsAttempt = () => state.connecting === attempt && !state.closed;
|
|
632
|
-
const abandon =
|
|
633
|
-
|
|
634
|
-
await owner.close();
|
|
635
|
-
}
|
|
636
|
-
catch {
|
|
637
|
-
// The attempt is detached either way.
|
|
638
|
-
}
|
|
681
|
+
const abandon = (client, transport) => {
|
|
682
|
+
void closeConnection(client, transport, ctx.logger);
|
|
639
683
|
throw scopeEndedError();
|
|
640
684
|
};
|
|
641
685
|
// Let the assignment immediately below this async IIFE publish
|
|
@@ -666,7 +710,7 @@ export function remoteMcp(id, opts) {
|
|
|
666
710
|
});
|
|
667
711
|
const t = buildTransport(ctx, provider, credentialFramed);
|
|
668
712
|
if (!ownsAttempt())
|
|
669
|
-
|
|
713
|
+
abandon(null, t);
|
|
670
714
|
state.transport = t;
|
|
671
715
|
try {
|
|
672
716
|
await c.connect(t);
|
|
@@ -675,7 +719,7 @@ export function remoteMcp(id, opts) {
|
|
|
675
719
|
// that race anyway, close the resulting client rather than
|
|
676
720
|
// resurrecting a session in the detached state object.
|
|
677
721
|
if (!ownsAttempt())
|
|
678
|
-
|
|
722
|
+
abandon(c, t);
|
|
679
723
|
// A force re-auth that landed WHILE we were connecting wiped the
|
|
680
724
|
// credentials this client just bound to. Discard it rather than
|
|
681
725
|
// cache a stale-isolate connection.
|
|
@@ -685,24 +729,22 @@ export function remoteMcp(id, opts) {
|
|
|
685
729
|
// connect succeeded but before this client is cached. Discard the
|
|
686
730
|
// client on that side of the await too.
|
|
687
731
|
if (!ownsAttempt())
|
|
688
|
-
|
|
732
|
+
abandon(c, t);
|
|
689
733
|
if (generation !== genAtStart) {
|
|
690
|
-
|
|
691
|
-
await c.close();
|
|
692
|
-
}
|
|
693
|
-
catch {
|
|
694
|
-
// discarding either way
|
|
695
|
-
}
|
|
734
|
+
void closeConnection(c, t, ctx.logger);
|
|
696
735
|
throw new UnauthorizedError("Connector was re-authorized during connect; reconnect required.");
|
|
697
736
|
}
|
|
698
737
|
}
|
|
699
738
|
if (!ownsAttempt())
|
|
700
|
-
|
|
739
|
+
abandon(c, t);
|
|
701
740
|
state.client = c;
|
|
702
741
|
state.connectedGeneration = genAtStart;
|
|
703
742
|
state.authRequired = false;
|
|
704
743
|
}
|
|
705
744
|
catch (err) {
|
|
745
|
+
void closeConnection(c, t, ctx.logger);
|
|
746
|
+
if (ownsAttempt())
|
|
747
|
+
state.transport = null;
|
|
706
748
|
// Only a real 401/UnauthorizedError means auth is the problem — a
|
|
707
749
|
// network error on an oauth connector must surface as "error", not
|
|
708
750
|
// "auth_required".
|
|
@@ -748,7 +790,7 @@ export function remoteMcp(id, opts) {
|
|
|
748
790
|
// client/transport exists. Reset is unconditional because KV may already
|
|
749
791
|
// be fenced behind a newer epoch after a cleanup error.
|
|
750
792
|
void connecting?.catch(() => { });
|
|
751
|
-
|
|
793
|
+
closeHalf(state, ctx);
|
|
752
794
|
}
|
|
753
795
|
};
|
|
754
796
|
const connector = {
|
|
@@ -976,7 +1018,7 @@ export function remoteMcp(id, opts) {
|
|
|
976
1018
|
state.authRequired = true;
|
|
977
1019
|
throw authRequiredError(err);
|
|
978
1020
|
}
|
|
979
|
-
throw err;
|
|
1021
|
+
throw downstreamCallError(err);
|
|
980
1022
|
}
|
|
981
1023
|
},
|
|
982
1024
|
async closeScope(ctx) {
|
|
@@ -993,20 +1035,7 @@ export function remoteMcp(id, opts) {
|
|
|
993
1035
|
const client = state.client;
|
|
994
1036
|
const transport = state.transport;
|
|
995
1037
|
reset(state);
|
|
996
|
-
|
|
997
|
-
// side, and the DELETE that frees the server's rides on the very
|
|
998
|
-
// AbortSignal the close is about to trip.
|
|
999
|
-
if (transport)
|
|
1000
|
-
await terminateSession(transport, ctx.logger, id);
|
|
1001
|
-
// Client.close() owns its connected transport. During an unfinished or
|
|
1002
|
-
// failed connect there is no cached client yet, so close the transport
|
|
1003
|
-
// directly to abort/release that half-open session.
|
|
1004
|
-
if (client) {
|
|
1005
|
-
await client.close();
|
|
1006
|
-
}
|
|
1007
|
-
else {
|
|
1008
|
-
await transport?.close();
|
|
1009
|
-
}
|
|
1038
|
+
await closeConnection(client, transport, ctx.logger);
|
|
1010
1039
|
},
|
|
1011
1040
|
async status(ctx) {
|
|
1012
1041
|
const state = stateFor(ctx);
|
|
@@ -1054,7 +1083,9 @@ export function remoteMcp(id, opts) {
|
|
|
1054
1083
|
}
|
|
1055
1084
|
await provider.clearPending();
|
|
1056
1085
|
// Reset so the next use reconnects with the freshly stored tokens.
|
|
1057
|
-
|
|
1086
|
+
if (!state.transport)
|
|
1087
|
+
state.transport = t;
|
|
1088
|
+
closeHalf(state, ctx);
|
|
1058
1089
|
},
|
|
1059
1090
|
};
|
|
1060
1091
|
if (opts.auth?.type === "oauth") {
|
package/dist/errors.d.ts
CHANGED
|
@@ -39,6 +39,17 @@ export declare function boundedEchoText(value: string, maxBytes?: number): strin
|
|
|
39
39
|
export declare function echoedCallArgs(args: unknown): {
|
|
40
40
|
args?: unknown;
|
|
41
41
|
};
|
|
42
|
+
/** Optional transport diagnostics; never a URL path or raw runtime message. */
|
|
43
|
+
interface UnavailableDetails {
|
|
44
|
+
/** HTTP(S) origin only, at most 253 UTF-8 bytes. */
|
|
45
|
+
host?: string;
|
|
46
|
+
/** Validated network errno or `timeout`, at most 32 bytes. */
|
|
47
|
+
code?: string;
|
|
48
|
+
}
|
|
49
|
+
/** Runtime fields only: provider prose cannot supply an errno or a deadline. */
|
|
50
|
+
export declare function networkErrorCode(error: unknown): string | undefined;
|
|
51
|
+
/** Use at a fetch boundary where the destination and transport failure are known. */
|
|
52
|
+
export declare function unavailableCallError(cause: unknown, host?: string, message?: string): ConnectorCallError;
|
|
42
53
|
/** Agent-visible recovery class attached only to `auth_required` failures. */
|
|
43
54
|
export type AuthRecoveryMode = "oauth" | "operator_config" | "unavailable";
|
|
44
55
|
/**
|
|
@@ -67,15 +78,20 @@ export declare class ConnectorCallError extends Error {
|
|
|
67
78
|
readonly retryAfterMs: number | undefined;
|
|
68
79
|
/** Bounded schema findings for `invalid_args`; never submitted values. */
|
|
69
80
|
readonly validation: ArgumentValidationDetails | undefined;
|
|
81
|
+
/** Sanitized transport diagnostics for `unavailable` only. */
|
|
82
|
+
readonly details: UnavailableDetails | undefined;
|
|
70
83
|
constructor(code: ConnectorCallErrorCode, message: string, opts?: {
|
|
71
84
|
retryable?: boolean;
|
|
72
85
|
retryAfterMs?: number;
|
|
73
86
|
cause?: unknown;
|
|
74
87
|
validation?: ArgumentValidationDetails;
|
|
88
|
+
details?: UnavailableDetails;
|
|
75
89
|
});
|
|
76
90
|
}
|
|
77
91
|
/** The `error` object surfaced in value-mode call results and rejected promises. */
|
|
78
92
|
export interface CallErrorDetails {
|
|
93
|
+
/** Sanitized transport diagnostics, absent when the runtime supplies none. */
|
|
94
|
+
details?: UnavailableDetails;
|
|
79
95
|
code: string;
|
|
80
96
|
message: string;
|
|
81
97
|
retryable: boolean;
|
|
@@ -151,3 +167,4 @@ export declare function framingError(code: string, message: string): CallErrorDe
|
|
|
151
167
|
*/
|
|
152
168
|
export declare function classifyCallError(err: unknown, fallbackCode?: string): CallErrorDetails;
|
|
153
169
|
export declare function msg(err: unknown): string;
|
|
170
|
+
export {};
|
package/dist/errors.js
CHANGED
|
@@ -102,6 +102,58 @@ function boundedValidation(details) {
|
|
|
102
102
|
...(truncated ? { truncated: true } : {}),
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
106
|
+
"ECONNREFUSED", "ENOTFOUND", "ECONNRESET", "ETIMEDOUT", "EAI_AGAIN",
|
|
107
|
+
"EAI_FAIL", "EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EHOSTDOWN",
|
|
108
|
+
"ECONNABORTED", "EPIPE", "EACCES", "EPERM",
|
|
109
|
+
"UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT",
|
|
110
|
+
"UND_ERR_SOCKET", "timeout",
|
|
111
|
+
]);
|
|
112
|
+
function networkCode(value) {
|
|
113
|
+
return typeof value === "string" && value.length <= 32 && NETWORK_ERROR_CODES.has(value)
|
|
114
|
+
? value
|
|
115
|
+
: undefined;
|
|
116
|
+
}
|
|
117
|
+
function sanitizedUnavailableDetails(details) {
|
|
118
|
+
if (!details)
|
|
119
|
+
return undefined;
|
|
120
|
+
let host;
|
|
121
|
+
if (typeof details.host === "string") {
|
|
122
|
+
try {
|
|
123
|
+
const url = new URL(details.host);
|
|
124
|
+
if ((url.protocol === "https:" || url.protocol === "http:") &&
|
|
125
|
+
echoEncoder.encode(url.origin).length <= 253)
|
|
126
|
+
host = url.origin;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// An invalid or oversized origin is absent, never a clipped destination.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const code = networkCode(details.code);
|
|
133
|
+
return host || code ? { ...(host ? { host } : {}), ...(code ? { code } : {}) } : undefined;
|
|
134
|
+
}
|
|
135
|
+
/** Runtime fields only: provider prose cannot supply an errno or a deadline. */
|
|
136
|
+
export function networkErrorCode(error) {
|
|
137
|
+
if (!error || typeof error !== "object")
|
|
138
|
+
return undefined;
|
|
139
|
+
if (error instanceof Error &&
|
|
140
|
+
(error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
141
|
+
return "timeout";
|
|
142
|
+
}
|
|
143
|
+
const runtime = error;
|
|
144
|
+
const cause = runtime.cause;
|
|
145
|
+
return networkCode(runtime.code) ?? (cause && typeof cause === "object"
|
|
146
|
+
? networkCode(cause.code)
|
|
147
|
+
: undefined);
|
|
148
|
+
}
|
|
149
|
+
/** Use at a fetch boundary where the destination and transport failure are known. */
|
|
150
|
+
export function unavailableCallError(cause, host, message = "Could not reach the downstream service.") {
|
|
151
|
+
const code = networkErrorCode(cause);
|
|
152
|
+
return new ConnectorCallError("unavailable", message, {
|
|
153
|
+
cause,
|
|
154
|
+
details: { ...(host ? { host } : {}), ...(code ? { code } : {}) },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
105
157
|
const RETRYABLE_BY_CODE = {
|
|
106
158
|
timeout: true,
|
|
107
159
|
rate_limited: true,
|
|
@@ -146,12 +198,16 @@ export class ConnectorCallError extends Error {
|
|
|
146
198
|
retryAfterMs;
|
|
147
199
|
/** Bounded schema findings for `invalid_args`; never submitted values. */
|
|
148
200
|
validation;
|
|
201
|
+
/** Sanitized transport diagnostics for `unavailable` only. */
|
|
202
|
+
details;
|
|
149
203
|
constructor(code, message, opts = {}) {
|
|
150
204
|
super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
151
205
|
this.name = "ConnectorCallError";
|
|
152
206
|
this.code = code;
|
|
153
207
|
this.retryable = opts.retryable ?? RETRYABLE_BY_CODE[code];
|
|
154
208
|
this.retryAfterMs = normalizeRetryAfterMs(opts.retryAfterMs);
|
|
209
|
+
this.details =
|
|
210
|
+
code === "unavailable" ? sanitizedUnavailableDetails(opts.details) : undefined;
|
|
155
211
|
this.validation =
|
|
156
212
|
code === "invalid_args" ? boundedValidation(opts.validation) : undefined;
|
|
157
213
|
}
|
|
@@ -164,6 +220,7 @@ export class ConnectorCallError extends Error {
|
|
|
164
220
|
* trusts the flag would cheerfully retry a refusal forever.
|
|
165
221
|
*/
|
|
166
222
|
const NEVER_RETRYABLE_FRAMING = new Set([
|
|
223
|
+
"result_processing_failed",
|
|
167
224
|
"unknown_address",
|
|
168
225
|
"unknown_tool",
|
|
169
226
|
"ambiguous_tool_alias",
|
|
@@ -203,6 +260,7 @@ export function classifyCallError(err, fallbackCode = "connector_call_failed") {
|
|
|
203
260
|
? { retryAfterMs: err.retryAfterMs }
|
|
204
261
|
: {}),
|
|
205
262
|
...(err.validation ? { validation: err.validation } : {}),
|
|
263
|
+
...(err.details ? { details: err.details } : {}),
|
|
206
264
|
};
|
|
207
265
|
}
|
|
208
266
|
// An aborted fetch rejects with a DOMException named "AbortError" whose
|
package/dist/execute.js
CHANGED
|
@@ -130,7 +130,8 @@ export class EmitCollector {
|
|
|
130
130
|
accept(raw) {
|
|
131
131
|
const block = requireEmittedBlock(raw);
|
|
132
132
|
if (this.blocks.length >= this.maxBlocks) {
|
|
133
|
-
|
|
133
|
+
// M5: distinguish exhausted block slots from the remaining byte budget.
|
|
134
|
+
throw guestFailure("budget_exceeded", `connecta.emit block-count budget exceeded: ${this.maxBlocks} block(s) maximum, 0 blocks remaining; ${this.maxBytes - this.bytes} of ${this.maxBytes} serialized bytes remaining`);
|
|
134
135
|
}
|
|
135
136
|
const size = diagnosticsEncoder.encode(JSON.stringify(block)).byteLength;
|
|
136
137
|
if (this.bytes + size > this.maxBytes) {
|
|
@@ -171,6 +172,48 @@ function guestFailureSecret() {
|
|
|
171
172
|
crypto.getRandomValues(words);
|
|
172
173
|
return Array.from(words, (word) => word.toString(16).padStart(8, "0")).join("");
|
|
173
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* E1/X11: bound the serialized frame, including JSON escapes, before transport.
|
|
177
|
+
* Keep optional recovery whole: clipping an address or echoed args could turn
|
|
178
|
+
* recovery into a different call. Oversized metadata is omitted instead.
|
|
179
|
+
*/
|
|
180
|
+
function boundedGuestFailure(failure) {
|
|
181
|
+
const boundText = (text, maxChars) => {
|
|
182
|
+
if (JSON.stringify(text).length <= maxChars)
|
|
183
|
+
return text;
|
|
184
|
+
let low = 0;
|
|
185
|
+
let high = Math.min(text.length, maxChars);
|
|
186
|
+
while (low < high) {
|
|
187
|
+
const mid = Math.ceil((low + high) / 2);
|
|
188
|
+
if (JSON.stringify(`${text.slice(0, mid)}…`).length <= maxChars)
|
|
189
|
+
low = mid;
|
|
190
|
+
else
|
|
191
|
+
high = mid - 1;
|
|
192
|
+
}
|
|
193
|
+
// Do not manufacture a lone surrogate when clipping a Unicode message.
|
|
194
|
+
if (low > 0 && /[\uD800-\uDBFF]/.test(text[low - 1]))
|
|
195
|
+
low--;
|
|
196
|
+
return `${text.slice(0, low)}…`;
|
|
197
|
+
};
|
|
198
|
+
let details = {
|
|
199
|
+
...failure.details,
|
|
200
|
+
code: boundText(failure.details.code, 128),
|
|
201
|
+
message: boundText(failure.details.message, 2_000),
|
|
202
|
+
};
|
|
203
|
+
// 3,700 plus the prefix and 32-character secret stays below QuickJS's
|
|
204
|
+
// 4,000-character error bound, with room for bridge-added context.
|
|
205
|
+
if (JSON.stringify(details).length > 3_700) {
|
|
206
|
+
details = {
|
|
207
|
+
code: details.code,
|
|
208
|
+
message: details.message,
|
|
209
|
+
retryable: details.retryable,
|
|
210
|
+
...(details.retryAfterMs !== undefined
|
|
211
|
+
? { retryAfterMs: details.retryAfterMs }
|
|
212
|
+
: {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return new InvocationFailure(details);
|
|
216
|
+
}
|
|
174
217
|
function framedGuestFailure(secret, failure) {
|
|
175
218
|
const framed = new InvocationFailure(failure.details);
|
|
176
219
|
framed.message =
|
|
@@ -191,7 +234,8 @@ function guestErrorPrelude(failureSecret) {
|
|
|
191
234
|
function ConnectaError(message, options) {
|
|
192
235
|
let details;
|
|
193
236
|
if (typeof message === "string" && startsWith(message, failurePrefix)) {
|
|
194
|
-
try { details = parse(slice(message, failurePrefix.length)); }
|
|
237
|
+
try { details = parse(slice(message, failurePrefix.length)); }
|
|
238
|
+
catch { message = "Invalid host failure frame."; }
|
|
195
239
|
}
|
|
196
240
|
const error = construct(
|
|
197
241
|
NativeError,
|
|
@@ -238,17 +282,18 @@ export async function buildSandboxProviders(registry, baseUrl, _logger, activity
|
|
|
238
282
|
const hostCallTimeoutMs = Math.max(1, Math.trunc(limits.hostCallTimeoutMs ?? EXECUTE_HOST_CALL_TIMEOUT_MS));
|
|
239
283
|
const failureSecret = guestFailureSecret();
|
|
240
284
|
let hostCalls = 0;
|
|
285
|
+
// L4/M7: discovery and invocation spend the same budget; emit does not.
|
|
286
|
+
const spendHostCall = () => {
|
|
287
|
+
hostCalls++;
|
|
288
|
+
if (hostCalls > maxHostCalls) {
|
|
289
|
+
throw guestFailure("budget_exceeded", `execute_code host-call budget exceeded (${maxHostCalls} calls maximum)`);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
241
292
|
const invocationContext = () => ({
|
|
242
293
|
source: "execute_code",
|
|
243
294
|
timeoutMs: hostCallTimeoutMs,
|
|
244
295
|
...(limits.signal !== undefined ? { requestSignal: limits.signal } : {}),
|
|
245
296
|
unwrapResult: true,
|
|
246
|
-
beforeDispatch: () => {
|
|
247
|
-
hostCalls++;
|
|
248
|
-
if (hostCalls > maxHostCalls) {
|
|
249
|
-
throw guestFailure("budget_exceeded", `execute_code host-call budget exceeded (${maxHostCalls} calls maximum)`);
|
|
250
|
-
}
|
|
251
|
-
},
|
|
252
297
|
});
|
|
253
298
|
/**
|
|
254
299
|
* Discovery policy failures use the same thrown vocabulary as calls and
|
|
@@ -268,6 +313,7 @@ export async function buildSandboxProviders(registry, baseUrl, _logger, activity
|
|
|
268
313
|
const timedCatalog = async (operation, fn) => {
|
|
269
314
|
const started = Date.now();
|
|
270
315
|
try {
|
|
316
|
+
spendHostCall();
|
|
271
317
|
const result = await fn();
|
|
272
318
|
limits.diagnostics?.recordCatalog(operation, Date.now() - started, true, result);
|
|
273
319
|
return result;
|
|
@@ -278,6 +324,7 @@ export async function buildSandboxProviders(registry, baseUrl, _logger, activity
|
|
|
278
324
|
}
|
|
279
325
|
};
|
|
280
326
|
const callAddress = async (address, args) => {
|
|
327
|
+
spendHostCall();
|
|
281
328
|
const outcome = await invocation.invoke(String(address), args ?? {}, invocationContext());
|
|
282
329
|
limits.diagnostics?.recordCall(outcome);
|
|
283
330
|
if (!outcome.ok)
|
|
@@ -296,7 +343,9 @@ export async function buildSandboxProviders(registry, baseUrl, _logger, activity
|
|
|
296
343
|
}
|
|
297
344
|
limits.emitCollector.accept(block);
|
|
298
345
|
},
|
|
299
|
-
|
|
346
|
+
// Not `async`: a synchronous budget refusal must reject the same promise
|
|
347
|
+
// the transport wrapper awaits, or workerd reports an unhandled rejection.
|
|
348
|
+
search: (raw) => timedCatalog("search", () => typedDiscovery(async () => {
|
|
300
349
|
const args = (raw ?? {});
|
|
301
350
|
const result = flatSearchResult(await catalog.search({
|
|
302
351
|
...args,
|
|
@@ -305,7 +354,7 @@ export async function buildSandboxProviders(registry, baseUrl, _logger, activity
|
|
|
305
354
|
boundedDiscoveryText(result, "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.");
|
|
306
355
|
return result;
|
|
307
356
|
})),
|
|
308
|
-
describe:
|
|
357
|
+
describe: (raw) => timedCatalog("describe", () => typedDiscovery(async () => {
|
|
309
358
|
const args = (raw ?? {});
|
|
310
359
|
const result = { tools: await catalog.describe(args) };
|
|
311
360
|
boundedDiscoveryText(result, 'Split the address list or use format: "compact".');
|
|
@@ -320,8 +369,9 @@ export async function buildSandboxProviders(registry, baseUrl, _logger, activity
|
|
|
320
369
|
}
|
|
321
370
|
catch (err) {
|
|
322
371
|
if (err instanceof InvocationFailure) {
|
|
323
|
-
const
|
|
324
|
-
|
|
372
|
+
const failure = boundedGuestFailure(err);
|
|
373
|
+
const framed = framedGuestFailure(failureSecret, failure);
|
|
374
|
+
limits.onInvocationFailure?.(failure);
|
|
325
375
|
throw framed;
|
|
326
376
|
}
|
|
327
377
|
throw err;
|
|
@@ -377,6 +427,8 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
|
|
|
377
427
|
signal: controller.signal,
|
|
378
428
|
onInvocationFailure: (failure) => {
|
|
379
429
|
invocationFailures.push(failure);
|
|
430
|
+
if (invocationFailures.length > 64)
|
|
431
|
+
invocationFailures.shift();
|
|
380
432
|
},
|
|
381
433
|
emitCollector: emitted,
|
|
382
434
|
...(diagnostics ? { diagnostics } : {}),
|
|
@@ -407,6 +459,9 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
|
|
|
407
459
|
}
|
|
408
460
|
}
|
|
409
461
|
catch (err) {
|
|
462
|
+
const logs = err !== null && typeof err === "object" && "logs" in err
|
|
463
|
+
? executeLogs(err.logs)
|
|
464
|
+
: undefined;
|
|
410
465
|
if (err instanceof ExecutorAdmissionError) {
|
|
411
466
|
if (err.code === "executor_overloaded") {
|
|
412
467
|
logger.warn("[connecta] execute_code admission rejected", {
|
|
@@ -415,6 +470,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
|
|
|
415
470
|
});
|
|
416
471
|
}
|
|
417
472
|
return failureResponse(err.message, {
|
|
473
|
+
logs,
|
|
418
474
|
emitted: err instanceof ExecutorExecutionError ? emitted : undefined,
|
|
419
475
|
diagnostics,
|
|
420
476
|
code: {
|
|
@@ -428,6 +484,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
|
|
|
428
484
|
});
|
|
429
485
|
}
|
|
430
486
|
return failureResponse(`Executor failed: ${msg(err)}`, {
|
|
487
|
+
logs,
|
|
431
488
|
emitted,
|
|
432
489
|
diagnostics,
|
|
433
490
|
code: "executor_failed",
|
|
@@ -440,10 +497,8 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
|
|
|
440
497
|
lease?.release();
|
|
441
498
|
options.signal?.removeEventListener("abort", forwardAbort);
|
|
442
499
|
}
|
|
443
|
-
const logs = outcome.logs
|
|
444
|
-
|
|
445
|
-
: undefined;
|
|
446
|
-
if (outcome.error) {
|
|
500
|
+
const logs = executeLogs(outcome.logs);
|
|
501
|
+
if (outcome.error !== undefined) {
|
|
447
502
|
// Executor bridges necessarily reduce thrown host errors to strings.
|
|
448
503
|
// Match that terminal string back to the request-local typed failure so
|
|
449
504
|
// an unhandled tool failure keeps the same structured contract as
|
|
@@ -457,8 +512,13 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
|
|
|
457
512
|
// rather than losing it to prose.
|
|
458
513
|
let invocationFailure;
|
|
459
514
|
for (const match of [
|
|
460
|
-
(candidate) =>
|
|
461
|
-
|
|
515
|
+
(candidate) => outcome.error !== "" &&
|
|
516
|
+
[candidate.message, guestFailureFrames.get(candidate)].includes(outcome.error),
|
|
517
|
+
(candidate) => [candidate.message, guestFailureFrames.get(candidate)].some((message) =>
|
|
518
|
+
// E6: empty or tiny prose cannot identify a wrapped failure.
|
|
519
|
+
message !== undefined &&
|
|
520
|
+
message.length >= 8 &&
|
|
521
|
+
outcome.error?.includes(message) === true),
|
|
462
522
|
]) {
|
|
463
523
|
for (let i = invocationFailures.length - 1; i >= 0; i--) {
|
|
464
524
|
const candidate = invocationFailures[i];
|
|
@@ -471,10 +531,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
|
|
|
471
531
|
break;
|
|
472
532
|
}
|
|
473
533
|
if (invocationFailure) {
|
|
474
|
-
//
|
|
475
|
-
// framed with bounded caller text (`boundedEchoText`) precisely so
|
|
476
|
-
// this path never needs one. Adding a cap here instead would leave the
|
|
477
|
-
// top-level surfaces, which have the same amplification, uncovered.
|
|
534
|
+
// E1/X11: return the same bounded details the guest received.
|
|
478
535
|
return failureResponse(invocationFailure.details.message, {
|
|
479
536
|
logs,
|
|
480
537
|
emitted,
|
|
@@ -482,7 +539,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
|
|
|
482
539
|
code: invocationFailure.details,
|
|
483
540
|
});
|
|
484
541
|
}
|
|
485
|
-
const message = `Error: ${outcome.error}`;
|
|
542
|
+
const message = `Error: ${outcome.error || "Execution failed without an error message."}`;
|
|
486
543
|
return failureResponse(message, {
|
|
487
544
|
logs,
|
|
488
545
|
emitted,
|
|
@@ -521,6 +578,11 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
|
|
|
521
578
|
return response;
|
|
522
579
|
};
|
|
523
580
|
}
|
|
581
|
+
function executeLogs(value) {
|
|
582
|
+
return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === "string")
|
|
583
|
+
? truncateExecuteText(value.join("\n"), MAX_EXECUTE_LOG_CHARS)
|
|
584
|
+
: undefined;
|
|
585
|
+
}
|
|
524
586
|
function failureResponse(message, options) {
|
|
525
587
|
const { logs, emitted, diagnostics, code } = options;
|
|
526
588
|
if (diagnostics || typeof code !== "string") {
|
package/dist/executor-result.js
CHANGED
|
@@ -57,7 +57,9 @@ export function prepareExecuteResultForTransport(outcome) {
|
|
|
57
57
|
// characters). Preserve that Executor-level shape; createExecuteTool applies
|
|
58
58
|
// the smaller model-facing 4k presentation cap in the parent.
|
|
59
59
|
const logs = outcome.logs && outcome.logs.length > 0 ? outcome.logs : undefined;
|
|
60
|
-
|
|
60
|
+
// An empty string is still a failure (E5): the parent renders a fixed
|
|
61
|
+
// message for it, but this transport must not turn it into a success.
|
|
62
|
+
if (outcome.error !== undefined) {
|
|
61
63
|
return {
|
|
62
64
|
result: undefined,
|
|
63
65
|
error: outcome.error,
|
|
@@ -59,7 +59,11 @@ async function run(payload) {
|
|
|
59
59
|
activeJobId = payload.id;
|
|
60
60
|
try {
|
|
61
61
|
const providers = payload.providers.map(({ name, prelude }) => provider(name, payload.id, prelude));
|
|
62
|
-
const raw = await executeQuickJs(payload.code, providers, payload.options)
|
|
62
|
+
const raw = await executeQuickJs(payload.code, providers, payload.options, (entry) => send({
|
|
63
|
+
type: "log",
|
|
64
|
+
jobId: payload.id,
|
|
65
|
+
payloadJson: stringifyBounded(entry, "QuickJS log entry"),
|
|
66
|
+
}));
|
|
63
67
|
const prepared = {
|
|
64
68
|
outcome: prepareExecuteResultForTransport(raw),
|
|
65
69
|
...(raw.timedOut ? { timedOut: true } : {}),
|
|
@@ -22,4 +22,4 @@ export interface QuickJsExecutionResult extends ExecuteResult {
|
|
|
22
22
|
* CPU accumulates only while evalCode/executePendingJobs is synchronously
|
|
23
23
|
* driving QuickJS, so a slow downstream does not consume the short CPU budget.
|
|
24
24
|
*/
|
|
25
|
-
export declare function executeQuickJs(code: string, providers: ExecutorProvider[], options: QuickJsRuntimeOptions): Promise<QuickJsExecutionResult>;
|
|
25
|
+
export declare function executeQuickJs(code: string, providers: ExecutorProvider[], options: QuickJsRuntimeOptions, onLog?: (entry: string) => void): Promise<QuickJsExecutionResult>;
|