@zackbart/connecta 0.24.4 → 0.26.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.
Files changed (155) hide show
  1. package/AGENTS.md +43 -12
  2. package/CHANGELOG.md +524 -0
  3. package/README.md +32 -15
  4. package/SECURITY.md +6 -5
  5. package/bin/connecta.mjs +5 -1
  6. package/dist/activity.d.ts +10 -3
  7. package/dist/activity.js +10 -1
  8. package/dist/advertised-schema.js +53 -0
  9. package/dist/artifacts/connector.d.ts +55 -0
  10. package/dist/artifacts/connector.js +724 -0
  11. package/dist/artifacts/document.js +76 -0
  12. package/dist/artifacts/guide.js +140 -0
  13. package/dist/artifacts/html-scan.js +329 -0
  14. package/dist/artifacts/json.js +68 -0
  15. package/dist/artifacts/kv-store.d.ts +20 -0
  16. package/dist/artifacts/kv-store.js +140 -0
  17. package/dist/artifacts/markdown.js +449 -0
  18. package/dist/artifacts/module.d.ts +37 -0
  19. package/dist/artifacts/module.js +60 -0
  20. package/dist/artifacts/operations.d.ts +195 -0
  21. package/dist/artifacts/operations.js +944 -0
  22. package/dist/artifacts/refresh.d.ts +53 -0
  23. package/dist/artifacts/refresh.js +235 -0
  24. package/dist/artifacts/routes.js +194 -0
  25. package/dist/artifacts/types.d.ts +214 -0
  26. package/dist/artifacts/types.js +22 -0
  27. package/dist/artifacts/validate.d.ts +62 -0
  28. package/dist/artifacts/validate.js +652 -0
  29. package/dist/artifacts.d.ts +6 -0
  30. package/dist/artifacts.js +9 -0
  31. package/dist/auth/downstream-oauth.js +490 -106
  32. package/dist/call-admission.js +139 -113
  33. package/dist/catalog-service.js +152 -106
  34. package/dist/catalog.js +297 -38
  35. package/dist/concurrency.js +24 -23
  36. package/dist/connector-access.d.ts +18 -4
  37. package/dist/connector-access.js +65 -20
  38. package/dist/connector-caller.d.ts +8 -0
  39. package/dist/connector-caller.js +19 -0
  40. package/dist/connector-scope.js +6 -37
  41. package/dist/connectors/api.d.ts +2 -2
  42. package/dist/connectors/remote-mcp.d.ts +6 -5
  43. package/dist/connectors/remote-mcp.js +330 -240
  44. package/dist/credential-contract.d.ts +9 -0
  45. package/dist/credential-rules.js +2 -2
  46. package/dist/credentials.d.ts +6 -1
  47. package/dist/credentials.js +45 -3
  48. package/dist/errors.d.ts +16 -1
  49. package/dist/errors.js +28 -0
  50. package/dist/execute.js +745 -282
  51. package/dist/executor-admission.d.ts +11 -1
  52. package/dist/executor-admission.js +145 -63
  53. package/dist/executors/quickjs-runtime.js +4 -2
  54. package/dist/executors/quickjs.js +255 -212
  55. package/dist/fix-prompt.js +37 -0
  56. package/dist/index.d.ts +72 -7
  57. package/dist/index.js +281 -26
  58. package/dist/invocation.js +371 -286
  59. package/dist/meta-tools.js +213 -94
  60. package/dist/module-contracts.d.ts +21 -1
  61. package/dist/oauth-callback-outcome.js +94 -0
  62. package/dist/oauth-sealing.js +29 -0
  63. package/dist/operator-ui/fix-prompts.js +87 -0
  64. package/dist/operator-ui/generated.js +2 -2
  65. package/dist/operator-ui/model.d.ts +103 -1
  66. package/dist/operator-ui/setup-commands.js +53 -0
  67. package/dist/operator-ui/view.js +128 -3
  68. package/dist/program-source.js +321 -0
  69. package/dist/providers/notion.js +2 -2
  70. package/dist/providers/revenuecat.js +32 -5
  71. package/dist/providers/stripe.d.ts +3 -2
  72. package/dist/providers/vercel.js +1 -1
  73. package/dist/registry.d.ts +43 -7
  74. package/dist/registry.js +434 -288
  75. package/dist/resumable.js +1291 -0
  76. package/dist/routes/activity.js +74 -154
  77. package/dist/routes/actor-labels.js +114 -0
  78. package/dist/routes/credentials.js +196 -163
  79. package/dist/routes/mcp.js +228 -112
  80. package/dist/routes/oauth-management.js +73 -71
  81. package/dist/routes/oauth.js +44 -16
  82. package/dist/routes/operator.js +83 -0
  83. package/dist/routes/shared.d.ts +25 -1
  84. package/dist/routes/shared.js +20 -2
  85. package/dist/routes/ui-data.js +211 -0
  86. package/dist/routes/ui.js +76 -35
  87. package/dist/run-journal.js +347 -0
  88. package/dist/runtime/admission.js +43 -0
  89. package/dist/runtime/call-admission.js +67 -0
  90. package/dist/runtime/connector-scope.js +72 -0
  91. package/dist/runtime/run.js +174 -0
  92. package/dist/runtime/services.js +130 -0
  93. package/dist/runtime/shared-read.js +106 -0
  94. package/dist/runtime/storage.js +66 -0
  95. package/dist/server.js +137 -89
  96. package/dist/skills.js +28 -9
  97. package/dist/storage/file.js +39 -2
  98. package/dist/storage/memory.js +28 -16
  99. package/dist/timeout.js +39 -43
  100. package/dist/tool-safety.d.ts +27 -1
  101. package/dist/tool-safety.js +31 -0
  102. package/dist/types.d.ts +45 -7
  103. package/dist/ui.d.ts +25 -4
  104. package/dist/ui.js +77 -167
  105. package/dist/url-safety.js +157 -0
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/documentation/architecture.md +367 -16
  109. package/documentation/auth.md +163 -2
  110. package/documentation/code-mode.md +274 -56
  111. package/documentation/meta-tools.md +200 -44
  112. package/ethos.md +46 -47
  113. package/examples/worker/AGENTS.md +6 -0
  114. package/examples/worker/README.md +224 -14
  115. package/examples/worker/src/d1-activity-row.ts +14 -2
  116. package/examples/worker/src/d1-activity.ts +4 -2
  117. package/examples/worker/src/d1-storage.ts +116 -0
  118. package/examples/worker/src/index.ts +42 -7
  119. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  120. package/examples/worker/wrangler.jsonc +21 -5
  121. package/package.json +16 -4
  122. package/templates/node/AGENTS.md +9 -2
  123. package/templates/node/README.md +31 -3
  124. package/templates/node/package.json +1 -1
  125. package/templates/node/src/index.ts +27 -0
  126. package/dist/auth/downstream-oauth.d.ts +0 -200
  127. package/dist/catalog-fingerprint.d.ts +0 -19
  128. package/dist/catalog-limits.d.ts +0 -9
  129. package/dist/catalog-service.d.ts +0 -263
  130. package/dist/catalog.d.ts +0 -87
  131. package/dist/concurrency.d.ts +0 -3
  132. package/dist/connectors/guarded-fetch.d.ts +0 -96
  133. package/dist/credential-rules.d.ts +0 -85
  134. package/dist/execute.d.ts +0 -154
  135. package/dist/executor-result.d.ts +0 -10
  136. package/dist/executors/quickjs-child.d.ts +0 -1
  137. package/dist/executors/quickjs-protocol.d.ts +0 -67
  138. package/dist/favicon.d.ts +0 -1
  139. package/dist/identity.d.ts +0 -4
  140. package/dist/invocation.d.ts +0 -74
  141. package/dist/mcp-result.d.ts +0 -8
  142. package/dist/meta-tools.d.ts +0 -129
  143. package/dist/operator-ui/generated.d.ts +0 -2
  144. package/dist/operator-ui/view.d.ts +0 -188
  145. package/dist/result-shapes.d.ts +0 -13
  146. package/dist/retired-toolkits.d.ts +0 -2
  147. package/dist/routes/activity.d.ts +0 -2
  148. package/dist/routes/credentials.d.ts +0 -2
  149. package/dist/routes/mcp.d.ts +0 -10
  150. package/dist/routes/oauth-management.d.ts +0 -2
  151. package/dist/routes/oauth.d.ts +0 -2
  152. package/dist/routes/ui.d.ts +0 -2
  153. package/dist/server.d.ts +0 -9
  154. package/dist/skills.d.ts +0 -44
  155. package/dist/timeout.d.ts +0 -21
