@zackbart/connecta 0.40.0 → 0.42.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 (195) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +32 -5
  7. package/dist/activity.js +32 -6
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-observation.js +24 -2
  10. package/dist/agent-routes.js +33 -44
  11. package/dist/auth/clerk.d.ts +6 -1
  12. package/dist/auth/clerk.js +16 -0
  13. package/dist/auth/cloudflare-access.js +49 -7
  14. package/dist/auth/downstream-oauth/consent.js +79 -29
  15. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  16. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  17. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  18. package/dist/auth/downstream-oauth.js +15 -4
  19. package/dist/auth/oauth-request-not-sent.js +4 -0
  20. package/dist/auth/static-oauth.js +77 -39
  21. package/dist/auth-elicitation.js +118 -61
  22. package/dist/authorization-handoff.js +36 -3
  23. package/dist/catalog-cache.js +352 -43
  24. package/dist/catalog-freshness.js +25 -0
  25. package/dist/catalog-service.js +408 -78
  26. package/dist/catalog.js +216 -62
  27. package/dist/config-defaults.d.ts +2 -1
  28. package/dist/config-defaults.js +4 -1
  29. package/dist/config.d.ts +53 -21
  30. package/dist/config.js +56 -17
  31. package/dist/connectors/remote-mcp/call.js +1 -1
  32. package/dist/connectors/remote-mcp/catalog.js +52 -28
  33. package/dist/connectors/remote-mcp/connection.js +113 -22
  34. package/dist/connectors/remote-mcp/oauth.js +2 -2
  35. package/dist/connectors/remote-mcp/resources.js +5 -2
  36. package/dist/connectors/remote-mcp/transport.js +24 -6
  37. package/dist/connectors/remote-mcp.js +14 -5
  38. package/dist/credential-contract.d.ts +3 -1
  39. package/dist/describe-config.d.ts +4 -2
  40. package/dist/describe-config.js +1 -0
  41. package/dist/discovery-health.js +26 -11
  42. package/dist/downstream-input.js +11 -2
  43. package/dist/errors.d.ts +60 -4
  44. package/dist/errors.js +88 -3
  45. package/dist/execute.js +491 -202
  46. package/dist/executor-admission.js +2 -0
  47. package/dist/executor-contract.js +6 -0
  48. package/dist/executor-result.js +3 -3
  49. package/dist/executors/quickjs-child.js +14 -5
  50. package/dist/executors/quickjs-runtime.d.ts +2 -0
  51. package/dist/executors/quickjs-runtime.js +37 -20
  52. package/dist/executors/quickjs.js +24 -7
  53. package/dist/guarded-transport.js +2 -0
  54. package/dist/guest-functions.js +5 -4
  55. package/dist/guest-runtime.js +37 -1
  56. package/dist/guest-types.d.ts +18 -0
  57. package/dist/http/client.js +14 -2
  58. package/dist/http/errors.js +9 -4
  59. package/dist/http/fields.js +17 -5
  60. package/dist/http/hatch.js +11 -3
  61. package/dist/http/node-transport.js +110 -4
  62. package/dist/http/oauth.js +17 -6
  63. package/dist/http/paging.js +1 -1
  64. package/dist/http/provider.js +2 -6
  65. package/dist/http/vendor.js +6 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.js +15 -21
  68. package/dist/invocation-failure.js +28 -0
  69. package/dist/invocation.js +167 -64
  70. package/dist/membership.d.ts +1 -1
  71. package/dist/meta-output.js +24 -18
  72. package/dist/meta-tool-names.d.ts +1 -1
  73. package/dist/meta-tool-names.js +1 -1
  74. package/dist/meta-tools.js +286 -73
  75. package/dist/module-contracts.d.ts +3 -1
  76. package/dist/native-recovery.js +4 -1
  77. package/dist/native-tools.js +177 -55
  78. package/dist/node.d.ts +7 -0
  79. package/dist/node.js +85 -4
  80. package/dist/oauth-handoff.js +53 -43
  81. package/dist/operation-activity.js +104 -0
  82. package/dist/operation-vocabulary.js +6 -0
  83. package/dist/operator-record.d.ts +9 -1
  84. package/dist/operator-record.js +16 -0
  85. package/dist/operator-ui/generated.js +2 -2
  86. package/dist/program-result.js +2 -0
  87. package/dist/program-source.js +1 -1
  88. package/dist/program-writes.js +112 -19
  89. package/dist/provider.d.ts +1 -14
  90. package/dist/provider.js +0 -3
  91. package/dist/providers/_shared/google/kit.js +2 -2
  92. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  93. package/dist/providers/breeze/api.js +2 -2
  94. package/dist/providers/breeze/index.js +1 -1
  95. package/dist/providers/breeze/skill.generated.js +1 -1
  96. package/dist/providers/ccb/api.js +2 -2
  97. package/dist/providers/ccb/index.js +1 -1
  98. package/dist/providers/ccb/skill.generated.js +1 -1
  99. package/dist/providers/cloudflare/api.js +2 -2
  100. package/dist/providers/cloudflare/index.js +3 -1
  101. package/dist/providers/cloudflare/skill.generated.js +1 -1
  102. package/dist/providers/docs/index.js +1 -1
  103. package/dist/providers/docs/skill.generated.js +1 -1
  104. package/dist/providers/drive/index.js +1 -1
  105. package/dist/providers/drive/skill.generated.js +1 -1
  106. package/dist/providers/forms/index.js +2 -1
  107. package/dist/providers/forms/skill.generated.js +1 -1
  108. package/dist/providers/github/api.js +98 -6
  109. package/dist/providers/github/index.js +8 -3
  110. package/dist/providers/github/lib/auth.d.ts +3 -1
  111. package/dist/providers/github/lib/auth.js +43 -12
  112. package/dist/providers/github/lib/discovery.js +11 -1
  113. package/dist/providers/github/lib/operations.js +8 -0
  114. package/dist/providers/github/safety.js +39 -0
  115. package/dist/providers/github/skill.generated.js +1 -1
  116. package/dist/providers/github/tools.js +12 -5
  117. package/dist/providers/gmail/index.js +2 -1
  118. package/dist/providers/gmail/skill.generated.js +1 -1
  119. package/dist/providers/granola/index.js +1 -1
  120. package/dist/providers/granola/skill.generated.js +1 -1
  121. package/dist/providers/overflow/index.js +3 -1
  122. package/dist/providers/overflow/skill.generated.js +1 -1
  123. package/dist/providers/planning-center/index.js +12 -6
  124. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  125. package/dist/providers/planning-center/skill.generated.js +1 -1
  126. package/dist/providers/quickbooks/api.js +28 -25
  127. package/dist/providers/quickbooks/index.js +3 -6
  128. package/dist/providers/quickbooks/lib/writes.js +55 -0
  129. package/dist/providers/quickbooks/safety.js +17 -5
  130. package/dist/providers/quickbooks/skill.generated.js +1 -1
  131. package/dist/providers/sheets/index.js +2 -1
  132. package/dist/providers/sheets/skill.generated.js +1 -1
  133. package/dist/providers/slides/index.js +3 -3
  134. package/dist/providers/slides/skill.generated.js +1 -1
  135. package/dist/providers/stripe/index.js +2 -2
  136. package/dist/providers/stripe/skill.generated.js +1 -1
  137. package/dist/providers/tithely/index.js +2 -1
  138. package/dist/providers/tithely/skill.generated.js +1 -1
  139. package/dist/providers/vercel/api.js +3 -2
  140. package/dist/providers/vercel/skill.generated.js +1 -1
  141. package/dist/registry.d.ts +36 -4
  142. package/dist/registry.js +142 -30
  143. package/dist/resource-read.js +21 -2
  144. package/dist/result-preview.js +210 -0
  145. package/dist/result-seal.js +168 -0
  146. package/dist/route-names.js +53 -0
  147. package/dist/routes/activity-context.js +1 -0
  148. package/dist/routes/activity-disclosure.js +26 -4
  149. package/dist/routes/activity.js +2 -1
  150. package/dist/routes/connect.js +21 -13
  151. package/dist/routes/mcp.js +83 -35
  152. package/dist/routes/shared.d.ts +6 -0
  153. package/dist/routes/ui-config.js +1 -1
  154. package/dist/routes/ui-data.js +3 -2
  155. package/dist/runtime/call.js +15 -10
  156. package/dist/runtime/native-storage.js +12 -0
  157. package/dist/runtime/request-reads.js +66 -14
  158. package/dist/runtime/request-scope.js +38 -2
  159. package/dist/runtime/run.js +4 -1
  160. package/dist/sent-secrets.d.ts +20 -0
  161. package/dist/sent-secrets.js +49 -1
  162. package/dist/skill-origin.js +16 -0
  163. package/dist/skills.js +213 -57
  164. package/dist/storage/d1-driver.js +33 -5
  165. package/dist/storage/keys.js +16 -7
  166. package/dist/storage/sql-native.js +60 -3
  167. package/dist/storage/sql.js +6 -0
  168. package/dist/timing-trace.js +84 -0
  169. package/dist/tool-safety.d.ts +33 -0
  170. package/dist/tool-safety.js +51 -15
  171. package/dist/types.d.ts +36 -0
  172. package/dist/url-safety.js +38 -20
  173. package/dist/usage-guide.js +14 -13
  174. package/dist/utility-recovery.js +33 -0
  175. package/dist/validate.js +29 -10
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/worker.js +60 -15
  179. package/documentation/architecture.md +152 -43
  180. package/documentation/auth.md +160 -53
  181. package/documentation/code-mode.md +396 -119
  182. package/documentation/deploying.md +35 -10
  183. package/documentation/hosted-mcp-migration.md +9 -3
  184. package/documentation/integrating.md +27 -5
  185. package/documentation/meta-tools.md +761 -157
  186. package/documentation/operating.md +91 -4
  187. package/documentation/operator-ui.md +51 -2
  188. package/documentation/providers.md +34 -20
  189. package/examples/worker/README.md +3 -3
  190. package/guides/how-it-works.md +2 -1
  191. package/guides/safety-and-trust.md +1 -3
  192. package/package.json +10 -8
  193. package/templates/node/AGENTS.md +3 -3
  194. package/templates/node/README.md +1 -1
  195. package/templates/node/package.json +1 -1
