@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/dist/auth/bearer.js +2 -0
  3. package/dist/auth/downstream-oauth.d.ts +12 -1
  4. package/dist/auth/downstream-oauth.js +147 -35
  5. package/dist/call-admission.d.ts +4 -0
  6. package/dist/call-admission.js +26 -0
  7. package/dist/catalog-drift.js +9 -4
  8. package/dist/catalog-service.d.ts +2 -0
  9. package/dist/catalog-service.js +25 -8
  10. package/dist/catalog.d.ts +2 -0
  11. package/dist/catalog.js +246 -121
  12. package/dist/connectors/api.js +11 -1
  13. package/dist/connectors/guarded-fetch.d.ts +1 -1
  14. package/dist/connectors/guarded-fetch.js +27 -20
  15. package/dist/connectors/remote-mcp.js +84 -53
  16. package/dist/errors.d.ts +17 -0
  17. package/dist/errors.js +58 -0
  18. package/dist/execute.js +85 -23
  19. package/dist/executor-result.js +3 -1
  20. package/dist/executors/quickjs-child.js +5 -1
  21. package/dist/executors/quickjs-protocol.d.ts +4 -0
  22. package/dist/executors/quickjs-runtime.d.ts +1 -1
  23. package/dist/executors/quickjs-runtime.js +38 -21
  24. package/dist/executors/quickjs.js +68 -27
  25. package/dist/index.d.ts +14 -0
  26. package/dist/index.js +24 -3
  27. package/dist/invocation.js +134 -93
  28. package/dist/mcp-result.js +3 -2
  29. package/dist/meta-tools.js +118 -39
  30. package/dist/registry.d.ts +14 -2
  31. package/dist/registry.js +87 -13
  32. package/dist/routes/mcp.d.ts +4 -1
  33. package/dist/routes/mcp.js +84 -13
  34. package/dist/routes/oauth.js +4 -0
  35. package/dist/routes/shared.d.ts +1 -0
  36. package/dist/routes/shared.js +4 -4
  37. package/dist/server.js +15 -3
  38. package/dist/skills.js +6 -5
  39. package/dist/storage/file.d.ts +6 -2
  40. package/dist/storage/file.js +312 -34
  41. package/dist/storage/memory.js +12 -1
  42. package/dist/validate.js +3 -3
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/documentation/architecture.md +22 -6
  46. package/documentation/auth.md +42 -9
  47. package/documentation/call-admission.md +24 -8
  48. package/documentation/code-mode.md +34 -22
  49. package/documentation/connectors.md +47 -5
  50. package/documentation/meta-tools.md +74 -6
  51. package/documentation/operations.md +19 -19
  52. package/documentation/provider-conventions.md +7 -0
  53. package/documentation/request-admission.md +38 -4
  54. package/documentation/storage-and-credentials.md +54 -1
  55. package/documentation/upgrading.md +18 -4
  56. package/package.json +1 -1
  57. 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
- const response = await baseFetch(current, {
270
- ...init,
271
- redirect: "manual",
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
- const closeHalf = async (state) => {
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
- try {
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
- await closeHalf(state);
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
- reset(state);
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
- await closeHalf(state);
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 = async (owner) => {
633
- try {
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
- await abandon(t);
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
- await abandon(c);
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
- await abandon(c);
732
+ abandon(c, t);
689
733
  if (generation !== genAtStart) {
690
- try {
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
- await abandon(c);
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
- await closeHalf(state);
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
- // Ask the downstream to drop its session first — closing only aborts our
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
- reset(state);
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
- throw guestFailure("budget_exceeded", `connecta.emit block-count budget exceeded: ${this.maxBlocks} block(s) maximum, 0 remaining`);
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)); } catch {}
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
- search: async (raw) => timedCatalog("search", () => typedDiscovery(async () => {
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: async (raw) => timedCatalog("describe", () => typedDiscovery(async () => {
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 framed = framedGuestFailure(failureSecret, err);
324
- limits.onInvocationFailure?.(err);
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 && outcome.logs.length > 0
444
- ? truncateExecuteText(outcome.logs.join("\n"), MAX_EXECUTE_LOG_CHARS)
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) => [candidate.message, guestFailureFrames.get(candidate)].includes(outcome.error),
461
- (candidate) => [candidate.message, guestFailureFrames.get(candidate)].some((message) => message !== undefined && outcome.error?.includes(message) === true),
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
- // Handed back whole, with no size guard of its own — the failure was
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") {
@@ -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
- if (outcome.error) {
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 } : {}),
@@ -29,6 +29,10 @@ export type ChildToParentMessage = {
29
29
  jobId: number;
30
30
  callId: number;
31
31
  payloadJson: string;
32
+ } | {
33
+ type: "log";
34
+ jobId: number;
35
+ payloadJson: string;
32
36
  } | {
33
37
  type: "result";
34
38
  jobId: number;
@@ -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>;