package/dist/execute.js CHANGED
@@ -1,14 +1,31 @@
1
+ import { Cause, Deferred, Duration, Effect, Exit } from "effect";
1
2
  import { z } from "zod";
3
+ import { advertisedSchema } from "./advertised-schema.js";
2
4
  import { boundedDiscoveryText, CatalogService, DiscoveryPolicyError, flatSearchResult, } from "./catalog-service.js";
3
5
  import { errorResult, jsonResult } from "./meta-tools.js";
4
6
  import { guardExecuteResultValue, MAX_EXECUTE_LOG_CHARS, truncateExecuteText, } from "./executor-result.js";
5
7
  import { ExecutorAdmissionError, ExecutorExecutionError, isAdmittingExecutor, } from "./executor-admission.js";
6
8
  import { boundedEchoText, msg } from "./errors.js";
7
- import { InvocationFailure, InvocationService, } from "./invocation.js";
9
+ import { InvocationFailure, InvocationService, timed, } from "./invocation.js";
10
+ import { normalizeProgramSource } from "./program-source.js";
11
+ import { DEFAULT_MAX_WRITES, ExemptWrites, MIN_STORAGE_TIMEOUT_MS, RunState, writeStateOf, } from "./resumable.js";
12
+ import { journalKey } from "./run-journal.js";
13
+ import { underAnySignal } from "./timeout.js";
14
+ import { isApprovalExempt, NO_EXEMPTIONS, } from "./tool-safety.js";
15
+ import { fromSignal, runEdge } from "./runtime/run.js";
8
16
  import { connectorGuide, connectorGuideRequired, connectorSkillName, hasConnectorGuides, } from "./skills.js";
9
17
  /** Keep one model-written program from amplifying into an unbounded fan-out. */
10
18
  const EXECUTE_MAX_HOST_CALLS = 20;
11
19
  const EXECUTE_HOST_CALL_TIMEOUT_MS = 15_000;
20
+ /** Above every program in the recorded evals, below the QuickJS IPC ceiling. */
21
+ const EXECUTE_MAX_CODE_BYTES = 64 * 1024;
22
+ /**
23
+ * The outer ceiling on one execution. It sits above both executors' own
24
+ * deadlines (QuickJS 30s, the Dynamic Worker 60s), so a healthy executor
25
+ * always reports its own timeout first and this fires only for one that
26
+ * never settles at all.
27
+ */
28
+ const EXECUTE_WATCHDOG_MS = 120_000;
12
29
  /** Complete entries plus an exact omission count, all inside this byte cap. */
13
30
  export const CONNECTOR_INVENTORY_MAX_BYTES = 256;
14
31
  /**
@@ -261,11 +278,147 @@ function guestErrorPrelude(failureSecret) {
261
278
  });
262
279
  })(${JSON.stringify(`${GUEST_FAILURE_FRAME}${failureSecret}:`)});`;
263
280
  }
281
+ /** A clock read now and a seed drawn from the host's CSPRNG. */
282
+ function freshEnvironment() {
283
+ const words = new Uint32Array(4);
284
+ crypto.getRandomValues(words);
285
+ return {
286
+ clockMs: Date.now(),
287
+ seed: [words[0] ?? 0, words[1] ?? 0, words[2] ?? 0, words[3] ?? 0],
288
+ };
289
+ }
290
+ /**
291
+ * Trusted guest code installing the pinned clock and stream, evaluated after
292
+ * the error prelude and before the program. Every replacement is locked the
293
+ * way `Error` is — non-writable and non-configurable — so a program can read
294
+ * the pinned values and nothing else.
295
+ *
296
+ * `Date` becomes a wrapper whose argument-free forms (`new Date()`, `Date()`,
297
+ * `Date.now()`) read the pinned instant; with arguments it is the native
298
+ * constructor, and instances are ordinary dates. The native constructor's own
299
+ * `now` and the prototype's `constructor` are pinned too, so the usual ways
300
+ * back to it read the same instant. `Math.random` is sfc32 over the seed:
301
+ * small, fast, and specified in a few lines anyone can check. Where the
302
+ * runtime has them (a Dynamic Worker; QuickJS has neither), `crypto`'s
303
+ * `getRandomValues` and `randomUUID` draw from the same stream and
304
+ * `performance.now()` stays at 0, the run's start. None of that makes
305
+ * `crypto` contract (`P2`): it only keeps a Workers-only program from being
306
+ * the one that cannot replay.
307
+ */
308
+ function pinnedEnvironmentPrelude(environment) {
309
+ return `((clock, seed) => {
310
+ const NativeDate = globalThis.Date;
311
+ const construct = Reflect.construct;
312
+ const defineProperty = Object.defineProperty;
313
+ const lock = (target, key, value) => defineProperty(target, key, {
314
+ value, writable: false, enumerable: false, configurable: false
315
+ });
316
+ let a = seed[0] | 0, b = seed[1] | 0, c = seed[2] | 0, d = seed[3] | 0;
317
+ const next = () => {
318
+ const t = (((a + b) | 0) + d) | 0;
319
+ d = (d + 1) | 0;
320
+ a = b ^ (b >>> 9);
321
+ b = (c + (c << 3)) | 0;
322
+ c = (c << 21) | (c >>> 11);
323
+ c = (c + t) | 0;
324
+ return t >>> 0;
325
+ };
326
+ const now = function now() { return clock; };
327
+ function Date(...args) {
328
+ if (new.target === undefined) return new NativeDate(clock).toString();
329
+ return construct(NativeDate, args.length === 0 ? [clock] : args, new.target);
330
+ }
331
+ Date.prototype = NativeDate.prototype;
332
+ lock(Date, "now", now);
333
+ lock(Date, "parse", NativeDate.parse);
334
+ lock(Date, "UTC", NativeDate.UTC);
335
+ lock(NativeDate, "now", now);
336
+ lock(NativeDate.prototype, "constructor", Date);
337
+ lock(globalThis, "Date", Date);
338
+ lock(Math, "random", function random() { return next() / 4294967296; });
339
+ // Lock the replacement on the object and on its prototype, where the
340
+ // native method lives: \`Object.getPrototypeOf(crypto).getRandomValues\`
341
+ // would otherwise still reach the real stream. A prototype the runtime
342
+ // will not let us redefine keeps its native method (\`tryLock\`), and a
343
+ // program that uses it diverges on replay rather than sending anything.
344
+ const tryLock = (target, key, value) => {
345
+ try { lock(target, key, value); } catch {}
346
+ };
347
+ if (typeof crypto === "object" && crypto !== null) {
348
+ const getRandomValues = function getRandomValues(array) {
349
+ const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
350
+ for (let i = 0; i < bytes.length; i++) bytes[i] = next() >>> 24;
351
+ return array;
352
+ };
353
+ const randomUUID = function randomUUID() {
354
+ const bytes = getRandomValues(new Uint8Array(16));
355
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
356
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
357
+ let hex = "";
358
+ for (let i = 0; i < 16; i++) {
359
+ hex += (bytes[i] + 0x100).toString(16).slice(1);
360
+ if (i === 3 || i === 5 || i === 7 || i === 9) hex += "-";
361
+ }
362
+ return hex;
363
+ };
364
+ lock(crypto, "getRandomValues", getRandomValues);
365
+ lock(crypto, "randomUUID", randomUUID);
366
+ const cryptoProto = Object.getPrototypeOf(crypto);
367
+ if (cryptoProto) {
368
+ tryLock(cryptoProto, "getRandomValues", getRandomValues);
369
+ tryLock(cryptoProto, "randomUUID", randomUUID);
370
+ }
371
+ }
372
+ if (typeof performance === "object" && performance !== null) {
373
+ const perfNow = function now() { return 0; };
374
+ lock(performance, "now", perfNow);
375
+ const performanceProto = Object.getPrototypeOf(performance);
376
+ if (performanceProto) tryLock(performanceProto, "now", perfNow);
377
+ }
378
+ // An Intl formatter asked for "now" (no date) reads the native clock.
379
+ if (typeof Intl === "object" && Intl !== null && typeof Intl.DateTimeFormat === "function") {
380
+ const proto = Intl.DateTimeFormat.prototype;
381
+ const formatGetter = Object.getOwnPropertyDescriptor(proto, "format");
382
+ if (formatGetter && typeof formatGetter.get === "function") {
383
+ const nativeFormat = formatGetter.get;
384
+ try {
385
+ defineProperty(proto, "format", {
386
+ get() {
387
+ const bound = nativeFormat.call(this);
388
+ return function format(date) { return bound(date === undefined ? clock : date); };
389
+ },
390
+ enumerable: false,
391
+ configurable: false,
392
+ });
393
+ } catch {}
394
+ }
395
+ const nativeFormatToParts = proto.formatToParts;
396
+ if (typeof nativeFormatToParts === "function") {
397
+ tryLock(proto, "formatToParts", function formatToParts(date) {
398
+ return nativeFormatToParts.call(this, date === undefined ? clock : date);
399
+ });
400
+ }
401
+ }
402
+ })(${JSON.stringify(environment.clockMs)}, ${JSON.stringify([...environment.seed])});`;
403
+ }
264
404
  /**
265
405
  * Expose one host provider and typed guest errors. Catalogs load only when
266
406
  * the program calls a tool or asks search/describe.
267
407
  */