@@ -335,6 +335,8 @@ export function executorName(executor) {
335
335
  */
336
336
  export function withExecutorAdmission(executor, admission) {
337
337
  return {
338
+ ...(executor.deadlineMs !== undefined ? { deadlineMs: executor.deadlineMs } : {}),
339
+ ...(executor.maxReadBytes !== undefined ? { maxReadBytes: executor.maxReadBytes } : {}),
338
340
  async acquire(options = {}) {
339
341
  const token = await admission.acquire(options);
340
342
  let released = false;
@@ -12,6 +12,10 @@ export function brandExecutor(executor, lifecycle) {
12
12
  return executor;
13
13
  }
14
14
  export function assertExecutor(executor) {
15
+ const { deadlineMs } = executor;
16
+ if (deadlineMs !== undefined && (!Number.isSafeInteger(deadlineMs) || deadlineMs < 1)) {
17
+ throw new Error("ConnectaConfig.executor.deadlineMs must be a positive whole number of milliseconds.");
18
+ }
15
19
  const descriptor = Object.getOwnPropertyDescriptor(executor, EXECUTOR_BRAND);
16
20
  const brand = descriptor?.value;
17
21
  if (typeof executor.execute === "function" &&
@@ -35,6 +39,8 @@ export function customExecutor(executor, options) {
35
39
  const name = executorName(executor);
36
40
  const wrapper = {
37
41
  ...(name ? { name } : {}),
42
+ ...(executor.deadlineMs !== undefined ? { deadlineMs: executor.deadlineMs } : {}),
43
+ ...(executor.maxReadBytes !== undefined ? { maxReadBytes: executor.maxReadBytes } : {}),
38
44
  execute: executor.execute.bind(executor),
39
45
  ...(executor.close ? { close: executor.close.bind(executor) } : {}),
40
46
  ...(isAdmittingExecutor(executor)
@@ -1,6 +1,6 @@
1
- import { META_ROUTES } from "./agent-routes.js";
1
+ import { META_ROUTES } from "./route-names.js";
2
2
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
3
- import { msg } from "./errors.js";
3
+ import { msg, tokenPrefix } from "./errors.js";
4
4
  /** ~6k tokens. Sandbox code should filter data down before returning. */
5
5
  export const MAX_EXECUTE_RESULT_CHARS = CONFIG_DEFAULTS.execute.maxResultChars;
6
6
  export const MAX_EXECUTE_LOG_CHARS = 4_000;
@@ -64,7 +64,7 @@ export function guardExecuteResultValue(value, maxChars = MAX_EXECUTE_RESULT_CHA
64
64
  export function truncateExecuteText(text, max) {
65
65
  if (text.length <= max)
66
66
  return text;
67
- return `${text.slice(0, max)}\n--- TRUNCATED (${text.length} chars total) — filter/map/slice data inside your code and return only what you need ---`;
67
+ return `${tokenPrefix(text, max)}\n--- TRUNCATED (${text.length} chars total) — filter/map/slice data inside your code and return only what you need ---`;
68
68
  }
69
69
  /**
70
70
  * Validate serialization before IPC without discarding the program return.
@@ -1,11 +1,18 @@
1
1
  import { prepareExecuteResultForTransport } from "../executor-result.js";
2
- import { InvocationFailure } from "../invocation.js";
2
+ import { InvocationFailure } from "../invocation-failure.js";
3
3
  import { msg } from "../errors.js";
4
4
  import { MAX_QUICKJS_IPC_BYTES, MAX_QUICKJS_RESULT_BYTES, QUICKJS_RESULT_CHUNK_CHARS, MAX_QUICKJS_HOST_RPC_BYTES, serializedBytes, stringifyBounded, } from "./quickjs-protocol.js";
5
5
  import { executeQuickJs, prepareQuickJs } from "./quickjs-runtime.js";
6
6
  let activeJobId;
7
7
  let nextCallId = 1;
8
8
  const pending = new Map();
9
+ /** Host calls still open will never hear back; settling them lets their handles go. */
10
+ function settlePending() {
11
+ for (const request of pending.values()) {
12
+ request.reject(new Error("Execution ended before the host call settled."));
13
+ }
14
+ pending.clear();
15
+ }
9
16
  function send(message) {
10
17
  if (!process.send)
11
18
  throw new Error("QuickJS child IPC channel is unavailable.");
@@ -70,9 +77,14 @@ async function run(payload) {
70
77
  jobId: payload.id,
71
78
  payloadJson: stringifyBounded(entry, "QuickJS log entry"),
72
79
  }));
80
+ // Answer only once the runtime is freed, so the parent knows whether this child can take the
81
+ // next run before it could send one. Only the settled stragglers stand in the way.
82
+ settlePending();
83
+ const tornDown = await raw.tornDown;
73
84
  const prepared = {
74
85
  outcome: prepareExecuteResultForTransport(raw),
75
86
  ...(raw.timedOut ? { timedOut: true } : {}),
87
+ ...(tornDown === false ? { retire: true } : {}),
76
88
  };
77
89
  let payloadJson;
78
90
  try {
@@ -85,10 +97,7 @@ async function run(payload) {
85
97
  }
86
98
  finally {
87
99
  activeJobId = undefined;
88
- for (const request of pending.values()) {
89
- request.reject(new Error("Execution ended before the host call settled."));
90
- }
91
- pending.clear();
100
+ settlePending();
92
101
  }
93
102
  }
94
103
  process.on("message", (message) => {
@@ -16,6 +16,8 @@ export declare function normalizeCode(code: string): string;
16
16
  */
17
17
  export interface QuickJsExecutionResult extends ExecuteResult {
18
18
  timedOut?: boolean;
19
+ /** Settles once the run's runtime is freed; false when freeing it failed. */
20
+ tornDown?: Promise<boolean>;
19
21
  }
20
22
  /**
21
23
  * Execute one program inside the child. Wall time includes host waits; guest
@@ -9,8 +9,8 @@ import { utf8Length } from "../bytes.js";
9
9
  // continuations from a host-side loop. Values cross the boundary as JSON, so
10
10
  // provider args/results must be JSON-serializable.
11
11
  import { getQuickJS } from "quickjs-emscripten";
12
- import { InvocationFailure } from "../invocation.js";
13
- import { msg } from "../errors.js";
12
+ import { InvocationFailure } from "../invocation-failure.js";
13
+ import { msg, tokenPrefix } from "../errors.js";
14
14
  import { withoutProgramTerminator } from "../program-source.js";
15
15
  import { hostCallLabel, MAX_QUICKJS_LOG_TRANSPORT_BYTES, serializedBytes } from "./quickjs-protocol.js";
16
16
  /** Load and compile the shared QuickJS WASM module before a run budget starts. */
@@ -28,10 +28,8 @@ const MAX_LOG_TOTAL_CHARS = 256_000;
28
28
  const LOG_ENTRY_LIMIT_MARKER = `[log truncated after ${MAX_LOG_ENTRIES} entries]`;
29
29
  const LOG_SIZE_LIMIT_MARKER = "[log truncated: size budget exceeded]";
30
30
  const MAX_LOG_MARKER_TRANSPORT_BYTES = Math.max(logTransportBytes(LOG_ENTRY_LIMIT_MARKER), logTransportBytes(LOG_SIZE_LIMIT_MARKER));
31
- // Keep one host result below the range where quickjs-emscripten@0.32.0 can
32
- // nondeterministically fail during runtime disposal under concurrent load.
33
- // This still lets guest code reduce data more than ten times larger than
34
- // connecta's final response budget.
31
+ // Bounds what one host result puts in the guest. This still lets guest code
32
+ // reduce data more than ten times larger than connecta's final response budget.
35
33
  const MAX_HOST_RESULT_BYTES = 256 * 1024;
36
34
  function logTransportBytes(entry) {
37
35
  // The log is encoded into ExecutionPayload, then that payloadJson string is
@@ -159,7 +157,7 @@ function installBridge(ctx, providers, logs, onLog) {
159
157
  // but slicing here keeps only a bounded string alive in `logs`.
160
158
  let entry = ctx.getString(h);
161
159
  if (entry.length > MAX_LOG_ENTRY_CHARS) {
162
- entry = `${entry.slice(0, MAX_LOG_ENTRY_CHARS)}…[entry truncated]`;
160
+ entry = `${tokenPrefix(entry, MAX_LOG_ENTRY_CHARS)}…[entry truncated]`;
163
161
  }
164
162
  const entryTransportBytes = logTransportBytes(entry);
165
163
  if (logTotalChars + entry.length > MAX_LOG_TOTAL_CHARS ||
@@ -222,7 +220,7 @@ function installBridge(ctx, providers, logs, onLog) {
222
220
  }
223
221
  else {
224
222
  const error = ctx.newError();
225
- const message = ctx.unwrapResult(ctx.evalCode(JSON.stringify(msg(reply.error).slice(0, 4_000))));
223
+ const message = ctx.unwrapResult(ctx.evalCode(JSON.stringify(tokenPrefix(msg(reply.error), 4_000))));
226
224
  ctx.setProp(error, "message", message);
227
225
  message.dispose();
228
226
  if (reply.error instanceof InvocationFailure) {
@@ -262,6 +260,13 @@ export async function executeQuickJs(code, providers, options, onLog) {
262
260
  const { timeoutMs, cpuTimeMs, memoryLimitBytes, maxStackSizeBytes } = options;
263
261
  const QuickJS = await getQuickJS();
264
262
  const ctx = QuickJS.newContext();
263
+ // quickjs-emscripten-core 0.32.0's executePendingJobs reads its context out-pointer through a
264
+ // typed array, which detaches when WASM memory grows during the job pass. It then reads
265
+ // undefined and calls newContext({ contextPointer: undefined }), which creates a context
266
+ // nothing frees, and freeing the runtime aborts. A run has this one context, and that lookup
267
+ // is the only thing here that reaches newContext, so it always means this one.
268
+ // Remove once a release fixes it: justjake/quickjs-emscripten#240, fix in PR #248.
269
+ ctx.runtime.newContext = () => ctx;
265
270
  const deadline = Date.now() + timeoutMs;
266
271
  const timeoutError = `Execution timed out after ${timeoutMs}ms.`;
267
272
  const cpuTimeoutError = `Execution exceeded the ${cpuTimeMs}ms guest CPU budget.`;
@@ -293,6 +298,16 @@ export async function executeQuickJs(code, providers, options, onLog) {
293
298
  let describeError;
294
299
  const logs = [];
295
300
  const bridge = installBridge(ctx, providers, logs, onLog);
301
+ // A failed free leaves this child's WASM module unusable, but the answer is already in host memory.
302
+ const dispose = () => {
303
+ try {
304
+ ctx.dispose();
305
+ return true;
306
+ }
307
+ catch {
308
+ return false;
309
+ }
310
+ };
296
311
  const finish = (r) => {
297
312
  bridge.aborted = true;
298
313
  describeError?.dispose();
@@ -307,19 +322,17 @@ export async function executeQuickJs(code, providers, options, onLog) {
307
322
  // stragglers are waiting on. Arming before the pending check (and
308
323
  // synchronously, before the first await) closes the settle-before-arm
309
324
  // window: any settle after this point resolves the promise we await.
310
- if (bridge.pending === 0)
311
- ctx.dispose();
312
- else {
313
- void (async () => {
325
+ const tornDown = bridge.pending === 0
326
+ ? Promise.resolve(dispose())
327
+ : (async () => {
314
328
  armWake(bridge);
315
329
  while (bridge.pending > 0) {
316
330
  await bridge.waitForSettle;
317
331
  armWake(bridge);
318
332
  }
319
- ctx.dispose();
333
+ return dispose();
320
334
  })();
321
- }
322
- return logs.length > 0 ? { ...r, logs } : r;
335
+ return { ...r, ...(logs.length > 0 ? { logs } : {}), tornDown };
323
336
  };
324
337
  const rejected = (error) => {
325
338
  // Interrupt/deadline facts are host state, never inferred by inspecting
@@ -332,7 +345,7 @@ export async function executeQuickJs(code, providers, options, onLog) {
332
345
  let description = { name: "Error", message: "Program threw a value." };
333
346
  const type = ctx.typeof(error);
334
347
  if (type === "string")
335
- description = ctx.getString(error).slice(0, 4000);
348
+ description = tokenPrefix(ctx.getString(error), 4000);
336
349
  else if (type === "number")
337
350
  description = ctx.getNumber(error);
338
351
  else if (type === "undefined")
@@ -347,7 +360,10 @@ export async function executeQuickJs(code, providers, options, onLog) {
347
360
  if (described.error)
348
361
  described.error.dispose();
349
362
  else {
350
- description = JSON.parse(ctx.getString(described.value)) ?? description;
363
+ const parsed = JSON.parse(ctx.getString(described.value));
364
+ if (typeof parsed?.name === "string")
365
+ parsed.name = tokenPrefix(parsed.name, 64);
366
+ description = parsed ?? description;
351
367
  described.value.dispose();
352
368
  }
353
369
  if (wallInterrupted || Date.now() >= deadline)
@@ -364,7 +380,7 @@ export async function executeQuickJs(code, providers, options, onLog) {
364
380
  const location = /:(\d+)(?::\d+)?\)?(?:\n|$)/.exec(stack);
365
381
  return {
366
382
  result: undefined,
367
- error: formatGuestError(description).slice(0, 4000),
383
+ error: tokenPrefix(formatGuestError(description), 4000),
368
384
  failure: {
369
385
  name,
370
386
  ...(call ? { call } : {}),
@@ -410,8 +426,9 @@ export async function executeQuickJs(code, providers, options, onLog) {
410
426
  for (let i = 0; i < prototypes.length; i++) {
411
427
  if (prototype === prototypes[i][0]) { name = prototypes[i][1]; break; }
412
428
  }
413
- return stringify({ __proto__: null, name: text("name", name, 64),
414
- message: text("message", "Program threw a value.", 4000), stack: text("stack", "", 1000) });
429
+ // One unit past the host's cuts, which read it to tell a finished token from an open one.
430
+ return stringify({ __proto__: null, name: text("name", name, 65),
431
+ message: text("message", "Program threw a value.", 4001), stack: text("stack", "", 1000) });
415
432
  };
416
433
  })()`));
417
434
  if (described.error) {
@@ -17,9 +17,10 @@ import { existsSync } from "node:fs";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { Deferred, Duration, Effect, Exit, Schedule, Scope } from "effect";
19
19
  import { AdmissionController, ExecutorAdmissionError, ExecutorExecutionError } from "../executor-admission.js";
20
- import { msg } from "../errors.js";
21
- import { InvocationFailure } from "../invocation.js";
20
+ import { msg, tokenPrefix } from "../errors.js";
21
+ import { InvocationFailure } from "../invocation-failure.js";
22
22
  import { brandExecutor } from "../executor-contract.js";
23
+ import { PROGRAM_RESULT_READ_BYTES } from "../program-result.js";
23
24
  import { MAX_EXECUTE_LOG_CHARS } from "../executor-result.js";
24
25
  import { admit } from "../runtime/admission.js";
25
26
  import { awaitExecutorLease, executorProgram, provideExecutorProgram } from "../runtime/executor.js";
@@ -77,7 +78,7 @@ function nonNegativeWhole(value, fallback, name) {
77
78
  function errorPayload(error) {
78
79
  return JSON.stringify({
79
80
  ok: false,
80
- error: msg(error).slice(0, MAX_ERROR_CHARS),
81
+ error: tokenPrefix(msg(error), MAX_ERROR_CHARS),
81
82
  ...(error instanceof InvocationFailure ? { call: error.details } : {}),
82
83
  });
83
84
  }
@@ -121,10 +122,12 @@ let providePoolProgram;
121
122
  class QuickJsChildPool {
122
123
  /** What `/health` and `connecta doctor` call this sandbox. */
123
124
  name = "QuickJS";
125
+ deadlineMs;
124
126
  admission;
125
127
  slots;
126
128
  available;
127
129
  runtimeOptions;
130
+ maxReadBytes;
128
131
  // Scope closes still in flight, so close() can wait out every child rather
129
132
  // than only the ones it retired itself.
130
133
  retiring = new Set();
@@ -145,6 +148,10 @@ class QuickJsChildPool {
145
148
  memoryLimitBytes: positiveWhole(options.memoryLimitBytes, DEFAULT_MEMORY_LIMIT_BYTES, "memoryLimitBytes"),
146
149
  maxStackSizeBytes: positiveWhole(options.maxStackSizeBytes, DEFAULT_STACK_LIMIT_BYTES, "maxStackSizeBytes"),
147
150
  };
151
+ this.deadlineMs = this.runtimeOptions.timeoutMs;
152
+ // Parsing and then using a value costs CPU and heap in proportion to its size,
153
+ // so a tighter budget reads less, and no budget reads past the fixed cap.
154
+ this.maxReadBytes = Math.min(Math.floor((PROGRAM_RESULT_READ_BYTES * this.runtimeOptions.cpuTimeMs) / DEFAULT_CPU_TIME_MS), 2 * 1024 * 1024, Math.floor(this.runtimeOptions.memoryLimitBytes / 16));
148
155
  this.slots = Array.from({ length: concurrency }, () => ({
149
156
  crashes: [],
150
157
  notBefore: 0,
@@ -475,9 +482,16 @@ class QuickJsChildPool {
475
482
  if (typeof entry !== "string")
476
483
  return;
477
484
  const separator = active.logs.length > 0 ? 1 : 0;
478
- const retained = entry.slice(0, MAX_EXECUTE_LOG_CHARS + 1 - active.logChars - separator);
479
- active.logs.push(retained);
480
- active.logChars += separator + retained.length;
485
+ const room = MAX_EXECUTE_LOG_CHARS + 1 - active.logChars - separator;
486
+ if (entry.length > room) {
487
+ // A cut between tokens can stop short of the bound, so its marker signals the loss.
488
+ active.logs.push(`${tokenPrefix(entry, room)}…[entry truncated]`);
489
+ active.logChars = MAX_EXECUTE_LOG_CHARS + 1;
490
+ }
491
+ else {
492
+ active.logs.push(entry);
493
+ active.logChars += separator + entry.length;
494
+ }
481
495
  }
482
496
  catch {
483
497
  /* Malformed log messages carry no output. */
@@ -519,7 +533,10 @@ class QuickJsChildPool {
519
533
  slot.backoff = undefined;
520
534
  slot.notBefore = 0;
521
535
  this.resolveActive(slot, payload.outcome);
522
- if (payload.timedOut) {
536
+ if (payload.retire) {
537
+ console.warn("[connecta] QuickJS could not free a finished run's runtime. Its answer stands; replacing the child.");
538
+ }
539
+ if (payload.timedOut || payload.retire) {
523
540
  // A timed-out host call may still retain QuickJS handles. A fresh child
524
541
  // is cheaper and safer than reusing a context with unknown stragglers.
525
542
  // The runtime sets this flag itself: matching error text here would let
@@ -1,6 +1,8 @@
1
1
  // The plain runtime slot keeps the HTTP kit and Node sender off the root
2
2
  // and Worker graphs. /node installs its sender when listen() starts serving.
3
3
  import { ConnectorCallError } from "./errors.js";
4
+ /** Internal request marker: only downstream-learned URL fetches set this. */
5
+ export const learnedDestination = Symbol("connecta.learnedDestination");
4
6
  let owned;
5
7
  /** Runtime configuration, never request data. Returns the previous slot for isolated fixtures. */
6
8
  export function configureGuardedTransport(transport) {
@@ -13,7 +13,7 @@ export const GUEST_FUNCTIONS = [
13
13
  {
14
14
  name: "search",
15
15
  budget: "hostCall",
16
- declaration: "search(args?: { query?: string; connector?: string; limit?: number; offset?: number; includeSchemas?: Schemas }): Promise<{ tools: Tool[]; catalogErrors: { connector: string; code: string; message: string }[]; hasMore: boolean; nextOffset?: number }>;",
16
+ declaration: "search(args?: { query?: string; connector?: string; limit?: number; offset?: number; includeSchemas?: Schemas; fullDescriptions?: boolean }): Promise<{ tools: Tool[]; catalogErrors: { connector: string; code: string; message: string; stale?: true }[]; absence?: { message: string }; readyCalls?: { address: string; args: { path: string } }[]; hasMore: boolean; nextOffset?: number }>;",
17
17
  },
18
18
  /** Describes exact addresses, one entry per address in order. */
19
19
  {
@@ -34,10 +34,11 @@ export const GUEST_FUNCTIONS = [
34
34
  budget: "hostCall",
35
35
  declaration: "read(uri: string): Promise<{ contents: { uri: string; text?: string; blob?: string }[] }>;",
36
36
  },
37
- /** Pages a retained result by page or UTF-8 byte offset. */
37
+ /** Reads a retained result whole, or pages it by page or UTF-8 byte offset. */
38
38
  {
39
39
  name: "result",
40
40
  budget: "hostCall",
41
+ wrapped: true,
41
42
  deadline: "result storage",
42
43
  declaration: "result(id: string | Paged, options?: { page?: number; offset?: number; maxBytes?: number }): Promise<{ text: string; hasMore: boolean; nextOffset?: number }>;",
43
44
  },
@@ -55,7 +56,7 @@ export const GUEST_FUNCTIONS = [
55
56
  budget: "emission",
56
57
  declaration: "emit(block: Block | Native | Exclude<Result, Paged>): PromiseLike<void>;",
57
58
  },
58
- /** Tells the host a failed program read a result field off an unawaited call. */
59
+ /** Tells the host a program read a result field off an unawaited call. */
59
60
  {
60
61
  name: "unawaited",
61
62
  budget: "once",
@@ -76,7 +77,7 @@ export const GUEST_API_DECLARATION = `declare const connecta: {
76
77
  ${GUEST_FUNCTIONS.flatMap((fn) => ("declaration" in fn ? [fn.declaration] : [])).join("\n")}
77
78
  };
78
79
  type Schemas = "json" | "compact" | "typescript";
79
- type Tool = { address: string; description?: string; inputSchema?: unknown; outputSchema?: unknown; signature?: string };
80
+ type Tool = { address: string; name?: string; description?: string; inputSchema?: unknown; outputSchema?: unknown; signature?: string; guideRequired?: true };
80
81
  type Paged = { format: "paged"; valueFormat: "json" | "text"; resultId?: string; totalBytes: number; truncated: true; hint: string };
81
82
  type Result = ({ format: "json"; data: unknown } | { format: "text"; data: string } | Paged) & { content?: Native[] };
82
83
  type Block = { type: "text"; text: string } | { type: "image" | "audio"; data: string; mimeType: string };
@@ -1,4 +1,5 @@
1
1
  import { GUEST_FUNCTIONS } from "./guest-functions.js";
2
+ import { LEADING_TRIVIA } from "./program-source.js";
2
3
  /** Capture the single-use runner before evaluating the guest expression. */
3
4
  export function wrapGuestProgram(code) {
4
5
  return `async () => globalThis.__connecta_run()(async (connecta) => await (\n${code}\n)())`;
@@ -109,6 +110,7 @@ export function guestInitializer() {
109
110
  const promiseCatch = Function.prototype.call.bind(NativePromise.prototype.catch);
110
111
  const promiseFinally = Function.prototype.call.bind(NativePromise.prototype.finally);
111
112
  const push = Function.prototype.call.bind(Array.prototype.push);
113
+ const join = Function.prototype.call.bind(Array.prototype.join);
112
114
  const apply = Function.prototype.call.bind(Function.prototype.apply);
113
115
  const NativeError = Error;
114
116
  const NativeWeakMap = WeakMap;
@@ -131,10 +133,35 @@ export function guestInitializer() {
131
133
  page = await hostResult(value.transfer);
132
134
  text += page.text;
133
135
  } while (page.hasMore);
134
- return { data: value.handle.valueFormat === "json" ? JSON.parse(text) : text,
136
+ const whole = { data: value.handle.valueFormat === "json" ? JSON.parse(text) : text,
135
137
  format: value.handle.valueFormat };
138
+ // Nothing can name a read's bytes now, so the host may reclaim them.
139
+ if (value.reclaim) try { await hostResult(value.transfer); } catch {}
140
+ return whole;
136
141
  } catch { return value.handle; }
137
142
  }
143
+ async function result(id, options) {
144
+ if (!options || options.all !== true) {
145
+ const page = await apply(hostResult, undefined, arguments);
146
+ // The transfer cursor is the prelude's: a getter on all must not hand it to the program.
147
+ if (page && page.transfer) delete page.transfer;
148
+ return page;
149
+ }
150
+ let page = await hostResult(id, options);
151
+ const parts = [page.text];
152
+ const transfer = page.transfer;
153
+ while (page.hasMore) {
154
+ page = await hostResult(transfer);
155
+ push(parts, page.text);
156
+ }
157
+ const text = join(parts, "");
158
+ const stored = id ? id.valueFormat : undefined;
159
+ if (stored === "text") return { data: text, format: "text" };
160
+ // A handle's JSON that won't parse throws; only a bare id, which doesn't say, may be text.
161
+ if (stored === "json") try { return { data: JSON.parse(text), format: "json" }; } catch { throw new NativeError("This result can't be parsed whole, because redaction can leave its JSON invalid. Page it with { offset } instead."); }
162
+ try { return { data: JSON.parse(text), format: "json" }; } catch {}
163
+ return { data: text, format: "text" };
164
+ }
138
165
  async function call() {
139
166
  const reply = await apply(hostCall, undefined, arguments);
140
167
  if (!reply || !reply.native) return inline(reply);
@@ -208,6 +235,7 @@ ${GUEST_NAMESPACE}
208
235
  if (unawaited) try { await provider.unawaited(); } catch {}
209
236
  throw error;
210
237
  }
238
+ if (unawaited) try { await provider.unawaited(); } catch {}
211
239
  for (let i = 0; i < pending.length; i++) {
212
240
  const report = await pending[i].settled;
213
241
  if (!report.ok && !pending[i].handled) throw report.error;
@@ -272,6 +300,14 @@ export function programError(raw, code) {
272
300
  hint =
273
301
  'Use connecta.call(address, args), connecta.search, connecta.describe, connecta.result, and connecta.skill("usage"). Services are addressed by connector.tool, never guest globals.';
274
302
  }
303
+ else if (name === "TypeError" &&
304
+ /not a function|not iterable/.test(message) &&
305
+ /\bconnecta\s*\.\s*(?:search|describe|call)\b/.test(code.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ""))) {
306
+ hint = "search and describe return { tools }; call returns { data, format }.";
307
+ }
308
+ else if (name === "SyntaxError" && !/^(?:async\b|\(|function\b)/.test(code.replace(LEADING_TRIVIA, ""))) {
309
+ hint = "Pass one expression: async () => { ...; return answer; }.";
310
+ }
275
311
  return {
276
312
  code: "program_error",
277
313
  message: `Program ${name}: ${message}`,
@@ -15,6 +15,10 @@ export interface GuestApi {
15
15
  timeoutMs?: number;
16
16
  }): Promise<GuestResult>;
17
17
  read(uri: string): Promise<GuestResourceResult>;
18
+ /** Reads a retained result whole, for one host call; a larger one throws `result_too_large`. */
19
+ result(id: string | GuestResultHandle, options: {
20
+ all: true;
21
+ }): Promise<Exclude<GuestResult, GuestResultHandle>>;
18
22
  result(id: string | GuestResultHandle, options?: {
19
23
  page?: number;
20
24
  offset?: number;
@@ -138,8 +142,16 @@ interface CatalogSearchFailure extends CatalogFailureDetail {
138
142
  connector: string;
139
143
  nextAction?: Extract<NonNullable<CallErrorDetails["nextAction"]>, {
140
144
  tool: "authorize_connector";
145
+ } | {
146
+ url: string;
147
+ } | {
148
+ action: string;
141
149
  }>;
142
150
  retry?: string;
151
+ /** This connector's tools are listed from its last complete catalog; a call still checks the live one. */
152
+ stale?: true;
153
+ /** With `stale`: milliseconds since the downstream last confirmed that catalog. */
154
+ ageMs?: number;
143
155
  }
144
156
  interface CatalogSearchPage {
145
157
  catalogErrors: CatalogSearchFailure[];
@@ -196,6 +208,7 @@ interface CatalogDescription {
196
208
  schemaFormat?: "json" | "text";
197
209
  name?: string;
198
210
  description?: string;
211
+ descriptionTruncated?: true;
199
212
  guide?: string;
200
213
  guideSummary?: string;
201
214
  guideRequired?: true;
@@ -207,6 +220,8 @@ interface CatalogDescription {
207
220
  inputSchemaTruncated?: true;
208
221
  outputSchemaTruncated?: true;
209
222
  annotations?: ToolDef["annotations"];
223
+ /** In `execute_read_code`: false when this route refuses the tool because it can write. */
224
+ callable?: false;
210
225
  error?: string;
211
226
  errorDetails?: CatalogDescriptionFailureDetail;
212
227
  }
@@ -214,6 +229,7 @@ type CatalogSearchTool = {
214
229
  name: string;
215
230
  address: string;
216
231
  description?: string;
232
+ descriptionTruncated?: true;
217
233
  inputSchema?: unknown;
218
234
  outputSchema?: unknown;
219
235
  signature?: string;
@@ -226,6 +242,8 @@ type CatalogSearchTool = {
226
242
  outputKeys?: string[];
227
243
  annotations?: ToolDef["annotations"];
228
244
  classification?: ToolDef["classification"];
245
+ /** In `execute_read_code`: false when this route refuses the tool because it can write. */
246
+ callable?: false;
229
247
  guideRequired?: true;
230
248
  guideRequiredReasons?: GuideRequiredReason[];
231
249
  };
@@ -30,6 +30,12 @@ import { RedirectRefused, Refusal, RequestRefused, ResendRefused, ResponseTooLar
30
30
  import { Classification, Dispatches, IdempotencyKey, Ledger, RequestFacts, Secrets, Signal, WriteCapacity, reserveWrite, writeRequestKey, } from "./services.js";
31
31
  const decoder = new TextDecoder();
32
32
  const responseEntries = new WeakMap();
33
+ // Private request identity for middleware observing a rejected response. Never
34
+ // attach headers or body to the failure or its payload-free ledger entry.
35
+ const failedRequests = new WeakMap();
36
+ export function failedResponseRequest(failure) {
37
+ return failedRequests.get(failure);
38
+ }
33
39
  /** The core's physical request, also available to direct HttpClient middleware. */
34
40
  export function responseRequest(response) {
35
41
  return responseEntries.get(response);
@@ -88,18 +94,23 @@ export function guardedClient(options) {
88
94
  confined = HttpClientRequest.setHeader(confined, name, value);
89
95
  }
90
96
  const secrets = yield* Secrets;
97
+ const identifiers = new Set();
91
98
  for (const [name, value] of Object.entries(yield* options.authenticate)) {
92
99
  if (confined.headers[name.toLowerCase()] !== undefined) {
93
100
  return yield* refuse(`A ${provider} request may not set the ${name.toLowerCase()} header; authentication is connector-owned.`);
94
101
  }
95
102
  confined = HttpClientRequest.setHeader(confined, name, value);
96
- secrets.header(value);
103
+ const lower = name.toLowerCase();
104
+ if (!options.publicIdentifierHeaders?.includes(lower))
105
+ secrets.header(value);
106
+ else if (secrets.publicIdentifierHeader(value))
107
+ identifiers.add(lower);
97
108
  }
98
109
  // Registered as guarded-fetch registers: the final URL, headers, and a
99
110
  // text body (a form body's secrets are read from it).
100
111
  const text = bodyText(confined);
101
112
  secrets.request(wireUrl(confined), {
102
- headers: Object.fromEntries(Object.entries(confined.headers)),
113
+ headers: Object.fromEntries(Object.entries(confined.headers).filter(([name]) => !identifiers.has(name))),
103
114
  ...(text === undefined ? {} : { body: text }),
104
115
  });
105
116
  preparedEntries.set(confined, entry);
@@ -216,6 +227,7 @@ export function guardedClient(options) {
216
227
  const refused = (failure) => {
217
228
  dispatch.failure = failure;
218
229
  responseEntries.set(failure, entry);
230
+ failedRequests.set(failure, request);
219
231
  return Effect.fail(failure);
220
232
  };
221
233
  const response = yield* Effect.tryPromise({
@@ -88,6 +88,9 @@ export function isHttpFailure(error) {
88
88
  export function validateVendorErrors(vendor) {
89
89
  errorMeanings(vendor);
90
90
  captureErrorReviews(vendor);
91
+ const limit = vendor.maxHeaderChars;
92
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_REVIEWED_HEADER))
93
+ throw new Error(`maxHeaderChars must be a whole number from 1 to ${MAX_REVIEWED_HEADER}.`);
91
94
  const proof = vendor.authBeforeExecution;
92
95
  if (proof && (typeof proof.evidence !== "string" || !proof.evidence.trim() || typeof proof.matches !== "function")) {
93
96
  throw new Error("An authBeforeExecution declaration requires nonempty evidence and a narrow raw-fact predicate.");
@@ -106,6 +109,7 @@ const TOKEN = /^[A-Za-z0-9_.:[\]-]{1,128}$/;
106
109
  /** How many entries of a list the detail line sees. Classification sees them all. */
107
110
  const DISPLAYED = 8;
108
111
  const MAX_HEADER = 128;
112
+ const MAX_REVIEWED_HEADER = 1024;
109
113
  const MAX_DEPTH = 32;
110
114
  const MAX_WAIT_MS = 86_400_000;
111
115
  const errorReviews = new WeakMap();
@@ -292,11 +296,12 @@ function displayed(facts, vendor) {
292
296
  Array.isArray(value) ? value.slice(0, display[name] ?? DISPLAYED) : value,
293
297
  ]));
294
298
  }
295
- function visibleHeaders(headers, allowed) {
299
+ function visibleHeaders(headers, vendor) {
296
300
  const out = Object.create(null);
297
- for (const name of allowed ?? []) {
301
+ const limit = vendor.maxHeaderChars ?? MAX_HEADER;
302
+ for (const name of vendor.headers ?? []) {
298
303
  const value = headers.get(name);
299
- if (value !== null && value.length <= MAX_HEADER && !hasControl(value))
304
+ if (value !== null && value.length <= limit && !hasControl(value))
300
305
  out[name.toLowerCase()] = value;
301
306
  }
302
307
  return out;
@@ -518,7 +523,7 @@ function classificationInput(failure, vendor, withheld) {
518
523
  facts: { ...redacted, ...(captured?.vendor === vendor ? captured.facts : vocabularyFacts(redacted, vendor)) },
519
524
  presence: structuralPresence(failure.body, captureErrorReviews(vendor).presence),
520
525
  numbers,
521
- headers: visibleHeaders(failure.headers, vendor.headers),
526
+ headers: visibleHeaders(failure.headers, vendor),
522
527
  retryAfterMs: numbers["retryAfterMs"],
523
528
  operation: failure.operation,
524
529
  withheld,
@@ -22,8 +22,10 @@ const ANCHORS = {
22
22
  item: "each item in items",
23
23
  data: "the response body in data, each item when the body is a list",
24
24
  };
25
+ /** What a result can carry that its schema cannot name: the withheld marker and cuts for size. */
26
+ const RESULT_NOTE = 'It only narrows the result. A withheld value reads "[redacted]"; truncated lists paths cut for size, and the ids to fetch them stay.';
25
27
  /** The description of a `fields` argument: its anchor, one valid example, then any tool note. */
26
- export function fieldsDescription(anchor, example, note = "It only narrows the result.") {
28
+ export function fieldsDescription(anchor, example, note = RESULT_NOTE) {
27
29
  return `Dot paths to keep. Paths start at ${ANCHORS[anchor]}, e.g. ${JSON.stringify([example])}. ${note}`;
28
30
  }
29
31
  /** The `fields` argument: at most 50 dot paths of 1 to 200 characters. */
@@ -174,13 +176,15 @@ export function boundWarnings(warnings) {
174
176
  const omitted = warnings.length - kept.length;
175
177
  return omitted ? [...kept, `${omitted} more warning${omitted === 1 ? "" : "s"} omitted.`] : kept;
176
178
  }
179
+ /** Enough declared names to repair a path; a wide contract's full list costs more than most replies. */
180
+ const NAMED_CHARS = 160;
177
181
  /** Declared names listed in one warning, within its character budget. */
178
182
  function named(names) {
179
183
  let text = "";
180
184
  let shown = 0;
181
185
  for (const name of names) {
182
186
  const next = text ? `${text}, ${name}` : name;
183
- if (next.length > WARNING_BUDGET.chars - 200)
187
+ if (next.length > NAMED_CHARS)
184
188
  break;
185
189
  text = next;
186
190
  shown += 1;
@@ -201,6 +205,8 @@ export function fieldsMisses(data, fields, declared = []) {
201
205
  return [];
202
206
  const flat = (values) => values.flatMap((value) => (Array.isArray(value) ? flat(value) : [value]));
203
207
  const contract = declared.map((path) => segments(path.replace(/^\[\]\.?/, "")));
208
+ // Misses stopping at the same place list its names once.
209
+ const listed = new Map();
204
210
  return fields.flatMap((field, index) => {
205
211
  const path = segments(field);
206
212
  let values = flat([data]);
@@ -219,9 +225,15 @@ export function fieldsMisses(data, fields, declared = []) {
219
225
  .filter((parts) => parts.length > depth && prefix.every((part, at) => parts[at] === part))
220
226
  .map((parts) => parts[depth])),
221
227
  ];
222
- return [
223
- `fields/${index} matched nothing in this response after value safety and privacy filtering, at segment ${depth + 1}; ${names.length ? `declared there: ${named(names)}` : "the operation declares no properties there"}.`,
224
- ];
228
+ const opening = `fields/${index} matched nothing after filtering, at segment ${depth + 1}`;
229
+ if (!names.length)
230
+ return [`${opening}; nothing is declared there.`];
231
+ const list = named(names);
232
+ const earlier = listed.get(list);
233
+ if (earlier !== undefined)
234
+ return [`${opening}; declared there: same as fields/${earlier}.`];
235
+ listed.set(list, index);
236
+ return [`${opening}; declared there: ${list}.`];
225
237
  }
226
238
  return [];
227
239
  });