@zackbart/connecta 0.24.3 → 0.25.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 (145) hide show
  1. package/AGENTS.md +58 -30
  2. package/CHANGELOG.md +317 -1
  3. package/README.md +5 -6
  4. package/dist/advertised-schema.js +53 -0
  5. package/dist/auth/downstream-oauth.js +490 -106
  6. package/dist/branding.d.ts +31 -2
  7. package/dist/branding.js +116 -8
  8. package/dist/call-admission.js +139 -113
  9. package/dist/catalog-service.js +141 -105
  10. package/dist/catalog.js +258 -33
  11. package/dist/concurrency.js +24 -23
  12. package/dist/connector-scope.js +6 -37
  13. package/dist/connectors/api.d.ts +3 -3
  14. package/dist/connectors/api.js +10 -2
  15. package/dist/connectors/guarded-fetch.js +34 -4
  16. package/dist/connectors/remote-mcp.d.ts +6 -5
  17. package/dist/connectors/remote-mcp.js +325 -245
  18. package/dist/credential-contract.d.ts +9 -0
  19. package/dist/credential-rules.js +2 -2
  20. package/dist/credentials.d.ts +6 -1
  21. package/dist/credentials.js +45 -3
  22. package/dist/errors.d.ts +11 -3
  23. package/dist/errors.js +2 -1
  24. package/dist/execute.js +363 -257
  25. package/dist/executor-admission.js +67 -50
  26. package/dist/executors/quickjs-runtime.js +4 -2
  27. package/dist/executors/quickjs.js +251 -210
  28. package/dist/fix-prompt.js +37 -0
  29. package/dist/index.d.ts +11 -2
  30. package/dist/index.js +63 -21
  31. package/dist/invocation.js +324 -285
  32. package/dist/meta-tools.js +304 -114
  33. package/dist/oauth-callback-outcome.js +94 -0
  34. package/dist/oauth-sealing.js +29 -0
  35. package/dist/operator-ui/fix-prompts.js +87 -0
  36. package/dist/operator-ui/generated.js +2 -2
  37. package/dist/operator-ui/model.d.ts +46 -1
  38. package/dist/operator-ui/setup-commands.js +53 -0
  39. package/dist/operator-ui/view.js +109 -2
  40. package/dist/program-source.js +205 -0
  41. package/dist/providers/cloudflare.d.ts +14 -2
  42. package/dist/providers/cloudflare.js +107 -16
  43. package/dist/providers/linear.d.ts +26 -4
  44. package/dist/providers/linear.js +19 -4
  45. package/dist/providers/mixpanel.d.ts +16 -3
  46. package/dist/providers/mixpanel.js +13 -2
  47. package/dist/providers/notion.d.ts +8 -1
  48. package/dist/providers/notion.js +85 -12
  49. package/dist/providers/revenuecat.d.ts +30 -4
  50. package/dist/providers/revenuecat.js +70 -5
  51. package/dist/providers/stripe.d.ts +10 -3
  52. package/dist/providers/stripe.js +30 -4
  53. package/dist/providers/vercel.js +12 -2
  54. package/dist/registry.d.ts +13 -5
  55. package/dist/registry.js +317 -258
  56. package/dist/routes/activity.js +120 -137
  57. package/dist/routes/credentials.js +174 -163
  58. package/dist/routes/mcp.js +129 -70
  59. package/dist/routes/oauth-management.js +60 -71
  60. package/dist/routes/oauth.js +44 -16
  61. package/dist/routes/operator.js +82 -0
  62. package/dist/routes/shared.d.ts +2 -0
  63. package/dist/routes/ui-data.js +210 -0
  64. package/dist/routes/ui.js +64 -32
  65. package/dist/runtime/admission.js +43 -0
  66. package/dist/runtime/call-admission.js +67 -0
  67. package/dist/runtime/connector-scope.js +72 -0
  68. package/dist/runtime/run.js +174 -0
  69. package/dist/runtime/services.js +130 -0
  70. package/dist/runtime/storage.js +66 -0
  71. package/dist/server.js +111 -89
  72. package/dist/skills.js +1 -1
  73. package/dist/storage/file.js +39 -2
  74. package/dist/storage/memory.js +28 -16
  75. package/dist/timeout.js +17 -47
  76. package/dist/types.d.ts +64 -7
  77. package/dist/ui.d.ts +16 -2
  78. package/dist/ui.js +68 -176
  79. package/dist/url-safety.js +157 -0
  80. package/dist/version.d.ts +1 -1
  81. package/dist/version.js +1 -1
  82. package/documentation/architecture.md +368 -182
  83. package/documentation/auth.md +277 -176
  84. package/documentation/code-mode.md +479 -329
  85. package/documentation/meta-tools.md +494 -417
  86. package/ethos.md +44 -47
  87. package/examples/worker/AGENTS.md +2 -1
  88. package/examples/worker/README.md +62 -14
  89. package/examples/worker/src/d1-storage.ts +116 -0
  90. package/examples/worker/src/index.ts +18 -21
  91. package/examples/worker/wrangler.jsonc +16 -4
  92. package/package.json +11 -4
  93. package/templates/node/.env.example +3 -3
  94. package/templates/node/AGENTS.md +5 -4
  95. package/templates/node/README.md +2 -1
  96. package/templates/node/package.json +1 -1
  97. package/templates/node/src/index.ts +23 -22
  98. package/dist/auth/downstream-oauth.d.ts +0 -200
  99. package/dist/catalog-fingerprint.d.ts +0 -19
  100. package/dist/catalog-limits.d.ts +0 -9
  101. package/dist/catalog-service.d.ts +0 -263
  102. package/dist/catalog.d.ts +0 -87
  103. package/dist/concurrency.d.ts +0 -3
  104. package/dist/connectors/guarded-fetch.d.ts +0 -92
  105. package/dist/credential-rules.d.ts +0 -85
  106. package/dist/execute.d.ts +0 -154
  107. package/dist/executor-result.d.ts +0 -10
  108. package/dist/executors/quickjs-child.d.ts +0 -1
  109. package/dist/executors/quickjs-protocol.d.ts +0 -67
  110. package/dist/favicon.d.ts +0 -1
  111. package/dist/identity.d.ts +0 -4
  112. package/dist/invocation.d.ts +0 -74
  113. package/dist/mcp-result.d.ts +0 -8
  114. package/dist/meta-tools.d.ts +0 -129
  115. package/dist/operator-ui/generated.d.ts +0 -2
  116. package/dist/operator-ui/view.d.ts +0 -151
  117. package/dist/result-shapes.d.ts +0 -13
  118. package/dist/retired-toolkits.d.ts +0 -2
  119. package/dist/routes/activity.d.ts +0 -2
  120. package/dist/routes/credentials.d.ts +0 -2
  121. package/dist/routes/mcp.d.ts +0 -10
  122. package/dist/routes/oauth-management.d.ts +0 -2
  123. package/dist/routes/oauth.d.ts +0 -2
  124. package/dist/routes/ui.d.ts +0 -2
  125. package/dist/server.d.ts +0 -9
  126. package/dist/skills.d.ts +0 -44
  127. package/dist/timeout.d.ts +0 -21
  128. package/dist/tool-safety.d.ts +0 -9
  129. package/documentation/call-admission.md +0 -158
  130. package/documentation/cloudflare.md +0 -471
  131. package/documentation/connector-guides.md +0 -176
  132. package/documentation/connectors.md +0 -431
  133. package/documentation/linear.md +0 -193
  134. package/documentation/mixpanel.md +0 -160
  135. package/documentation/notion.md +0 -308
  136. package/documentation/operations.md +0 -359
  137. package/documentation/operator-ui.md +0 -135
  138. package/documentation/optional-modules-upgrade.md +0 -243
  139. package/documentation/provider-conventions.md +0 -729
  140. package/documentation/request-admission.md +0 -204
  141. package/documentation/revenuecat.md +0 -305
  142. package/documentation/storage-and-credentials.md +0 -254
  143. package/documentation/stripe.md +0 -262
  144. package/documentation/upgrading.md +0 -768
  145. package/documentation/vercel.md +0 -241