268
408
  export async function buildSandboxProviders(registry, baseUrl, _logger, activity, limits = {}) {
409
+ return [sandboxProvider(registry, baseUrl, activity, limits)];
410
+ }
411
+ /**
412
+ * The `connecta` provider for one execution.
413
+ *
414
+ * Building it is synchronous and loads nothing. Each function is a Promise
415
+ * edge the executor awaits, and behind it one host call is one fiber: spend
416
+ * the budget, do the operation, and turn a typed failure into the frame the
417
+ * prelude rebuilds inside the guest. Nothing is shared between those fibers
418
+ * but the budget counter and this request's catalog, so a call the program
419
+ * never awaits cannot disturb the others.
420
+ */
421
+ function sandboxProvider(registry, baseUrl, activity, limits) {
269
422
  // All host calls made by one execute_code invocation share a downstream
270
423
  // connection, while a later invocation receives a fresh request scope.
271
424
  const requestScope = {};
@@ -276,76 +429,208 @@ export async function buildSandboxProviders(registry, baseUrl, _logger, activity
276
429
  concurrency: limits.discoveryConcurrency,
277
430
  probeTimeoutMs: limits.probeTimeoutMs,
278
431
  defer: limits.defer,
432
+ approval: limits.approval,
279
433
  });
280
434
  const invocation = new InvocationService(registry, catalog, activity);
281
435
  const maxHostCalls = Math.max(1, Math.trunc(limits.maxHostCalls ?? EXECUTE_MAX_HOST_CALLS));
282
436
  const hostCallTimeoutMs = Math.max(1, Math.trunc(limits.hostCallTimeoutMs ?? EXECUTE_HOST_CALL_TIMEOUT_MS));
283
437
  const failureSecret = guestFailureSecret();
438
+ const { signal, diagnostics } = limits;
284
439
  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
- };
292
- const invocationContext = () => ({
293
- source: "execute_code",
440
+ // L4/M7: discovery and invocation spend the same budget, on entry; emit
441
+ // does not.
442
+ const spendHostCall = Effect.suspend(() => ++hostCalls > maxHostCalls
443
+ ? Effect.fail(guestFailure("budget_exceeded", `execute_code host-call budget exceeded (${maxHostCalls} calls maximum)`))
444
+ : Effect.void);
445
+ const { runState, exemptWrites } = limits;
446
+ const approval = limits.approval ?? NO_EXEMPTIONS;
447
+ const maxWrites = resolveBudget(limits.maxWrites, DEFAULT_MAX_WRITES);
448
+ let exemptWriteCount = 0;
449
+ /**
450
+ * Without resumable writes nothing can pause, but a config exemption
451
+ * (#566) still lets its writes run: the gate covers exactly the exempt
452
+ * calls, spends the write budget on each, and every other write keeps
453
+ * E4's refusal, ahead of validation as it always was. A dispatched write
454
+ * is tracked until it settles (`ExemptWrites`), so the play can wait for
455
+ * it rather than abort it.
456
+ */
457
+ const exemptOnly = (sending) => ({
458
+ gates: (target) => isApprovalExempt(approval, target.connector, target.toolName, target.definition),
459
+ writeGate: (target) => Effect.sync(() => {
460
+ if (exemptWrites?.isClosed) {
461
+ return {
462
+ kind: "refuse",
463
+ error: {
464
+ code: "cancelled",
465
+ message: "The program had already returned, so this write was not sent.",
466
+ retryable: false,
467
+ },
468
+ activity: "none",
469
+ };
470
+ }
471
+ if (exemptWriteCount >= maxWrites) {
472
+ return {
473
+ kind: "refuse",
474
+ error: {
475
+ code: "budget_exceeded",
476
+ message: `execute_code write budget exceeded (${maxWrites} writes maximum, execute.maxWrites); ${target.connector.id}.${target.toolName} was not sent`,
477
+ retryable: false,
478
+ },
479
+ };
480
+ }
481
+ exemptWriteCount++;
482
+ if (exemptWrites)
483
+ sending.settle = exemptWrites.begin();
484
+ return { kind: "dispatch" };
485
+ }),
486
+ });
487
+ const invocationContext = (seq, key, sending) => ({
488
+ source: runState?.source ?? "execute_code",
294
489
  timeoutMs: hostCallTimeoutMs,
295
- ...(limits.signal !== undefined ? { requestSignal: limits.signal } : {}),
490
+ ...(signal !== undefined ? { requestSignal: signal } : {}),
296
491
  unwrapResult: true,
492
+ // With resumable writes, a consequential call reaches the run's gate
493
+ // instead of the flat E4 refusal, and a read's decision is marked the
494
+ // moment it passes the same point.
495
+ ...(runState && seq !== undefined && key !== undefined
496
+ ? {
497
+ beforeDispatch: () => runState.decide(seq),
498
+ writeGate: (target, args) => runState.gate(seq, key, target, args),
499
+ }
500
+ : runState
501
+ ? {}
502
+ : exemptOnly(sending)),
297
503
  });
298
504
  /**
299
- * Discovery policy failures use the same thrown vocabulary as calls and
300
- * utilities. The transport below reconstructs their code inside the guest.
505
+ * The front of every numbered host call once resumable writes are on: a
506
+ * stopped run makes no call at all (not journaled, no activity), a
507
+ * recorded one is answered from the journal before the budget-free part
508
+ * of the path, and one the journal cannot account for is divergence.
509
+ * Replayed calls spend the host-call budget again, so a play's total is
510
+ * the run's total and nothing is counted twice.
301
511
  */
302
- const typedDiscovery = async (operation) => {
303
- try {
304
- return await operation();
512
+ const fromJournal = (seq, op, address, args) => Effect.gen(function* () {
513
+ const halted = runState?.halted();
514
+ if (halted)
515
+ return yield* Effect.fail(halted);
516
+ yield* spendHostCall;
517
+ if (!runState || seq === undefined)
518
+ return { kind: "live", key: undefined };
519
+ const key = journalKey(op, address, args);
520
+ const found = runState.lookup(key);
521
+ if (found.kind === "diverged") {
522
+ return yield* Effect.fail(runState.diverge(found.reason));
305
523
  }
306
- catch (err) {
307
- if (err instanceof DiscoveryPolicyError) {
308
- throw guestFailure(err.code, err.message);
524
+ if (found.kind === "live")
525
+ return { kind: "live", key };
526
+ runState.decide(seq);
527
+ const { outcome } = found.entry;
528
+ return outcome.ok
529
+ ? { kind: "replayed", value: outcome.value }
530
+ : yield* Effect.fail(new InvocationFailure(outcome.error));
531
+ });
532
+ // A call brings its own cancellation: the invocation pipeline reads the
533
+ // run's signal, refuses a call that starts after it, and records the
534
+ // cancelled attempt in activity like any other outcome.
535
+ const call = (seq, address, args) => Effect.gen(function* () {
536
+ const addressText = String(address);
537
+ const callArgs = args ?? {};
538
+ const front = yield* fromJournal(seq, "call", addressText, callArgs);
539
+ if (front.kind === "replayed")
540
+ return front.value;
541
+ // An exempt write dispatched without a run state settles here —
542
+ // unknown if the call never returned an outcome.
543
+ const sending = {};
544
+ const outcome = yield* invocation.pipeline(addressText, callArgs, invocationContext(seq, front.key, sending)).pipe(Effect.onExit((exit) => Effect.sync(() => sending.settle?.(Exit.isSuccess(exit) ? writeStateOf(exit.value) : "unknown"))));
545
+ diagnostics?.recordCall(outcome);
546
+ if (runState && seq !== undefined && front.key !== undefined) {
547
+ if (runState.isLiveWrite(seq)) {
548
+ const resolved = outcome.resolved;
549
+ const replaced = yield* runState.settleWrite(seq, front.key, {
550
+ address: resolved
551
+ ? `${resolved.connector.id}.${resolved.toolName}`
552
+ : addressText,
553
+ args: callArgs,
554
+ }, outcome);
555
+ if (replaced)
556
+ return yield* Effect.fail(replaced);
557
+ }
558
+ else {
559
+ runState.record(seq, "call", front.key, outcome.ok
560
+ ? { ok: true, value: outcome.value }
561
+ : { ok: false, error: outcome.error });
309
562
  }
310
- throw err;
311
563
  }
312
- };
313
- const timedCatalog = async (operation, fn) => {
564
+ return outcome.ok
565
+ ? outcome.value
566
+ : yield* Effect.fail(new InvocationFailure(outcome.error));
567
+ });
568
+ // Discovery gets the same treatment from here (L2): once the run has
569
+ // ended no search or describe starts, and one still in flight fails
570
+ // `cancelled` instead of holding the program until its probe deadline.
571
+ // Policy failures use the same thrown vocabulary as calls and utilities;
572
+ // the transport below reconstructs their code inside the guest.
573
+ const discovery = (operation, seq, raw, read) => Effect.suspend(() => {
314
574
  const started = Date.now();
315
- try {
316
- spendHostCall();
317
- const result = await fn();
318
- limits.diagnostics?.recordCatalog(operation, Date.now() - started, true, result);
319
- return result;
320
- }
321
- catch (err) {
322
- limits.diagnostics?.recordCatalog(operation, Date.now() - started, false);
323
- throw err;
324
- }
325
- };
326
- const callAddress = async (address, args) => {
327
- spendHostCall();
328
- const outcome = await invocation.invoke(String(address), args ?? {}, invocationContext());
329
- limits.diagnostics?.recordCall(outcome);
330
- if (!outcome.ok)
331
- throw new InvocationFailure(outcome.error);
332
- return outcome.value;
333
- };
334
- const fns = {
335
- call: (address, args) => callAddress(address, args),
575
+ const cancelled = () => guestFailure("cancelled", `connecta.${operation} was cancelled because the run ended.`);
576
+ const reading = Effect.tryPromise({
577
+ try: read,
578
+ catch: (err) => err instanceof DiscoveryPolicyError
579
+ ? guestFailure(err.code, err.message)
580
+ : err,
581
+ });
582
+ return fromJournal(seq, operation, "", raw ?? {}).pipe(Effect.flatMap((front) => {
583
+ if (front.kind === "replayed")
584
+ return Effect.succeed(front.value);
585
+ const live = (!signal
586
+ ? reading
587
+ : signal.aborted
588
+ ? Effect.fail(cancelled())
589
+ : Effect.raceAllFirst([
590
+ reading,
591
+ fromSignal(signal).pipe(Effect.mapError(cancelled)),
592
+ ])).pipe(Effect.onExit((exit) => Effect.sync(() => {
593
+ if (!runState || seq === undefined || front.key === undefined)
594
+ return;
595
+ // Typed outcomes replay; an untyped throw is a bug, not an
596
+ // answer, and is not journaled — so a replay that re-issues
597
+ // the call before repeating the approved write finds no record
598
+ // and fails `execution_diverged`.
599
+ if (Exit.isSuccess(exit)) {
600
+ runState.record(seq, operation, front.key, {
601
+ ok: true,
602
+ value: exit.value,
603
+ });
604
+ return;
605
+ }
606
+ const error = Cause.squash(exit.cause);
607
+ if (error instanceof InvocationFailure) {
608
+ runState.record(seq, operation, front.key, {
609
+ ok: false,
610
+ error: error.details,
611
+ });
612
+ }
613
+ })));
614
+ return live;
615
+ }), Effect.onExit((exit) => Effect.sync(() => diagnostics?.recordCatalog(operation, Date.now() - started, Exit.isSuccess(exit), Exit.isSuccess(exit) ? exit.value : undefined))));
616
+ });
617
+ const operations = {
618
+ call,
336
619
  // Emission is a provider function, never an ExecuteResult field —
337
620
  // that is what keeps the Executor contract untouched and parity
338
621
  // structural (M8). It spends no host-call budget (M7); its own
339
- // budgets live in the collector.
340
- emit: async (block) => {
341
- if (!limits.emitCollector) {
342
- throw guestFailure("unavailable", "connecta.emit is unavailable: no emission collector was configured for this execution", true);
343
- }
344
- limits.emitCollector.accept(block);
345
- },
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 () => {
622
+ // budgets live in the collector. It is never journaled: a replay emits
623
+ // again, and only the play that completes delivers.
624
+ emit: (_seq, block) => Effect.try({
625
+ try: () => {
626
+ if (!limits.emitCollector) {
627
+ throw guestFailure("unavailable", "connecta.emit is unavailable: no emission collector was configured for this execution", true);
628
+ }
629
+ limits.emitCollector.accept(block);
630
+ },
631
+ catch: (err) => err,
632
+ }),
633
+ search: (seq, raw) => discovery("search", seq, raw, async () => {
349
634
  const args = (raw ?? {});
350
635
  const result = flatSearchResult(await catalog.search({
351
636
  ...args,
@@ -353,230 +638,413 @@ export async function buildSandboxProviders(registry, baseUrl, _logger, activity
353
638
  }));
354
639
  boundedDiscoveryText(result, "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.");
355
640
  return result;
356
- })),
357
- describe: (raw) => timedCatalog("describe", () => typedDiscovery(async () => {
641
+ }),
642
+ describe: (seq, raw) => discovery("describe", seq, raw, async () => {
358
643
  const args = (raw ?? {});
359
644
  const result = { tools: await catalog.describe(args) };
360
645
  boundedDiscoveryText(result, 'Split the address list or use format: "compact".');
361
646
  return result;
362
- })),
647
+ }),
363
648
  };
364
- const transportedFns = Object.fromEntries(Object.entries(fns).map(([name, fn]) => [
365
- name,
366
- async (...args) => {
367
- try {
368
- return await fn(...args);
369
- }
370
- catch (err) {
371
- if (err instanceof InvocationFailure) {
372
- const failure = boundedGuestFailure(err);
373
- const framed = framedGuestFailure(failureSecret, failure);
374
- limits.onInvocationFailure?.(failure);
375
- throw framed;
376
- }
377
- throw err;
378
- }
379
- },
380
- ]));
381
- return [
382
- {
383
- name: "connecta",
384
- prelude: guestErrorPrelude(failureSecret),
385
- fns: transportedFns,
386
- },
649
+ // Every failure leaves through the same frame, so the prelude can rebuild
650
+ // a typed error however the host call failed — refused, over budget, or
651
+ // cancelled. Anything that is not an InvocationFailure crosses unchanged.
652
+ const framed = (err) => Effect.suspend(() => {
653
+ if (!(err instanceof InvocationFailure))
654
+ return Effect.fail(err);
655
+ const failure = boundedGuestFailure(err);
656
+ const frame = framedGuestFailure(failureSecret, failure);
657
+ limits.onInvocationFailure?.(failure);
658
+ return Effect.fail(frame);
659
+ });
660
+ return {
661
+ name: "connecta",
662
+ // Typed errors first, then the pinned clock and randomness: both are
663
+ // trusted host code the program runs after and cannot undo.
664
+ prelude: `${guestErrorPrelude(failureSecret)}\n${pinnedEnvironmentPrelude(runState?.environment ?? limits.environment ?? freshEnvironment())}`,
665
+ fns: Object.fromEntries(Object.entries(operations).map(([name, operation]) => [
666
+ name,
667
+ (...args) => {
668
+ // Numbered here, synchronously, as the program makes the call: the
669
+ // numbering is the program's own issue order, which is what makes
670
+ // a pause point and a replay reproducible.
671
+ const seq = runState && name !== "emit" ? runState.begin() : undefined;
672
+ const settled = runEdge(Effect.suspend(() => operation(seq, ...args)).pipe(Effect.catch(framed), Effect.ensuring(Effect.sync(() => {
673
+ if (seq !== undefined)
674
+ runState?.decide(seq);
675
+ }))));
676
+ if (seq !== undefined)
677
+ runState?.track(seq, settled);
678
+ // The executor owns this promise, and a program may abandon a call
679
+ // that the run's end then cancels before anyone has awaited it.
680
+ // The rejection is still there for whoever does; it is just not an
681
+ // unhandled rejection of the host's, which workerd reports as soon
682
+ // as a microtask passes without a handler.
683
+ settled.catch(() => { });
684
+ return settled;
685
+ },
686
+ ])),
687
+ };
688
+ }
689
+ /**
690
+ * Await an executor's promise without trusting it to settle.
691
+ *
692
+ * An executor's deadline is its own business, and the Dynamic Worker's lives
693
+ * inside the sandbox: a wedged isolate never fires it, so the call never
694
+ * settles, the handler never returns, and the admission lease is held for
695
+ * good. Two of those fill the default code pool and every later execute_code
696
+ * queues behind them (executor hit this in production). An `acquire()` that
697
+ * ignores its signal holds a cancelled request the same way.
698
+ *
699
+ * So the host races the promise against the request's signal and, for a run,
700
+ * a ceiling of its own. Cancellation settles as the same `executor_cancelled`
701
+ * the QuickJS pool reports, unless the executor reports its own first; the
702
+ * ceiling as an untyped executor failure naming the key that sets it. Either
703
+ * way the handler returns and releases the lease, and the abandoned call
704
+ * keeps whatever it was doing: the QuickJS lease release recycles its child,
705
+ * a Dynamic Worker isolate runs on to its own deadline, and a lease granted
706
+ * after its request gave up goes to `late`. The result contract is untouched
707
+ * — this only decides when to stop waiting for one.
708
+ */
709
+ function awaitExecutor(start, signal, options = {}) {
710
+ const contenders = [
711
+ Effect.suspend(() => {
712
+ let pending;
713
+ const settling = Effect.tryPromise({
714
+ try: () => (pending = Promise.resolve(start())),
715
+ catch: (err) => err,
716
+ });
717
+ const { late } = options;
718
+ return late
719
+ ? settling.pipe(Effect.onInterrupt(() => Effect.sync(() => {
720
+ pending?.then(late, () => { });
721
+ })))
722
+ : settling;
723
+ }),
724
+ // An executor that honors the signal rejects in the abort event itself,
725
+ // with the logs it captured attached, and that report beats ours by one
726
+ // timer turn. (A 1ms sleep, not 0: Effect.sleep(0) waits a microtask,
727
+ // which the executor's promise chain can still lose to.)
728
+ fromSignal(signal).pipe(Effect.catch(() => Effect.sleep(Duration.millis(1))), Effect.andThen(Effect.fail(new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled.")))),
387
729
  ];
730
+ const { watchdog } = options;
731
+ if (watchdog) {
732
+ contenders.push(Effect.sleep(Duration.millis(watchdog.ms)).pipe(Effect.andThen(Effect.suspend(() => {
733
+ watchdog.logger.warn("[connecta] execute_code executor did not settle; run abandoned", { watchdogMs: watchdog.ms });
734
+ return Effect.fail(new Error(`sandbox unresponsive: no outcome within the ${watchdog.ms}ms ` +
735
+ "execute.watchdogMs ceiling, so the run was abandoned"));
736
+ }))));
737
+ }
738
+ return Effect.raceAllFirst(contenders);
388
739
  }
389
- /** The execute_code handler. Exported for direct testing. */
390
- export function createExecuteTool(registry, baseUrl, executor, logger, activity, config = {}) {
391
- return async ({ code, diagnostics: diagnosticsRequested }, options = {}) => {
740
+ /**
741
+ * The run's own signal, aborted however the run ends. It follows the
742
+ * caller's, and aborting it on the way out is what releases outstanding host
743
+ * waits and tells cooperative connectors to stop.
744
+ */
745
+ function runSignal(caller) {
746
+ return Effect.acquireRelease(Effect.sync(() => {
392
747
  const controller = new AbortController();
393
- const forwardAbort = () => controller.abort(options.signal?.reason);
394
- if (options.signal?.aborted)
395
- forwardAbort();
396
- else {
397
- options.signal?.addEventListener("abort", forwardAbort, { once: true });
398
- }
399
- let lease;
400
- let outcome;
401
- const diagnostics = diagnosticsRequested ? new ExecuteDiagnostics() : undefined;
748
+ const forward = () => controller.abort(caller?.reason);
749
+ if (caller?.aborted)
750
+ forward();
751
+ else
752
+ caller?.addEventListener("abort", forward, { once: true });
753
+ return { controller, forward };
754
+ }), ({ controller, forward }) => Effect.sync(() => {
755
+ controller.abort();
756
+ caller?.removeEventListener("abort", forward);
757
+ })).pipe(Effect.map(({ controller }) => controller.signal));
758
+ }
759
+ /**
760
+ * An admitting executor's lease, released when the run's scope closes.
761
+ *
762
+ * `acquire()` is the executor's Promise, awaited as one on purpose. A queued
763
+ * request is handed its lease from inside whichever request released one, so
764
+ * a fiber resumed straight from the executor's admission queue would build
765
+ * this request's providers and run its program in the releasing request —
766
+ * which workerd refuses for any I/O. Awaiting the promise resumes it here.
767
+ */
768
+ function leased(executor, signal) {
769
+ return Effect.acquireRelease(awaitExecutor(() => executor.acquire({ signal }), signal, {
770
+ late: (lease) => lease.release(),
771
+ }), (lease) => Effect.sync(() => lease.release()));
772
+ }
773
+ /**
774
+ * Room a claim leaves beyond the watchdog and one host-call deadline, for the
775
+ * executor queue a resumed play waits in first. Only liveness rides on it: a
776
+ * claim that lapses early lets a takeover start, and the write-ahead mark
777
+ * still keeps any write from being sent twice.
778
+ */
779
+ const CLAIM_SLACK_MS = 10_000;
780
+ /** The runner behind both program tools. */
781
+ export function createProgramRunner(registry, baseUrl, executor, logger, activity, config = {}) {
782
+ const watchdog = {
783
+ ms: resolveBudget(config.watchdogMs, EXECUTE_WATCHDOG_MS),
784
+ logger,
785
+ };
786
+ const hostCallTimeoutMs = resolveBudget(config.hostCallTimeoutMs, EXECUTE_HOST_CALL_TIMEOUT_MS);
787
+ const storageTimeoutMs = resolveBudget(config.storageTimeoutMs, Math.max(hostCallTimeoutMs, MIN_STORAGE_TIMEOUT_MS));
788
+ /**
789
+ * One play of a program. Without a run state it is the execute_code run it
790
+ * always was. With one, the play also ends when the run stops — a pause or
791
+ * a typed failure — and then waits for what is still on the wire, persists
792
+ * what it must, and discards the program's own result.
793
+ */
794
+ const play = (program, runState, diagnosticsRequested, callerSignal) => Effect.suspend(() => {
795
+ const diagnostics = diagnosticsRequested
796
+ ? new ExecuteDiagnostics()
797
+ : undefined;
402
798
  const emitted = new EmitCollector(resolveBudget(config.maxEmittedBytes, EXECUTE_MAX_EMITTED_BYTES), resolveBudget(config.maxEmittedBlocks, EXECUTE_MAX_EMITTED_BLOCKS), diagnostics);
403
799
  const invocationFailures = [];
404
- try {
405
- // Admission comes before provider construction: queued calls retain no
406
- // catalogs, request scopes, or one-closure-per-tool provider arrays.
800
+ const exemptWrites = runState ? undefined : new ExemptWrites();
801
+ // The run's scope holds its signal and its lease. However the run
802
+ // ends — a result, a thrown executor, the watchdog, cancellation —
803
+ // closing it releases the lease and then aborts the signal, so
804
+ // nothing the run started outlives the request.
805
+ const run = Effect.gen(function* () {
806
+ const signal = yield* runSignal(callerSignal);
807
+ // Admission comes before provider construction: queued calls retain
808
+ // no catalogs, request scopes, or provider closures.
809
+ let lease;
407
810
  if (isAdmittingExecutor(executor)) {
408
- const admissionStarted = Date.now();
409
- try {
410
- lease = await executor.acquire({ signal: controller.signal });
411
- }
412
- finally {
413
- if (diagnostics) {
414
- diagnostics.admissionMs = Date.now() - admissionStarted;
415
- }
416
- }
811
+ lease = yield* timed((elapsed) => {
812
+ if (diagnostics)
813
+ diagnostics.admissionMs = elapsed;
814
+ }, leased(executor, signal));
417
815
  if ((lease.waitMs ?? 0) > 0) {
418
816
  logger.debug("[connecta] execute_code admitted after queue wait", {
419
817
  waitMs: lease.waitMs,
420
818
  });
421
819
  }
422
820
  }
423
- const setupStarted = Date.now();
424
- let providers;
425
- try {
426
- providers = await buildSandboxProviders(registry, baseUrl, logger, activity, {
427
- signal: controller.signal,
428
- onInvocationFailure: (failure) => {
429
- invocationFailures.push(failure);
430
- if (invocationFailures.length > 64)
431
- invocationFailures.shift();
432
- },
433
- emitCollector: emitted,
434
- ...(diagnostics ? { diagnostics } : {}),
435
- discoveryConcurrency: config.discoveryConcurrency,
436
- probeTimeoutMs: config.probeTimeoutMs,
437
- maxHostCalls: config.maxHostCalls,
438
- hostCallTimeoutMs: config.hostCallTimeoutMs,
439
- defer: config.defer,
440
- });
441
- }
442
- finally {
821
+ const provider = yield* timed((elapsed) => {
443
822
  if (diagnostics)
444
- diagnostics.setupMs = Date.now() - setupStarted;
823
+ diagnostics.setupMs = elapsed;
824
+ }, Effect.sync(() => sandboxProvider(registry, baseUrl, activity, {
825
+ signal,
826
+ onInvocationFailure: (failure) => {
827
+ invocationFailures.push(failure);
828
+ if (invocationFailures.length > 64)
829
+ invocationFailures.shift();
830
+ },
831
+ emitCollector: emitted,
832
+ ...(diagnostics ? { diagnostics } : {}),
833
+ discoveryConcurrency: config.discoveryConcurrency,
834
+ probeTimeoutMs: config.probeTimeoutMs,
835
+ maxHostCalls: config.maxHostCalls,
836
+ hostCallTimeoutMs,
837
+ defer: config.defer,
838
+ environment: config.environment,
839
+ runState,
840
+ approval: config.approval,
841
+ maxWrites: config.maxWrites,
842
+ exemptWrites,
843
+ })));
844
+ if (signal.aborted) {
845
+ return yield* Effect.fail(new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled during sandbox setup."));
445
846
  }
446
- if (controller.signal.aborted) {
447
- throw new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled during sandbox setup.");
847
+ const admitted = lease;
848
+ const executing = awaitExecutor(() => admitted
849
+ ? admitted.execute(program, [provider])
850
+ : executor.execute(program, [provider]), signal, { watchdog }).pipe(Effect.map((settled) => ({ settled })));
851
+ if (!runState) {
852
+ // Nothing pauses, but an exempt write may be on the wire: close,
853
+ // then let it finish before the scope aborts it.
854
+ return yield* timed((elapsed) => {
855
+ if (diagnostics)
856
+ diagnostics.executorWallMs = elapsed;
857
+ }, executing.pipe(Effect.ensuring(Effect.suspend(() => {
858
+ exemptWrites?.close();
859
+ return exemptWrites?.drain() ?? Effect.void;
860
+ }))));
448
861
  }
449
- const executorStarted = Date.now();
450
- try {
451
- outcome = lease
452
- ? await lease.execute(code, providers)
453
- : await executor.execute(code, providers);
862
+ // The run stopping wins the race as soon as its gate decides, before
863
+ // the program even sees the rejection. Either way the play closes —
864
+ // a write gated after this is not sent — and what is still on the
865
+ // wire finishes before the scope aborts it: every call after a stop,
866
+ // since each is journaled, and every write otherwise, since an
867
+ // abandoned write would be an unknown one.
868
+ const ended = yield* timed((elapsed) => {
869
+ if (diagnostics)
870
+ diagnostics.executorWallMs = elapsed;
871
+ }, Effect.raceFirst(executing, Deferred.await(runState.stopped).pipe(Effect.map((stop) => ({ stop })))).pipe(Effect.ensuring(Effect.suspend(() => {
872
+ runState.close();
873
+ return runState.drain("writes");
874
+ }))));
875
+ if ("stop" in ended)
876
+ yield* runState.drain("all");
877
+ return ended;
878
+ });
879
+ const reported = { emitted, diagnostics, invocationFailures };
880
+ return Effect.flatMap(Effect.exit(Effect.scoped(run)), (exit) => {
881
+ if (Exit.isFailure(exit)) {
882
+ const failed = failedRun(Cause.squash(exit.cause), logger, reported);
883
+ return runState
884
+ ? runState.finishExecutorFailure(failed)
885
+ : Effect.succeed(exemptWrites?.finish(failed) ?? failed);
454
886
  }
455
- finally {
456
- if (diagnostics) {
457
- diagnostics.executorWallMs = Date.now() - executorStarted;
458
- }
887
+ const ended = exit.value;
888
+ if ("stop" in ended) {
889
+ return runState
890
+ ? runState.finishStopped()
891
+ : Effect.die(new Error("a run without state cannot stop"));
459
892
  }
460
- }
461
- catch (err) {
462
- const logs = err !== null && typeof err === "object" && "logs" in err
463
- ? executeLogs(err.logs)
464
- : undefined;
465
- if (err instanceof ExecutorAdmissionError) {
466
- if (err.code === "executor_overloaded") {
467
- logger.warn("[connecta] execute_code admission rejected", {
468
- code: err.code,
469
- retryAfterMs: err.retryAfterMs,
470
- });
471
- }
472
- return failureResponse(err.message, {
473
- logs,
474
- emitted: err instanceof ExecutorExecutionError ? emitted : undefined,
475
- diagnostics,
476
- code: {
477
- code: err.code,
478
- message: err.message,
479
- retryable: err.retryable,
480
- ...(err.retryAfterMs !== undefined
481
- ? { retryAfterMs: err.retryAfterMs }
482
- : {}),
483
- },
484
- });
893
+ const finished = finishedRun(ended.settled, reported);
894
+ if (config.failOnInvocationFailure && invocationFailures.length > 0) {
895
+ const refusal = invocationFailures[0];
896
+ return Effect.succeed(failureResponse(refusal.details.message, {
897
+ code: refusal.details,
898
+ }));
485
899
  }
486
- return failureResponse(`Executor failed: ${msg(err)}`, {
487
- logs,
488
- emitted,
489
- diagnostics,
490
- code: "executor_failed",
900
+ return runState
901
+ ? runState.finishSettled(finished)
902
+ : Effect.succeed(exemptWrites?.finish(finished) ?? finished);
903
+ });
904
+ });
905
+ return {
906
+ claimMs: watchdog.ms + hostCallTimeoutMs + CLAIM_SLACK_MS,
907
+ storageTimeoutMs,
908
+ execute: ({ code, diagnostics }, options = {}) => {
909
+ // A code-unit count above the cap is already too large in UTF-8. Check
910
+ // that first so a huge direct-call string is never encoded in full.
911
+ if (code.length > EXECUTE_MAX_CODE_BYTES ||
912
+ new TextEncoder().encode(code).byteLength > EXECUTE_MAX_CODE_BYTES) {
913
+ const message = `execute_code code exceeds the ${EXECUTE_MAX_CODE_BYTES}-byte UTF-8 limit.`;
914
+ return Promise.resolve(failureResponse(message, {
915
+ code: { code: "invalid_args", message, retryable: false },
916
+ }));
917
+ }
918
+ return runEdge(Effect.suspend(() => {
919
+ const program = normalizeProgramSource(code);
920
+ const storage = config.resumable ? registry.resultsStorage() : undefined;
921
+ const runState = config.resumable && storage?.compareAndSet
922
+ ? RunState.fresh(storage, program, config.environment ?? freshEnvironment(), config.resumable, storageTimeoutMs)
923
+ : undefined;
924
+ return play(program, runState, diagnostics, options.signal);
925
+ }));
926
+ },
927
+ replay: (runState, signal) => play(runState.program, runState, false, signal),
928
+ };
929
+ }
930
+ /** The execute_code handler. Exported for direct testing. */
931
+ export function createExecuteTool(registry, baseUrl, executor, logger, activity, config = {}) {
932
+ return createProgramRunner(registry, baseUrl, executor, logger, activity, config).execute;
933
+ }
934
+ /** An execution that never produced an ExecuteResult, as the model sees it. */
935
+ function failedRun(err, logger, { emitted, diagnostics }) {
936
+ const logs = err !== null && typeof err === "object" && "logs" in err
937
+ ? executeLogs(err.logs)
938
+ : undefined;
939
+ if (err instanceof ExecutorAdmissionError) {
940
+ if (err.code === "executor_overloaded") {
941
+ logger.warn("[connecta] execute_code admission rejected", {
942
+ code: err.code,
943
+ retryAfterMs: err.retryAfterMs,
491
944
  });
492
945
  }
493
- finally {
494
- // A sandbox timeout or early return must also release any outstanding
495
- // host waits and signal cooperative connectors to stop their work.
496
- controller.abort();
497
- lease?.release();
498
- options.signal?.removeEventListener("abort", forwardAbort);
499
- }
500
- const logs = executeLogs(outcome.logs);
501
- if (outcome.error !== undefined) {
502
- // Executor bridges necessarily reduce thrown host errors to strings.
503
- // Match that terminal string back to the request-local typed failure so
504
- // an unhandled tool failure keeps the same structured contract as
505
- // call_tool. Failures caught by model code never reach
506
- // outcome.error and therefore remain under that code's control.
507
- //
508
- // An error the program let through unchanged matches exactly, and an
509
- // exact match always wins: a program that wrapped one failure's message
510
- // around another's must not have the wrong type attached. Containment is
511
- // the fallback, so a wrapped message still reports its underlying type
512
- // rather than losing it to prose.
513
- let invocationFailure;
514
- for (const match of [
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),
522
- ]) {
523
- for (let i = invocationFailures.length - 1; i >= 0; i--) {
524
- const candidate = invocationFailures[i];
525
- if (candidate && match(candidate)) {
526
- invocationFailure = candidate;
527
- break;
528
- }
529
- }
530
- if (invocationFailure)
946
+ return failureResponse(err.message, {
947
+ logs,
948
+ emitted: err instanceof ExecutorExecutionError ? emitted : undefined,
949
+ diagnostics,
950
+ code: {
951
+ code: err.code,
952
+ message: err.message,
953
+ retryable: err.retryable,
954
+ ...(err.retryAfterMs !== undefined
955
+ ? { retryAfterMs: err.retryAfterMs }
956
+ : {}),
957
+ },
958
+ });
959
+ }
960
+ return failureResponse(`Executor failed: ${msg(err)}`, {
961
+ logs,
962
+ emitted,
963
+ diagnostics,
964
+ code: "executor_failed",
965
+ });
966
+ }
967
+ /** The response for an ExecuteResult: the program's error or its value. */
968
+ function finishedRun(outcome, { emitted, diagnostics, invocationFailures }) {
969
+ const logs = executeLogs(outcome.logs);
970
+ if (outcome.error !== undefined) {
971
+ // Executor bridges necessarily reduce thrown host errors to strings.
972
+ // Match that terminal string back to the request-local typed failure so
973
+ // an unhandled tool failure keeps the same structured contract as
974
+ // call_tool. Failures caught by model code never reach
975
+ // outcome.error and therefore remain under that code's control.
976
+ //
977
+ // An error the program let through unchanged matches exactly, and an
978
+ // exact match always wins: a program that wrapped one failure's message
979
+ // around another's must not have the wrong type attached. Containment is
980
+ // the fallback, so a wrapped message still reports its underlying type
981
+ // rather than losing it to prose.
982
+ let invocationFailure;
983
+ for (const match of [
984
+ (candidate) => outcome.error !== "" &&
985
+ [candidate.message, guestFailureFrames.get(candidate)].includes(outcome.error),
986
+ (candidate) => [candidate.message, guestFailureFrames.get(candidate)].some((message) =>
987
+ // E6: empty or tiny prose cannot identify a wrapped failure.
988
+ message !== undefined &&
989
+ message.length >= 8 &&
990
+ outcome.error?.includes(message) === true),
991
+ ]) {
992
+ for (let i = invocationFailures.length - 1; i >= 0; i--) {
993
+ const candidate = invocationFailures[i];
994
+ if (candidate && match(candidate)) {
995
+ invocationFailure = candidate;
531
996
  break;
997
+ }
532
998
  }
533
- if (invocationFailure) {
534
- // E1/X11: return the same bounded details the guest received.
535
- return failureResponse(invocationFailure.details.message, {
536
- logs,
537
- emitted,
538
- diagnostics,
539
- code: invocationFailure.details,
540
- });
541
- }
542
- const message = `Error: ${outcome.error || "Execution failed without an error message."}`;
543
- return failureResponse(message, {
544
- logs,
545
- emitted,
546
- diagnostics,
547
- code: "executor_failed",
548
- });
549
- }
550
- // A result crossing back as a host BigInt (or otherwise unserializable
551
- // value) makes JSON.stringify throw — keep that inside the structured
552
- // error path so captured logs survive instead of a raw SDK 500.
553
- let result;
554
- try {
555
- result = guardExecuteResultValue(outcome.result);
999
+ if (invocationFailure)
1000
+ break;
556
1001
  }
557
- catch (err) {
558
- const message = `Error: result is not JSON-serializable: ${msg(err)}`;
559
- return failureResponse(message, {
1002
+ if (invocationFailure) {
1003
+ // E1/X11: return the same bounded details the guest received.
1004
+ return failureResponse(invocationFailure.details.message, {
560
1005
  logs,
561
1006
  emitted,
562
1007
  diagnostics,
563
- code: "executor_failed",
1008
+ code: invocationFailure.details,
564
1009
  });
565
1010
  }
566
- const response = jsonResult({
567
- result,
568
- ...(emitted.blocks.length > 0 ? { emitted: emitted.blocks.length } : {}),
569
- ...(logs ? { logs } : {}),
570
- ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
1011
+ const message = `Error: ${outcome.error || "Execution failed without an error message."}`;
1012
+ return failureResponse(message, {
1013
+ logs,
1014
+ emitted,
1015
+ diagnostics,
1016
+ code: "executor_failed",
571
1017
  });
572
- if (emitted.blocks.length > 0) {
573
- // Emitted image/audio blocks are valid MCP content that ToolResult's
574
- // text-only typing does not model — the same acknowledged gap
575
- // guardContent lives with for downstream block passthrough.
576
- response.content.push(...emitted.blocks);
577
- }
578
- return response;
579
- };
1018
+ }
1019
+ // A result crossing back as a host BigInt (or otherwise unserializable
1020
+ // value) makes JSON.stringify throw — keep that inside the structured
1021
+ // error path so captured logs survive instead of a raw SDK 500.
1022
+ let result;
1023
+ try {
1024
+ result = guardExecuteResultValue(outcome.result);
1025
+ }
1026
+ catch (err) {
1027
+ const message = `Error: result is not JSON-serializable: ${msg(err)}`;
1028
+ return failureResponse(message, {
1029
+ logs,
1030
+ emitted,
1031
+ diagnostics,
1032
+ code: "executor_failed",
1033
+ });
1034
+ }
1035
+ const response = jsonResult({
1036
+ result,
1037
+ ...(emitted.blocks.length > 0 ? { emitted: emitted.blocks.length } : {}),
1038
+ ...(logs ? { logs } : {}),
1039
+ ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
1040
+ });
1041
+ if (emitted.blocks.length > 0) {
1042
+ // Emitted image/audio blocks are valid MCP content that ToolResult's
1043
+ // text-only typing does not model — the same acknowledged gap
1044
+ // guardContent lives with for downstream block passthrough.
1045
+ response.content.push(...emitted.blocks);
1046
+ }
1047
+ return response;
580
1048
  }
581
1049
  function executeLogs(value) {
582
1050
  return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === "string")
@@ -649,12 +1117,14 @@ function connectorInventory(connectors) {
649
1117
  return `${prefix}${shown.join(", ")}.`;
650
1118
  return `${prefix}${shown.join(", ")}${shown.length > 0 ? "; " : ""}+${omitted} more.`;
651
1119
  }
652
- const executeDescription = (emitBudgets, hostLimits, connectorGuides, connectors) => `Use the configured services below to answer the task. A known address uses call_tool. Unknown-address and wider read-only work uses one execute_code program for discovery, calls, and reduction. Do not return catalog matches alone. Only readOnlyHint: true tools are available. Limits: ${hostLimits.maxHostCalls} host calls, ${hostLimits.hostCallTimeoutMs / 1_000}s/host call.
1120
+ const executeDescription = (emitBudgets, hostLimits, connectorGuides, connectors, resumable) => `Use the configured services below to answer the task. A known address uses call_tool. Unknown-address and wider ${resumable ? "work, writes included," : "read-only work"} uses one execute_code program for discovery, calls, and reduction. Do not return catalog matches alone. ${resumable
1121
+ ? `Writes pause for resume_execution. Limits: ${hostLimits.maxHostCalls} host calls, ${resumable.maxWrites} writes`
1122
+ : `Only readOnlyHint: true tools are available. Limits: ${hostLimits.maxHostCalls} host calls`}, ${hostLimits.hostCallTimeoutMs / 1_000}s/host call.
653
1123
 
654
1124
  ${connectorInventory(connectors)}
655
1125
 
656
- Read relevant guides using top-level skills, not sandbox code. Write a plain-JavaScript async arrow:
657
- - connecta.search({ connector, query, safety: "readOnly", includeSchemas: "json" }) returns { tools }. Search each operation separately; choose by connectorTitle and schemas. Use schema.required and .properties to build args, never guessed fields. Compact schemas are text.
1126
+ Read guides with top-level skills. Write JavaScript as async () => { ... }; connecta is global, not a parameter:
1127
+ - connecta.search({ connector, query, ${resumable ? "" : 'safety: "readOnly", '}includeSchemas: "json" }) returns { tools }. Search each operation separately; choose by connectorTitle and schemas. Use schema.required and .properties to build args, never guessed fields. Compact schemas are text.
658
1128
  - connecta.describe({ address }) returns { tools } for unclear schemas.
659
1129
  - connecta.call(address, args) returns the provider value directly.
660
1130
  - Use Promise.all for independent calls, or Promise.allSettled to retain failures. Check status before reading value; rejected calls and missing fields are unknown, never false or zero.
@@ -662,6 +1132,18 @@ Read relevant guides using top-level skills, not sandbox code. Write a plain-Jav
662
1132
  - console.log(...) is captured. Return data for the client to render.
663
1133
 
664
1134
  No portable ambient capabilities. Return reduced JSON. If a provider result has an unfamiliar shape, return a small sample and continue in another call; never guess fields or use the whole text as an id. Top-level skills({ name: "usage" }): repair${connectorGuides ? ", guide handling" : ""}; skills({ name: "investigate" }): task planning.`;
1135
+ // Module scope, like the other six meta-tool inputs: its JSON Schema is
1136
+ // derived once per process. Budgets and connectors vary by deployment and
1137
+ // view, so they live in the description, never here.
1138
+ const EXECUTE_INPUT = advertisedSchema(z.object({
1139
+ code: z
1140
+ .string()
1141
+ .describe("One complete zero-argument JavaScript async arrow: async () => { ... }. Use the provided connecta global to discover, call, and return the reduced answer. At most 65,536 UTF-8 bytes."),
1142
+ diagnostics: z
1143
+ .boolean()
1144
+ .optional()
1145
+ .describe("Add request-local, payload-free timing and result-size summaries."),
1146
+ }));
665
1147
  /** Register the execute_code meta-tool. Only called when an executor is configured. */
666
1148
  export function registerExecuteTool(server, registry, ctx) {
667
1149
  // Resolved once so the description and the collector cannot disagree about
@@ -677,54 +1159,35 @@ export function registerExecuteTool(server, registry, ctx) {
677
1159
  hostCallTimeoutMs: resolveBudget(ctx.hostCallTimeoutMs, EXECUTE_HOST_CALL_TIMEOUT_MS),
678
1160
  };
679
1161
  const connectors = registry.listConnectors();
680
- const handler = createExecuteTool(registry, ctx.baseUrl, ctx.executor, ctx.logger, ctx.activity, {
1162
+ const runner = createProgramRunner(registry, ctx.baseUrl, ctx.executor, ctx.logger, ctx.activity, {
681
1163
  discoveryConcurrency: ctx.discoveryConcurrency,
682
1164
  probeTimeoutMs: ctx.probeTimeoutMs,
683
1165
  maxEmittedBytes: emitBudgets.maxBytes,
684
1166
  maxEmittedBlocks: emitBudgets.maxBlocks,
685
1167
  maxHostCalls: hostLimits.maxHostCalls,
686
1168
  hostCallTimeoutMs: hostLimits.hostCallTimeoutMs,
1169
+ watchdogMs: ctx.watchdogMs,
687
1170
  defer: ctx.defer,
1171
+ resumable: ctx.resumable,
1172
+ approval: ctx.approval,
1173
+ maxWrites: ctx.maxWrites,
688
1174
  });
689
1175
  server.registerTool("execute_code", {
690
- description: executeDescription(emitBudgets, hostLimits, hasConnectorGuides(connectors), connectors),
691
- inputSchema: z.object({
692
- code: z
693
- .string()
694
- .describe("One complete JavaScript async arrow function that discovers, calls, and returns the reduced answer."),
695
- diagnostics: z
696
- .boolean()
697
- .optional()
698
- .describe("Add request-local, payload-free timing and result-size summaries."),
699
- }),
700
- // This hint describes connector calls, all explicitly read-only. The
701
- // supported executor constructions deny outbound access, filesystem,
702
- // and deployment config; X5 documents Dynamic runtime modules separately.
1176
+ description: executeDescription(emitBudgets, hostLimits, hasConnectorGuides(connectors), connectors, ctx.resumable),
1177
+ inputSchema: EXECUTE_INPUT,
1178
+ // This hint describes connector calls: only explicitly read-only ones
1179
+ // run here, and any other call pauses the run unsent (W1) — the write
1180
+ // itself runs inside resume_execution, which is annotated destructive.
1181
+ // The supported executor constructions deny outbound access,
1182
+ // filesystem, and deployment config; X5 documents Dynamic runtime
1183
+ // modules separately.
703
1184
  annotations: {
704
1185
  readOnlyHint: true,
705
1186
  destructiveHint: false,
706
1187
  openWorldHint: true,
707
1188
  },
708
- }, async (args, extra) => {
709
- const controller = new AbortController();
710
- const signals = [extra.mcpReq.signal, ctx.requestSignal].filter((signal) => signal !== undefined);
711
- const forwarders = signals.map((signal) => {
712
- const forward = () => controller.abort(signal.reason);
713
- if (signal.aborted)
714
- forward();
715
- else
716
- signal.addEventListener("abort", forward, { once: true });
717
- return { signal, forward };
718
- });
719
- try {
720
- return await handler(args, {
721
- signal: controller.signal,
722
- });
723
- }
724
- finally {
725
- for (const { signal, forward } of forwarders) {
726
- signal.removeEventListener("abort", forward);
727
- }
728
- }
729
- });
1189
+ }, (args, extra) => underAnySignal([extra.mcpReq.signal, ctx.requestSignal], (signal) => runner.execute(args, {
1190
+ signal,
1191
+ })));
1192
+ return runner;
730
1193
  }