package/dist/execute.js CHANGED
@@ -1,14 +1,25 @@
1
+ import { Cause, 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 { withoutProgramTerminator } from "./program-source.js";
11
+ import { fromSignal, runEdge } from "./runtime/run.js";
8
12
  import { connectorGuide, connectorGuideRequired, connectorSkillName, hasConnectorGuides, } from "./skills.js";
9
13
  /** Keep one model-written program from amplifying into an unbounded fan-out. */
10
14
  const EXECUTE_MAX_HOST_CALLS = 20;
11
15
  const EXECUTE_HOST_CALL_TIMEOUT_MS = 15_000;
16
+ /**
17
+ * The outer ceiling on one execution. It sits above both executors' own
18
+ * deadlines (QuickJS 30s, the Dynamic Worker 60s), so a healthy executor
19
+ * always reports its own timeout first and this fires only for one that
20
+ * never settles at all.
21
+ */
22
+ const EXECUTE_WATCHDOG_MS = 120_000;
12
23
  /** Complete entries plus an exact omission count, all inside this byte cap. */
13
24
  export const CONNECTOR_INVENTORY_MAX_BYTES = 256;
14
25
  /**
@@ -266,6 +277,19 @@ function guestErrorPrelude(failureSecret) {
266
277
  * the program calls a tool or asks search/describe.
267
278
  */
268
279
  export async function buildSandboxProviders(registry, baseUrl, _logger, activity, limits = {}) {
280
+ return [sandboxProvider(registry, baseUrl, activity, limits)];
281
+ }
282
+ /**
283
+ * The `connecta` provider for one execution.
284
+ *
285
+ * Building it is synchronous and loads nothing. Each function is a Promise
286
+ * edge the executor awaits, and behind it one host call is one fiber: spend
287
+ * the budget, do the operation, and turn a typed failure into the frame the
288
+ * prelude rebuilds inside the guest. Nothing is shared between those fibers
289
+ * but the budget counter and this request's catalog, so a call the program
290
+ * never awaits cannot disturb the others.
291
+ */
292
+ function sandboxProvider(registry, baseUrl, activity, limits) {
269
293
  // All host calls made by one execute_code invocation share a downstream
270
294
  // connection, while a later invocation receives a fresh request scope.
271
295
  const requestScope = {};
@@ -281,71 +305,67 @@ export async function buildSandboxProviders(registry, baseUrl, _logger, activity
281
305
  const maxHostCalls = Math.max(1, Math.trunc(limits.maxHostCalls ?? EXECUTE_MAX_HOST_CALLS));
282
306
  const hostCallTimeoutMs = Math.max(1, Math.trunc(limits.hostCallTimeoutMs ?? EXECUTE_HOST_CALL_TIMEOUT_MS));
283
307
  const failureSecret = guestFailureSecret();
308
+ const { signal, diagnostics } = limits;
284
309
  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
- };
310
+ // L4/M7: discovery and invocation spend the same budget, on entry; emit
311
+ // does not.
312
+ const spendHostCall = Effect.suspend(() => ++hostCalls > maxHostCalls
313
+ ? Effect.fail(guestFailure("budget_exceeded", `execute_code host-call budget exceeded (${maxHostCalls} calls maximum)`))
314
+ : Effect.void);
292
315
  const invocationContext = () => ({
293
316
  source: "execute_code",
294
317
  timeoutMs: hostCallTimeoutMs,
295
- ...(limits.signal !== undefined ? { requestSignal: limits.signal } : {}),
318
+ ...(signal !== undefined ? { requestSignal: signal } : {}),
296
319
  unwrapResult: true,
297
320
  });
298
- /**
299
- * Discovery policy failures use the same thrown vocabulary as calls and
300
- * utilities. The transport below reconstructs their code inside the guest.
301
- */
302
- const typedDiscovery = async (operation) => {
303
- try {
304
- return await operation();
305
- }
306
- catch (err) {
307
- if (err instanceof DiscoveryPolicyError) {
308
- throw guestFailure(err.code, err.message);
309
- }
310
- throw err;
311
- }
312
- };
313
- const timedCatalog = async (operation, fn) => {
321
+ // A call brings its own cancellation: the invocation pipeline reads the
322
+ // run's signal, refuses a call that starts after it, and records the
323
+ // cancelled attempt in activity like any other outcome.
324
+ const call = (address, args) => spendHostCall.pipe(Effect.andThen(Effect.suspend(() => invocation.pipeline(String(address), args ?? {}, invocationContext()))), Effect.flatMap((outcome) => {
325
+ diagnostics?.recordCall(outcome);
326
+ return outcome.ok
327
+ ? Effect.succeed(outcome.value)
328
+ : Effect.fail(new InvocationFailure(outcome.error));
329
+ }));
330
+ // Discovery gets the same treatment from here (L2): once the run has
331
+ // ended no search or describe starts, and one still in flight fails
332
+ // `cancelled` instead of holding the program until its probe deadline.
333
+ // Policy failures use the same thrown vocabulary as calls and utilities;
334
+ // the transport below reconstructs their code inside the guest.
335
+ const discovery = (operation, read) => Effect.suspend(() => {
314
336
  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),
337
+ const cancelled = () => guestFailure("cancelled", `connecta.${operation} was cancelled because the run ended.`);
338
+ const reading = Effect.tryPromise({
339
+ try: read,
340
+ catch: (err) => err instanceof DiscoveryPolicyError
341
+ ? guestFailure(err.code, err.message)
342
+ : err,
343
+ });
344
+ return spendHostCall.pipe(Effect.andThen(!signal
345
+ ? reading
346
+ : signal.aborted
347
+ ? Effect.fail(cancelled())
348
+ : Effect.raceAllFirst([
349
+ reading,
350
+ fromSignal(signal).pipe(Effect.mapError(cancelled)),
351
+ ])), Effect.onExit((exit) => Effect.sync(() => diagnostics?.recordCatalog(operation, Date.now() - started, Exit.isSuccess(exit), Exit.isSuccess(exit) ? exit.value : undefined))));
352
+ });
353
+ const operations = {
354
+ call,
336
355
  // Emission is a provider function, never an ExecuteResult field —
337
356
  // that is what keeps the Executor contract untouched and parity
338
357
  // structural (M8). It spends no host-call budget (M7); its own
339
358
  // 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 () => {
359
+ emit: (block) => Effect.try({
360
+ try: () => {
361
+ if (!limits.emitCollector) {
362
+ throw guestFailure("unavailable", "connecta.emit is unavailable: no emission collector was configured for this execution", true);
363
+ }
364
+ limits.emitCollector.accept(block);
365
+ },
366
+ catch: (err) => err,
367
+ }),
368
+ search: (raw) => discovery("search", async () => {
349
369
  const args = (raw ?? {});
350
370
  const result = flatSearchResult(await catalog.search({
351
371
  ...args,
@@ -353,230 +373,311 @@ export async function buildSandboxProviders(registry, baseUrl, _logger, activity
353
373
  }));
354
374
  boundedDiscoveryText(result, "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.");
355
375
  return result;
356
- })),
357
- describe: (raw) => timedCatalog("describe", () => typedDiscovery(async () => {
376
+ }),
377
+ describe: (raw) => discovery("describe", async () => {
358
378
  const args = (raw ?? {});
359
379
  const result = { tools: await catalog.describe(args) };
360
380
  boundedDiscoveryText(result, 'Split the address list or use format: "compact".');
361
381
  return result;
362
- })),
382
+ }),
363
383
  };
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
- },
384
+ // Every failure leaves through the same frame, so the prelude can rebuild
385
+ // a typed error however the host call failed — refused, over budget, or
386
+ // cancelled. Anything that is not an InvocationFailure crosses unchanged.
387
+ const framed = (err) => Effect.suspend(() => {
388
+ if (!(err instanceof InvocationFailure))
389
+ return Effect.fail(err);
390
+ const failure = boundedGuestFailure(err);
391
+ const frame = framedGuestFailure(failureSecret, failure);
392
+ limits.onInvocationFailure?.(failure);
393
+ return Effect.fail(frame);
394
+ });
395
+ return {
396
+ name: "connecta",
397
+ prelude: guestErrorPrelude(failureSecret),
398
+ fns: Object.fromEntries(Object.entries(operations).map(([name, operation]) => [
399
+ name,
400
+ (...args) => {
401
+ const settled = runEdge(Effect.suspend(() => operation(...args)).pipe(Effect.catch(framed)));
402
+ // The executor owns this promise, and a program may abandon a call
403
+ // that the run's end then cancels before anyone has awaited it.
404
+ // The rejection is still there for whoever does; it is just not an
405
+ // unhandled rejection of the host's, which workerd reports as soon
406
+ // as a microtask passes without a handler.
407
+ settled.catch(() => { });
408
+ return settled;
409
+ },
410
+ ])),
411
+ };
412
+ }
413
+ /**
414
+ * Await an executor's promise without trusting it to settle.
415
+ *
416
+ * An executor's deadline is its own business, and the Dynamic Worker's lives
417
+ * inside the sandbox: a wedged isolate never fires it, so the call never
418
+ * settles, the handler never returns, and the admission lease is held for
419
+ * good. Two of those fill the default code pool and every later execute_code
420
+ * queues behind them (executor hit this in production). An `acquire()` that
421
+ * ignores its signal holds a cancelled request the same way.
422
+ *
423
+ * So the host races the promise against the request's signal and, for a run,
424
+ * a ceiling of its own. Cancellation settles as the same `executor_cancelled`
425
+ * the QuickJS pool reports, unless the executor reports its own first; the
426
+ * ceiling as an untyped executor failure naming the key that sets it. Either
427
+ * way the handler returns and releases the lease, and the abandoned call
428
+ * keeps whatever it was doing: the QuickJS lease release recycles its child,
429
+ * a Dynamic Worker isolate runs on to its own deadline, and a lease granted
430
+ * after its request gave up goes to `late`. The result contract is untouched
431
+ * — this only decides when to stop waiting for one.
432
+ */
433
+ function awaitExecutor(start, signal, options = {}) {
434
+ const contenders = [
435
+ Effect.suspend(() => {
436
+ let pending;
437
+ const settling = Effect.tryPromise({
438
+ try: () => (pending = Promise.resolve(start())),
439
+ catch: (err) => err,
440
+ });
441
+ const { late } = options;
442
+ return late
443
+ ? settling.pipe(Effect.onInterrupt(() => Effect.sync(() => {
444
+ pending?.then(late, () => { });
445
+ })))
446
+ : settling;
447
+ }),
448
+ // An executor that honors the signal rejects in the abort event itself,
449
+ // with the logs it captured attached, and that report beats ours by one
450
+ // timer turn. (A 1ms sleep, not 0: Effect.sleep(0) waits a microtask,
451
+ // which the executor's promise chain can still lose to.)
452
+ fromSignal(signal).pipe(Effect.catch(() => Effect.sleep(Duration.millis(1))), Effect.andThen(Effect.fail(new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled.")))),
387
453
  ];
454
+ const { watchdog } = options;
455
+ if (watchdog) {
456
+ contenders.push(Effect.sleep(Duration.millis(watchdog.ms)).pipe(Effect.andThen(Effect.suspend(() => {
457
+ watchdog.logger.warn("[connecta] execute_code executor did not settle; run abandoned", { watchdogMs: watchdog.ms });
458
+ return Effect.fail(new Error(`sandbox unresponsive: no outcome within the ${watchdog.ms}ms ` +
459
+ "execute.watchdogMs ceiling, so the run was abandoned"));
460
+ }))));
461
+ }
462
+ return Effect.raceAllFirst(contenders);
463
+ }
464
+ /**
465
+ * The run's own signal, aborted however the run ends. It follows the
466
+ * caller's, and aborting it on the way out is what releases outstanding host
467
+ * waits and tells cooperative connectors to stop.
468
+ */
469
+ function runSignal(caller) {
470
+ return Effect.acquireRelease(Effect.sync(() => {
471
+ const controller = new AbortController();
472
+ const forward = () => controller.abort(caller?.reason);
473
+ if (caller?.aborted)
474
+ forward();
475
+ else
476
+ caller?.addEventListener("abort", forward, { once: true });
477
+ return { controller, forward };
478
+ }), ({ controller, forward }) => Effect.sync(() => {
479
+ controller.abort();
480
+ caller?.removeEventListener("abort", forward);
481
+ })).pipe(Effect.map(({ controller }) => controller.signal));
482
+ }
483
+ /**
484
+ * An admitting executor's lease, released when the run's scope closes.
485
+ *
486
+ * `acquire()` is the executor's Promise, awaited as one on purpose. A queued
487
+ * request is handed its lease from inside whichever request released one, so
488
+ * a fiber resumed straight from the executor's admission queue would build
489
+ * this request's providers and run its program in the releasing request —
490
+ * which workerd refuses for any I/O. Awaiting the promise resumes it here.
491
+ */
492
+ function leased(executor, signal) {
493
+ return Effect.acquireRelease(awaitExecutor(() => executor.acquire({ signal }), signal, {
494
+ late: (lease) => lease.release(),
495
+ }), (lease) => Effect.sync(() => lease.release()));
388
496
  }
389
497
  /** The execute_code handler. Exported for direct testing. */
390
498
  export function createExecuteTool(registry, baseUrl, executor, logger, activity, config = {}) {
391
- return async ({ code, diagnostics: diagnosticsRequested }, options = {}) => {
392
- 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;
499
+ const watchdog = {
500
+ ms: resolveBudget(config.watchdogMs, EXECUTE_WATCHDOG_MS),
501
+ logger,
502
+ };
503
+ return ({ code, diagnostics: diagnosticsRequested }, options = {}) => runEdge(Effect.suspend(() => {
504
+ const diagnostics = diagnosticsRequested
505
+ ? new ExecuteDiagnostics()
506
+ : undefined;
402
507
  const emitted = new EmitCollector(resolveBudget(config.maxEmittedBytes, EXECUTE_MAX_EMITTED_BYTES), resolveBudget(config.maxEmittedBlocks, EXECUTE_MAX_EMITTED_BLOCKS), diagnostics);
403
508
  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.
509
+ // The run's scope holds its signal and its lease. However the run
510
+ // ends — a result, a thrown executor, the watchdog, cancellation —
511
+ // closing it releases the lease and then aborts the signal, so
512
+ // nothing the run started outlives the request.
513
+ const run = Effect.gen(function* () {
514
+ const signal = yield* runSignal(options.signal);
515
+ // Admission comes before provider construction: queued calls retain
516
+ // no catalogs, request scopes, or provider closures.
517
+ let lease;
407
518
  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
- }
519
+ lease = yield* timed((elapsed) => {
520
+ if (diagnostics)
521
+ diagnostics.admissionMs = elapsed;
522
+ }, leased(executor, signal));
417
523
  if ((lease.waitMs ?? 0) > 0) {
418
524
  logger.debug("[connecta] execute_code admitted after queue wait", {
419
525
  waitMs: lease.waitMs,
420
526
  });
421
527
  }
422
528
  }
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 {
529
+ const provider = yield* timed((elapsed) => {
443
530
  if (diagnostics)
444
- diagnostics.setupMs = Date.now() - setupStarted;
445
- }
446
- if (controller.signal.aborted) {
447
- throw new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled during sandbox setup.");
531
+ diagnostics.setupMs = elapsed;
532
+ }, Effect.sync(() => sandboxProvider(registry, baseUrl, activity, {
533
+ signal,
534
+ onInvocationFailure: (failure) => {
535
+ invocationFailures.push(failure);
536
+ if (invocationFailures.length > 64)
537
+ invocationFailures.shift();
538
+ },
539
+ emitCollector: emitted,
540
+ ...(diagnostics ? { diagnostics } : {}),
541
+ discoveryConcurrency: config.discoveryConcurrency,
542
+ probeTimeoutMs: config.probeTimeoutMs,
543
+ maxHostCalls: config.maxHostCalls,
544
+ hostCallTimeoutMs: config.hostCallTimeoutMs,
545
+ defer: config.defer,
546
+ })));
547
+ if (signal.aborted) {
548
+ return yield* Effect.fail(new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled during sandbox setup."));
448
549
  }
449
- const executorStarted = Date.now();
450
- try {
451
- outcome = lease
452
- ? await lease.execute(code, providers)
453
- : await executor.execute(code, providers);
454
- }
455
- finally {
456
- if (diagnostics) {
457
- diagnostics.executorWallMs = Date.now() - executorStarted;
458
- }
459
- }
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
- });
485
- }
486
- return failureResponse(`Executor failed: ${msg(err)}`, {
487
- logs,
488
- emitted,
489
- diagnostics,
490
- code: "executor_failed",
550
+ const admitted = lease;
551
+ // P1: a trailing `;` after the arrow expression breaks both
552
+ // executors' parenthesized evaluation. Strip it here, above them.
553
+ const program = withoutProgramTerminator(code);
554
+ return yield* timed((elapsed) => {
555
+ if (diagnostics)
556
+ diagnostics.executorWallMs = elapsed;
557
+ }, awaitExecutor(() => admitted
558
+ ? admitted.execute(program, [provider])
559
+ : executor.execute(program, [provider]), signal, { watchdog }));
560
+ });
561
+ const reported = { emitted, diagnostics, invocationFailures };
562
+ return Effect.map(Effect.exit(Effect.scoped(run)), (exit) => Exit.isSuccess(exit)
563
+ ? finishedRun(exit.value, reported)
564
+ : failedRun(Cause.squash(exit.cause), logger, reported));
565
+ }));
566
+ }
567
+ /** An execution that never produced an ExecuteResult, as the model sees it. */
568
+ function failedRun(err, logger, { emitted, diagnostics }) {
569
+ const logs = err !== null && typeof err === "object" && "logs" in err
570
+ ? executeLogs(err.logs)
571
+ : undefined;
572
+ if (err instanceof ExecutorAdmissionError) {
573
+ if (err.code === "executor_overloaded") {
574
+ logger.warn("[connecta] execute_code admission rejected", {
575
+ code: err.code,
576
+ retryAfterMs: err.retryAfterMs,
491
577
  });
492
578
  }
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)
579
+ return failureResponse(err.message, {
580
+ logs,
581
+ emitted: err instanceof ExecutorExecutionError ? emitted : undefined,
582
+ diagnostics,
583
+ code: {
584
+ code: err.code,
585
+ message: err.message,
586
+ retryable: err.retryable,
587
+ ...(err.retryAfterMs !== undefined
588
+ ? { retryAfterMs: err.retryAfterMs }
589
+ : {}),
590
+ },
591
+ });
592
+ }
593
+ return failureResponse(`Executor failed: ${msg(err)}`, {
594
+ logs,
595
+ emitted,
596
+ diagnostics,
597
+ code: "executor_failed",
598
+ });
599
+ }
600
+ /** The response for an ExecuteResult: the program's error or its value. */
601
+ function finishedRun(outcome, { emitted, diagnostics, invocationFailures }) {
602
+ const logs = executeLogs(outcome.logs);
603
+ if (outcome.error !== undefined) {
604
+ // Executor bridges necessarily reduce thrown host errors to strings.
605
+ // Match that terminal string back to the request-local typed failure so
606
+ // an unhandled tool failure keeps the same structured contract as
607
+ // call_tool. Failures caught by model code never reach
608
+ // outcome.error and therefore remain under that code's control.
609
+ //
610
+ // An error the program let through unchanged matches exactly, and an
611
+ // exact match always wins: a program that wrapped one failure's message
612
+ // around another's must not have the wrong type attached. Containment is
613
+ // the fallback, so a wrapped message still reports its underlying type
614
+ // rather than losing it to prose.
615
+ let invocationFailure;
616
+ for (const match of [
617
+ (candidate) => outcome.error !== "" &&
618
+ [candidate.message, guestFailureFrames.get(candidate)].includes(outcome.error),
619
+ (candidate) => [candidate.message, guestFailureFrames.get(candidate)].some((message) =>
620
+ // E6: empty or tiny prose cannot identify a wrapped failure.
621
+ message !== undefined &&
622
+ message.length >= 8 &&
623
+ outcome.error?.includes(message) === true),
624
+ ]) {
625
+ for (let i = invocationFailures.length - 1; i >= 0; i--) {
626
+ const candidate = invocationFailures[i];
627
+ if (candidate && match(candidate)) {
628
+ invocationFailure = candidate;
531
629
  break;
630
+ }
532
631
  }
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);
632
+ if (invocationFailure)
633
+ break;
556
634
  }
557
- catch (err) {
558
- const message = `Error: result is not JSON-serializable: ${msg(err)}`;
559
- return failureResponse(message, {
635
+ if (invocationFailure) {
636
+ // E1/X11: return the same bounded details the guest received.
637
+ return failureResponse(invocationFailure.details.message, {
560
638
  logs,
561
639
  emitted,
562
640
  diagnostics,
563
- code: "executor_failed",
641
+ code: invocationFailure.details,
564
642
  });
565
643
  }
566
- const response = jsonResult({
567
- result,
568
- ...(emitted.blocks.length > 0 ? { emitted: emitted.blocks.length } : {}),
569
- ...(logs ? { logs } : {}),
570
- ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
644
+ const message = `Error: ${outcome.error || "Execution failed without an error message."}`;
645
+ return failureResponse(message, {
646
+ logs,
647
+ emitted,
648
+ diagnostics,
649
+ code: "executor_failed",
571
650
  });
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
- };
651
+ }
652
+ // A result crossing back as a host BigInt (or otherwise unserializable
653
+ // value) makes JSON.stringify throw — keep that inside the structured
654
+ // error path so captured logs survive instead of a raw SDK 500.
655
+ let result;
656
+ try {
657
+ result = guardExecuteResultValue(outcome.result);
658
+ }
659
+ catch (err) {
660
+ const message = `Error: result is not JSON-serializable: ${msg(err)}`;
661
+ return failureResponse(message, {
662
+ logs,
663
+ emitted,
664
+ diagnostics,
665
+ code: "executor_failed",
666
+ });
667
+ }
668
+ const response = jsonResult({
669
+ result,
670
+ ...(emitted.blocks.length > 0 ? { emitted: emitted.blocks.length } : {}),
671
+ ...(logs ? { logs } : {}),
672
+ ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
673
+ });
674
+ if (emitted.blocks.length > 0) {
675
+ // Emitted image/audio blocks are valid MCP content that ToolResult's
676
+ // text-only typing does not model — the same acknowledged gap
677
+ // guardContent lives with for downstream block passthrough.
678
+ response.content.push(...emitted.blocks);
679
+ }
680
+ return response;
580
681
  }
581
682
  function executeLogs(value) {
582
683
  return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === "string")
@@ -662,6 +763,18 @@ Read relevant guides using top-level skills, not sandbox code. Write a plain-Jav
662
763
  - console.log(...) is captured. Return data for the client to render.
663
764
 
664
765
  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.`;
766
+ // Module scope, like the other six meta-tool inputs: its JSON Schema is
767
+ // derived once per process. Budgets and connectors vary by deployment and
768
+ // view, so they live in the description, never here.
769
+ const EXECUTE_INPUT = advertisedSchema(z.object({
770
+ code: z
771
+ .string()
772
+ .describe("One complete JavaScript async arrow function that discovers, calls, and returns the reduced answer."),
773
+ diagnostics: z
774
+ .boolean()
775
+ .optional()
776
+ .describe("Add request-local, payload-free timing and result-size summaries."),
777
+ }));
665
778
  /** Register the execute_code meta-tool. Only called when an executor is configured. */
666
779
  export function registerExecuteTool(server, registry, ctx) {
667
780
  // Resolved once so the description and the collector cannot disagree about
@@ -684,19 +797,12 @@ export function registerExecuteTool(server, registry, ctx) {
684
797
  maxEmittedBlocks: emitBudgets.maxBlocks,
685
798
  maxHostCalls: hostLimits.maxHostCalls,
686
799
  hostCallTimeoutMs: hostLimits.hostCallTimeoutMs,
800
+ watchdogMs: ctx.watchdogMs,
687
801
  defer: ctx.defer,
688
802
  });
689
803
  server.registerTool("execute_code", {
690
804
  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
- }),
805
+ inputSchema: EXECUTE_INPUT,
700
806
  // This hint describes connector calls, all explicitly read-only. The
701
807
  // supported executor constructions deny outbound access, filesystem,
702
808
  // and deployment config; X5 documents Dynamic runtime modules separately.