@zackbart/connecta 0.7.3 → 0.7.5

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 (86) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +6 -3
  3. package/dist/connector-scope.d.ts +7 -4
  4. package/dist/connector-scope.d.ts.map +1 -1
  5. package/dist/connector-scope.js +36 -16
  6. package/dist/connector-scope.js.map +1 -1
  7. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  8. package/dist/connectors/remote-mcp.js +47 -16
  9. package/dist/connectors/remote-mcp.js.map +1 -1
  10. package/dist/credential-health.d.ts +3 -2
  11. package/dist/credential-health.d.ts.map +1 -1
  12. package/dist/credential-health.js +9 -9
  13. package/dist/credential-health.js.map +1 -1
  14. package/dist/execute.d.ts +3 -0
  15. package/dist/execute.d.ts.map +1 -1
  16. package/dist/execute.js +70 -29
  17. package/dist/execute.js.map +1 -1
  18. package/dist/executor-admission.d.ts +53 -0
  19. package/dist/executor-admission.d.ts.map +1 -0
  20. package/dist/executor-admission.js +151 -0
  21. package/dist/executor-admission.js.map +1 -0
  22. package/dist/executor-result.d.ts +13 -0
  23. package/dist/executor-result.d.ts.map +1 -0
  24. package/dist/executor-result.js +57 -0
  25. package/dist/executor-result.js.map +1 -0
  26. package/dist/executors/quickjs-child.d.ts +2 -0
  27. package/dist/executors/quickjs-child.d.ts.map +1 -0
  28. package/dist/executors/quickjs-child.js +131 -0
  29. package/dist/executors/quickjs-child.js.map +1 -0
  30. package/dist/executors/quickjs-protocol.d.ts +55 -0
  31. package/dist/executors/quickjs-protocol.d.ts.map +1 -0
  32. package/dist/executors/quickjs-protocol.js +22 -0
  33. package/dist/executors/quickjs-protocol.js.map +1 -0
  34. package/dist/executors/quickjs-runtime.d.ts +26 -0
  35. package/dist/executors/quickjs-runtime.d.ts.map +1 -0
  36. package/dist/executors/quickjs-runtime.js +383 -0
  37. package/dist/executors/quickjs-runtime.js.map +1 -0
  38. package/dist/executors/quickjs.d.ts +20 -15
  39. package/dist/executors/quickjs.d.ts.map +1 -1
  40. package/dist/executors/quickjs.js +459 -311
  41. package/dist/executors/quickjs.js.map +1 -1
  42. package/dist/index.d.ts +13 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +7 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/meta-tools.d.ts +4 -0
  47. package/dist/meta-tools.d.ts.map +1 -1
  48. package/dist/meta-tools.js +3 -2
  49. package/dist/meta-tools.js.map +1 -1
  50. package/dist/node.d.ts.map +1 -1
  51. package/dist/node.js +36 -3
  52. package/dist/node.js.map +1 -1
  53. package/dist/registry.d.ts +3 -2
  54. package/dist/registry.d.ts.map +1 -1
  55. package/dist/registry.js +4 -4
  56. package/dist/registry.js.map +1 -1
  57. package/dist/server.d.ts.map +1 -1
  58. package/dist/server.js +11 -4
  59. package/dist/server.js.map +1 -1
  60. package/dist/types.d.ts +17 -0
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/ui.d.ts +2 -1
  63. package/dist/ui.d.ts.map +1 -1
  64. package/dist/ui.js +3 -3
  65. package/dist/ui.js.map +1 -1
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/package.json +1 -1
  69. package/src/connector-scope.ts +43 -18
  70. package/src/connectors/remote-mcp.ts +56 -19
  71. package/src/credential-health.ts +20 -6
  72. package/src/execute.ts +93 -35
  73. package/src/executor-admission.ts +229 -0
  74. package/src/executor-result.ts +63 -0
  75. package/src/executors/quickjs-child.ts +168 -0
  76. package/src/executors/quickjs-protocol.ts +77 -0
  77. package/src/executors/quickjs-runtime.ts +440 -0
  78. package/src/executors/quickjs.ts +660 -353
  79. package/src/index.ts +28 -4
  80. package/src/meta-tools.ts +9 -1
  81. package/src/node.ts +36 -2
  82. package/src/registry.ts +5 -2
  83. package/src/server.ts +10 -2
  84. package/src/types.ts +17 -0
  85. package/src/ui.ts +6 -1
  86. package/src/version.ts +1 -1
@@ -1,347 +1,495 @@
1
- // QuickJS-in-WebAssembly executor the Node-side sandbox for execute_code.
2
- //
3
- // Model-written code runs inside a QuickJS interpreter compiled to WASM:
4
- // memory-safe, no ambient authority — the guest has no fetch, filesystem,
5
- // env, timers, or imports. The only bridge to the host is `__call`, which
6
- // returns a QuickJS promise that the host resolves with a JSON payload after
7
- // running the provider function; `executePendingJobs` drives guest
8
- // continuations from a host-side loop. Values cross the boundary as JSON, so
9
- // provider args/results must be JSON-serializable.
10
- import { getQuickJS, shouldInterruptAfterDeadline, } from "quickjs-emscripten";
11
- const MAX_LOG_ENTRIES = 200;
12
- // Cap each entry AND the cumulative buffer at capture time so untrusted guest
13
- // code can't retain unbounded host memory: a single `console.log("x".repeat(N))`
14
- // otherwise copies the whole N-char guest string into a host array we hold for
15
- // the entire execution. 8k chars/entry is generous for glue-code logging (the
16
- // join in execute.ts trims the assembled log to 4k anyway), and 256k total
17
- // keeps the worst case — 200 maxed-out entries — bounded well under a MiB.
18
- const MAX_LOG_ENTRY_CHARS = 8_000;
19
- const MAX_LOG_TOTAL_CHARS = 256_000;
20
- // Keep one host result below the range where quickjs-emscripten@0.32.0 can
21
- // nondeterministically fail during runtime disposal under concurrent load.
22
- // This still lets guest code reduce data more than ten times larger than
23
- // connecta's final response budget.
24
- const MAX_HOST_RESULT_BYTES = 256 * 1024;
1
+ // Node's built-in code-mode executor. QuickJS itself lives in a disposable
2
+ // child process: guest CPU, WASM aborts, and interpreter OOMs cannot block or
3
+ // terminate the HTTP-serving process.
4
+ import { fork } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { AdmissionController, ExecutorAdmissionError, } from "../executor-admission.js";
7
+ import { MAX_QUICKJS_IPC_BYTES, MAX_QUICKJS_HOST_RPC_BYTES, serializedBytes, stringifyBounded, } from "./quickjs-protocol.js";
8
+ export { normalizeCode } from "./quickjs-runtime.js";
9
+ const DEFAULT_TIMEOUT_MS = 30_000;
10
+ const DEFAULT_CPU_TIME_MS = 250;
11
+ const DEFAULT_MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
12
+ const DEFAULT_STACK_LIMIT_BYTES = 1024 * 1024;
13
+ const DEFAULT_CONCURRENCY = 1;
14
+ const DEFAULT_MAX_QUEUE_SIZE = 32;
15
+ const DEFAULT_QUEUE_TIMEOUT_MS = 5_000;
16
+ const CHILD_EXIT_GRACE_MS = 250;
17
+ const CHILD_STARTUP_TIMEOUT_MS = 10_000;
18
+ const MAX_ERROR_CHARS = 4_000;
25
19
  function msg(err) {
26
20
  return err instanceof Error ? err.message : String(err);
27
21
  }
28
- function exceedsUtf8ByteLimit(value, limit) {
29
- let bytes = 0;
30
- for (let index = 0; index < value.length; index += 1) {
31
- const code = value.charCodeAt(index);
32
- if (code <= 0x7f)
33
- bytes += 1;
34
- else if (code <= 0x7ff)
35
- bytes += 2;
36
- else if (code >= 0xd800 &&
37
- code <= 0xdbff &&
38
- index + 1 < value.length &&
39
- value.charCodeAt(index + 1) >= 0xdc00 &&
40
- value.charCodeAt(index + 1) <= 0xdfff) {
41
- bytes += 4;
42
- index += 1;
43
- }
44
- else {
45
- bytes += 3;
46
- }
47
- if (bytes > limit)
48
- return true;
22
+ function positiveWhole(value, fallback, name) {
23
+ const resolved = value ?? fallback;
24
+ if (!Number.isFinite(resolved) ||
25
+ !Number.isInteger(resolved) ||
26
+ resolved < 1) {
27
+ throw new TypeError(`${name} must be a positive whole number.`);
49
28
  }
50
- return false;
51
- }
52
- /** Normalize model output into an async-arrow expression: strip markdown fences, wrap bare bodies. */
53
- export function normalizeCode(code) {
54
- let c = code.trim();
55
- const fence = /^```[\w-]*\s*\n([\s\S]*?)\n?```$/.exec(c);
56
- if (fence)
57
- c = fence[1].trim();
58
- // Sniff for an existing function expression past any leading comments/blank
59
- // lines — models routinely prefix their code with a `//` or `/* */` note,
60
- // which would otherwise defeat the detection and wrap the whole thing.
61
- const head = c.replace(/^(?:\s+|\/\/[^\n]*|\/\*[\s\S]*?\*\/)+/, "");
62
- if (/^async\b/.test(head) || head.startsWith("("))
63
- return c;
64
- return `async () => {\n${c}\n}`;
65
- }
66
- /** Guest-side prelude: console capture, the JSON call bridge, one frozen global per provider. */
67
- function setupScript(providers) {
68
- const lines = [
69
- `globalThis.console = (() => {
70
- const fmt = (x) => { if (typeof x === "string") return x; try { return JSON.stringify(x); } catch { return String(x); } };
71
- const emit = (...a) => __log(a.map(fmt).join(" "));
72
- return { log: emit, info: emit, warn: emit, error: emit, debug: emit };
73
- })();`,
74
- `globalThis.__invoke = async (ns, fn, args) => {
75
- const r = JSON.parse(await __call(ns, fn, JSON.stringify(args)));
76
- if (!r.ok) throw new Error(r.error);
77
- return r.value;
78
- };`,
79
- ];
80
- for (const p of providers) {
81
- const ns = JSON.stringify(p.name);
82
- const fields = Object.keys(p.fns).map((fn) => {
83
- const f = JSON.stringify(fn);
84
- // Forward every argument verbatim, positionally — same as codemode's
85
- // sandbox proxy (`(...args) => call(fn, args)`). The host wrappers in
86
- // buildSandboxProviders already guard the no-arg case, so no coercion.
87
- return ` [${f}]: (...a) => __invoke(${ns}, ${f}, a),`;
88
- });
89
- lines.push(`globalThis[${ns}] = Object.freeze({\n${fields.join("\n")}\n});`);
90
- }
91
- return lines.join("\n");
92
- }
93
- /** True when a dumped error is QuickJS's deadline-interrupt signal. */
94
- function isInterrupt(dumped) {
95
- if (!dumped || typeof dumped !== "object")
96
- return false;
97
- const e = dumped;
98
- return e.name === "InternalError" && e.message === "interrupted";
29
+ return resolved;
99
30
  }
100
- function formatGuestError(dumped) {
101
- if (dumped && typeof dumped === "object") {
102
- const e = dumped;
103
- if (e.message != null) {
104
- return e.name != null && e.name !== "Error"
105
- ? `${String(e.name)}: ${String(e.message)}`
106
- : String(e.message);
107
- }
31
+ function nonNegativeWhole(value, fallback, name) {
32
+ const resolved = value ?? fallback;
33
+ if (!Number.isFinite(resolved) ||
34
+ !Number.isInteger(resolved) ||
35
+ resolved < 0) {
36
+ throw new TypeError(`${name} must be a non-negative whole number.`);
108
37
  }
109
- return typeof dumped === "string" ? dumped : JSON.stringify(dumped);
38
+ return resolved;
110
39
  }
111
- function armWake(bridge) {
112
- bridge.waitForSettle = new Promise((resolve) => {
113
- bridge.wake = resolve;
40
+ function errorPayload(error) {
41
+ return JSON.stringify({
42
+ ok: false,
43
+ error: error.slice(0, MAX_ERROR_CHARS),
114
44
  });
115
45
  }
116
- function waitForHostOrDeadline(waitForSettle, remainingMs) {
117
- return new Promise((resolve) => {
118
- let done = false;
46
+ function delay(ms, signal) {
47
+ if (ms <= 0)
48
+ return Promise.resolve();
49
+ return new Promise((resolve, reject) => {
119
50
  const timer = setTimeout(() => {
120
- done = true;
121
- resolve(false);
122
- }, remainingMs);
123
- void waitForSettle.then(() => {
124
- if (done)
125
- return;
126
- done = true;
51
+ signal?.removeEventListener("abort", onAbort);
52
+ resolve();
53
+ }, ms);
54
+ const onAbort = () => {
127
55
  clearTimeout(timer);
128
- resolve(true);
129
- });
56
+ signal?.removeEventListener("abort", onAbort);
57
+ reject(new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled while the sandbox was restarting."));
58
+ };
59
+ signal?.addEventListener("abort", onAbort, { once: true });
60
+ if (signal?.aborted)
61
+ onAbort();
130
62
  });
131
63
  }
132
- function installBridge(ctx, providers, logs) {
133
- const bridge = {
134
- pending: 0,
135
- aborted: false,
136
- wake: () => { },
137
- waitForSettle: Promise.resolve(),
138
- };
139
- armWake(bridge);
140
- // Running total of chars actually retained in `logs`; once the cumulative
141
- // budget is spent we push one marker and drop the rest, so a flood of large
142
- // entries can't grow the host array without bound.
143
- let logTotalChars = 0;
144
- let logBudgetSpent = false;
145
- const logFn = ctx.newFunction("__log", (h) => {
146
- if (logs.length >= MAX_LOG_ENTRIES) {
147
- if (logs.length === MAX_LOG_ENTRIES) {
148
- logs.push(`[log truncated after ${MAX_LOG_ENTRIES} entries]`);
149
- }
64
+ function waitForReady(ready, signal) {
65
+ if (!signal)
66
+ return ready;
67
+ return new Promise((resolve, reject) => {
68
+ let settled = false;
69
+ const finish = (operation) => {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ signal.removeEventListener("abort", onAbort);
74
+ operation();
75
+ };
76
+ const onAbort = () => finish(() => reject(new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled while the sandbox was starting.")));
77
+ signal.addEventListener("abort", onAbort, { once: true });
78
+ if (signal.aborted) {
79
+ onAbort();
150
80
  return;
151
81
  }
152
- if (logBudgetSpent)
153
- return;
154
- // Cap the entry before retaining it: `getString` yields a transient copy,
155
- // but slicing here keeps only a bounded string alive in `logs`.
156
- let entry = ctx.getString(h);
157
- if (entry.length > MAX_LOG_ENTRY_CHARS) {
158
- entry = `${entry.slice(0, MAX_LOG_ENTRY_CHARS)}…[entry truncated]`;
82
+ void ready.then(() => finish(resolve), (error) => finish(() => reject(error instanceof Error ? error : new Error(String(error)))));
83
+ });
84
+ }
85
+ class QuickJsChildPool {
86
+ admission;
87
+ slots;
88
+ available;
89
+ runtimeOptions;
90
+ closed = false;
91
+ nextJobId = 1;
92
+ constructor(options) {
93
+ const concurrency = positiveWhole(options.concurrency, DEFAULT_CONCURRENCY, "concurrency");
94
+ const queueTimeoutMs = positiveWhole(options.queueTimeoutMs, DEFAULT_QUEUE_TIMEOUT_MS, "queueTimeoutMs");
95
+ this.admission = new AdmissionController({
96
+ concurrency,
97
+ maxQueueSize: nonNegativeWhole(options.maxQueueSize, DEFAULT_MAX_QUEUE_SIZE, "maxQueueSize"),
98
+ queueTimeoutMs,
99
+ retryAfterMs: queueTimeoutMs,
100
+ });
101
+ this.runtimeOptions = {
102
+ timeoutMs: positiveWhole(options.timeoutMs, DEFAULT_TIMEOUT_MS, "timeoutMs"),
103
+ cpuTimeMs: positiveWhole(options.cpuTimeMs, DEFAULT_CPU_TIME_MS, "cpuTimeMs"),
104
+ memoryLimitBytes: positiveWhole(options.memoryLimitBytes, DEFAULT_MEMORY_LIMIT_BYTES, "memoryLimitBytes"),
105
+ maxStackSizeBytes: positiveWhole(options.maxStackSizeBytes, DEFAULT_STACK_LIMIT_BYTES, "maxStackSizeBytes"),
106
+ };
107
+ this.slots = Array.from({ length: concurrency }, () => ({
108
+ consecutiveCrashes: 0,
109
+ notBefore: 0,
110
+ }));
111
+ this.available = [...this.slots];
112
+ }
113
+ async acquire(options = {}) {
114
+ const admission = await this.admission.acquire(options);
115
+ const slot = this.available.shift();
116
+ if (!slot) {
117
+ admission.release();
118
+ throw new Error("Executor admission invariant failed: no child slot.");
119
+ }
120
+ let released = false;
121
+ let executed = false;
122
+ return {
123
+ execute: async (code, providers) => {
124
+ if (released)
125
+ throw new Error("Executor lease was already released.");
126
+ if (executed)
127
+ throw new Error("Executor lease may execute only once.");
128
+ executed = true;
129
+ return this.run(slot, code, providers, options.signal);
130
+ },
131
+ release: () => {
132
+ if (released)
133
+ return;
134
+ released = true;
135
+ if (slot.active) {
136
+ this.rejectActive(slot, new Error("Executor lease was released during execution."));
137
+ this.recycle(slot);
138
+ }
139
+ this.available.push(slot);
140
+ admission.release();
141
+ },
142
+ };
143
+ }
144
+ async execute(code, providers) {
145
+ const lease = await this.acquire();
146
+ try {
147
+ return await lease.execute(code, providers);
159
148
  }
160
- if (logTotalChars + entry.length > MAX_LOG_TOTAL_CHARS) {
161
- logs.push("[log truncated: size budget exceeded]");
162
- logBudgetSpent = true;
149
+ finally {
150
+ lease.release();
151
+ }
152
+ }
153
+ async close() {
154
+ if (this.closed)
163
155
  return;
156
+ this.closed = true;
157
+ this.admission.close();
158
+ const exits = this.slots.map((slot) => this.stopSlot(slot));
159
+ await Promise.allSettled(exits);
160
+ }
161
+ async run(slot, code, providers, signal) {
162
+ if (signal?.aborted) {
163
+ throw new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled before it started.");
164
164
  }
165
- logs.push(entry);
166
- logTotalChars += entry.length;
167
- });
168
- ctx.setProp(ctx.global, "__log", logFn);
169
- logFn.dispose();
170
- const byName = new Map(providers.map((p) => [p.name, p]));
171
- const invoke = async (ns, fn, argsJson) => {
165
+ await this.ensureChild(slot, signal);
166
+ if (signal?.aborted) {
167
+ throw new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled before it started.");
168
+ }
169
+ const child = slot.child;
170
+ if (!child?.connected) {
171
+ throw new Error("QuickJS child IPC channel is unavailable.");
172
+ }
173
+ const id = this.nextJobId++;
174
+ const providerMap = new Map(providers.map((item) => [item.name, item]));
175
+ let payloadJson;
176
+ let runMessage;
172
177
  try {
173
- const provider = byName.get(ns);
174
- const f = provider && Object.hasOwn(provider.fns, fn)
175
- ? provider.fns[fn]
176
- : undefined;
177
- if (!f)
178
- throw new Error(`Unknown function ${ns}.${fn}`);
179
- const args = JSON.parse(argsJson);
180
- const value = await f(...args);
181
- let json;
178
+ payloadJson = stringifyBounded({
179
+ id,
180
+ code,
181
+ providerNames: providers.map((item) => item.name),
182
+ options: this.runtimeOptions,
183
+ }, "QuickJS run payload");
184
+ runMessage = { type: "run", payloadJson };
185
+ stringifyBounded(runMessage, "QuickJS run IPC envelope");
186
+ }
187
+ catch (err) {
188
+ return { result: undefined, error: msg(err) };
189
+ }
190
+ child.ref();
191
+ child.channel?.ref();
192
+ return new Promise((resolve, reject) => {
193
+ const wallTimer = setTimeout(() => {
194
+ this.resolveActive(slot, {
195
+ result: undefined,
196
+ error: `QuickJS child exceeded the ${this.runtimeOptions.timeoutMs}ms wall budget and was terminated.`,
197
+ });
198
+ this.recycle(slot);
199
+ }, this.runtimeOptions.timeoutMs + CHILD_EXIT_GRACE_MS);
200
+ const active = {
201
+ id,
202
+ providers: providerMap,
203
+ resolve,
204
+ reject,
205
+ ...(signal ? { signal } : {}),
206
+ wallTimer,
207
+ };
208
+ active.onAbort = () => {
209
+ this.rejectActive(slot, new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled."));
210
+ this.recycle(slot);
211
+ };
212
+ signal?.addEventListener("abort", active.onAbort, { once: true });
213
+ slot.active = active;
214
+ if (signal?.aborted) {
215
+ active.onAbort();
216
+ return;
217
+ }
182
218
  try {
183
- json = JSON.stringify({ ok: true, value });
219
+ child.send(runMessage, (error) => {
220
+ if (!error || slot.active?.id !== id)
221
+ return;
222
+ this.rejectActive(slot, error);
223
+ this.recycle(slot);
224
+ });
184
225
  }
185
226
  catch (err) {
186
- throw new Error(`Host result from ${ns}.${fn} could not be serialized: ${msg(err)}`);
227
+ this.rejectActive(slot, err instanceof Error ? err : new Error(String(err)));
228
+ this.recycle(slot);
229
+ }
230
+ });
231
+ }
232
+ async ensureChild(slot, signal) {
233
+ if (this.closed) {
234
+ throw new ExecutorAdmissionError("executor_closed", "Executor is shutting down.");
235
+ }
236
+ if (slot.child?.connected) {
237
+ if (slot.ready)
238
+ await waitForReady(slot.ready, signal);
239
+ return;
240
+ }
241
+ await delay(Math.max(0, slot.notBefore - Date.now()), signal);
242
+ if (this.closed) {
243
+ throw new ExecutorAdmissionError("executor_closed", "Executor is shutting down.");
244
+ }
245
+ const sourceMode = import.meta.url.endsWith(".ts");
246
+ const childUrl = new URL(sourceMode ? "./quickjs-child.ts" : "./quickjs-child.js", import.meta.url);
247
+ const child = fork(fileURLToPath(childUrl), [], {
248
+ execArgv: sourceMode ? ["--import", "tsx"] : [],
249
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
250
+ });
251
+ slot.child = child;
252
+ slot.ready = new Promise((resolve, reject) => {
253
+ slot.resolveReady = resolve;
254
+ slot.rejectReady = reject;
255
+ });
256
+ slot.readyTimer = setTimeout(() => {
257
+ if (slot.child !== child || !slot.ready)
258
+ return;
259
+ const error = new Error(`QuickJS child did not become ready within ${CHILD_STARTUP_TIMEOUT_MS}ms.`);
260
+ this.failChildStartup(slot, child, error);
261
+ }, CHILD_STARTUP_TIMEOUT_MS);
262
+ child.on("message", (message) => {
263
+ void this.onMessage(slot, child, message);
264
+ });
265
+ child.on("error", (error) => {
266
+ if (slot.child !== child)
267
+ return;
268
+ if (slot.ready) {
269
+ this.failChildStartup(slot, child, error);
270
+ return;
187
271
  }
188
- if (exceedsUtf8ByteLimit(json, MAX_HOST_RESULT_BYTES)) {
189
- throw new Error(`Host result from ${ns}.${fn} exceeds the ${MAX_HOST_RESULT_BYTES}-byte serialized bridge limit.`);
272
+ this.rejectActive(slot, error);
273
+ this.recycle(slot);
274
+ });
275
+ child.on("exit", (code, exitSignal) => {
276
+ const expected = slot.expectedExit === child;
277
+ if (slot.expectedExit === child)
278
+ slot.expectedExit = undefined;
279
+ this.rejectChildReady(slot, child, new Error(`QuickJS child exited before becoming ready` +
280
+ ` (${exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`}).`));
281
+ if (slot.child === child)
282
+ slot.child = undefined;
283
+ if (expected)
284
+ return;
285
+ this.recordCrash(slot);
286
+ this.resolveActive(slot, {
287
+ result: undefined,
288
+ error: `QuickJS child exited unexpectedly` +
289
+ ` (${exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`}).`,
290
+ });
291
+ });
292
+ child.unref();
293
+ child.channel?.unref();
294
+ if (slot.ready)
295
+ await waitForReady(slot.ready, signal);
296
+ }
297
+ async onMessage(slot, child, message) {
298
+ // A compromised child is exactly the adversary this process boundary
299
+ // contains, and this handler's rejection would crash the serving process.
300
+ // Refuse malformed intake before touching any field.
301
+ if (!message || typeof message !== "object")
302
+ return;
303
+ if (message.type === "ready") {
304
+ this.resolveChildReady(slot, child);
305
+ return;
306
+ }
307
+ const active = slot.active;
308
+ if (!active || slot.child !== child)
309
+ return;
310
+ if (typeof message.payloadJson !== "string")
311
+ return;
312
+ if (message.type === "host-call") {
313
+ if (message.jobId !== active.id)
314
+ return;
315
+ await this.handleHostCall(child, active, message);
316
+ return;
317
+ }
318
+ if (message.type !== "result" || message.jobId !== active.id)
319
+ return;
320
+ if (serializedBytes(message.payloadJson) > MAX_QUICKJS_IPC_BYTES) {
321
+ this.resolveActive(slot, {
322
+ result: undefined,
323
+ error: "QuickJS execution result exceeded the IPC limit.",
324
+ });
325
+ this.recycle(slot);
326
+ return;
327
+ }
328
+ try {
329
+ const payload = JSON.parse(message.payloadJson);
330
+ slot.consecutiveCrashes = 0;
331
+ slot.notBefore = 0;
332
+ this.resolveActive(slot, payload.outcome);
333
+ if (payload.timedOut) {
334
+ // A timed-out host call may still retain QuickJS handles. A fresh child
335
+ // is cheaper and safer than reusing a context with unknown stragglers.
336
+ // The runtime sets this flag itself: matching error text here would let
337
+ // a guest throw "Execution timed out…" and force cold-start churn.
338
+ this.recycle(slot);
190
339
  }
191
- return json;
192
340
  }
193
341
  catch (err) {
194
- return JSON.stringify({ ok: false, error: msg(err) });
342
+ this.resolveActive(slot, {
343
+ result: undefined,
344
+ error: `QuickJS child returned an invalid result: ${msg(err)}`,
345
+ });
346
+ this.recycle(slot);
195
347
  }
196
- };
197
- const callFn = ctx.newFunction("__call", (nsH, fnH, argsH) => {
198
- const ns = ctx.getString(nsH);
199
- const fn = ctx.getString(fnH);
200
- const argsJson = ctx.getString(argsH);
201
- const deferred = ctx.newPromise();
202
- bridge.pending++;
203
- void invoke(ns, fn, argsJson).then((json) => {
204
- bridge.pending--;
205
- if (bridge.aborted) {
206
- // Context is (about to be) torn down — settle nothing, free our handles.
207
- deferred.dispose();
348
+ }
349
+ async handleHostCall(child, active, message) {
350
+ let payloadJson;
351
+ try {
352
+ if (serializedBytes(message.payloadJson) >
353
+ MAX_QUICKJS_HOST_RPC_BYTES) {
354
+ throw new RangeError("Host call arguments exceeded the IPC limit.");
208
355
  }
209
- else {
210
- const h = ctx.newString(json);
211
- deferred.resolve(h);
212
- h.dispose();
356
+ const payload = JSON.parse(message.payloadJson);
357
+ const provider = active.providers.get(payload.namespace);
358
+ const fn = provider && Object.hasOwn(provider.fns, payload.functionName)
359
+ ? provider.fns[payload.functionName]
360
+ : undefined;
361
+ if (!fn) {
362
+ throw new Error(`Unknown function ${payload.namespace}.${payload.functionName}`);
213
363
  }
214
- bridge.wake();
364
+ const value = await fn(...payload.args);
365
+ try {
366
+ payloadJson = stringifyBounded({ ok: true, value }, `Host result from ${payload.namespace}.${payload.functionName}`, MAX_QUICKJS_HOST_RPC_BYTES);
367
+ }
368
+ catch (err) {
369
+ const detail = err instanceof RangeError
370
+ ? `Host result from ${payload.namespace}.${payload.functionName} exceeds the ${MAX_QUICKJS_HOST_RPC_BYTES}-byte serialized bridge limit.`
371
+ : `Host result from ${payload.namespace}.${payload.functionName} could not be serialized: ${msg(err)}`;
372
+ payloadJson = errorPayload(detail);
373
+ }
374
+ }
375
+ catch (err) {
376
+ payloadJson = errorPayload(msg(err));
377
+ }
378
+ if (!child.connected)
379
+ return;
380
+ try {
381
+ const response = {
382
+ type: "host-result",
383
+ jobId: message.jobId,
384
+ callId: message.callId,
385
+ payloadJson,
386
+ };
387
+ stringifyBounded(response, "QuickJS host-result IPC envelope");
388
+ child.send(response);
389
+ }
390
+ catch {
391
+ // The execution has already ended or the child is exiting. Its exit
392
+ // handler owns the structured failure for any still-active job.
393
+ }
394
+ }
395
+ resolveActive(slot, outcome) {
396
+ const active = slot.active;
397
+ if (!active)
398
+ return;
399
+ this.clearActive(slot, active);
400
+ active.resolve(outcome);
401
+ }
402
+ rejectActive(slot, error) {
403
+ const active = slot.active;
404
+ if (!active)
405
+ return;
406
+ this.clearActive(slot, active);
407
+ active.reject(error);
408
+ }
409
+ clearActive(slot, active) {
410
+ clearTimeout(active.wallTimer);
411
+ if (active.onAbort) {
412
+ active.signal?.removeEventListener("abort", active.onAbort);
413
+ }
414
+ slot.active = undefined;
415
+ slot.child?.unref();
416
+ slot.child?.channel?.unref();
417
+ }
418
+ resolveChildReady(slot, child) {
419
+ if (slot.child !== child || !slot.ready)
420
+ return;
421
+ if (slot.readyTimer)
422
+ clearTimeout(slot.readyTimer);
423
+ const resolve = slot.resolveReady;
424
+ slot.ready = undefined;
425
+ slot.resolveReady = undefined;
426
+ slot.rejectReady = undefined;
427
+ slot.readyTimer = undefined;
428
+ resolve?.();
429
+ }
430
+ rejectChildReady(slot, child, error) {
431
+ if (slot.child !== child || !slot.ready)
432
+ return;
433
+ if (slot.readyTimer)
434
+ clearTimeout(slot.readyTimer);
435
+ const reject = slot.rejectReady;
436
+ slot.ready = undefined;
437
+ slot.resolveReady = undefined;
438
+ slot.rejectReady = undefined;
439
+ slot.readyTimer = undefined;
440
+ reject?.(error);
441
+ }
442
+ recordCrash(slot) {
443
+ slot.consecutiveCrashes++;
444
+ slot.notBefore =
445
+ Date.now() +
446
+ Math.min(5_000, 100 * 2 ** (slot.consecutiveCrashes - 1));
447
+ }
448
+ failChildStartup(slot, child, error) {
449
+ if (slot.child !== child || !slot.ready)
450
+ return;
451
+ // Detach before the rejected readiness promise can release the lease. A
452
+ // queued successor must never observe a connected child that is unready
453
+ // and already on its way out.
454
+ slot.expectedExit = child;
455
+ this.rejectChildReady(slot, child, error);
456
+ slot.child = undefined;
457
+ this.recordCrash(slot);
458
+ child.kill();
459
+ }
460
+ recycle(slot) {
461
+ const child = slot.child;
462
+ if (!child)
463
+ return;
464
+ this.rejectChildReady(slot, child, new Error("QuickJS child was recycled before becoming ready."));
465
+ slot.expectedExit = child;
466
+ slot.child = undefined;
467
+ child.kill();
468
+ }
469
+ stopSlot(slot) {
470
+ const child = slot.child;
471
+ if (!child)
472
+ return Promise.resolve();
473
+ this.rejectActive(slot, new ExecutorAdmissionError("executor_closed", "Executor is shutting down."));
474
+ this.rejectChildReady(slot, child, new ExecutorAdmissionError("executor_closed", "Executor is shutting down."));
475
+ slot.expectedExit = child;
476
+ slot.child = undefined;
477
+ return new Promise((resolve) => {
478
+ const timer = setTimeout(resolve, 1_000);
479
+ child.once("exit", () => {
480
+ clearTimeout(timer);
481
+ resolve();
482
+ });
483
+ child.kill();
215
484
  });
216
- return deferred.handle;
217
- });
218
- ctx.setProp(ctx.global, "__call", callFn);
219
- callFn.dispose();
220
- return bridge;
485
+ }
221
486
  }
222
487
  /**
223
- * Sandbox executor for Node (or any JS runtime) built on quickjs-emscripten.
224
- * Suits connecta's execute_code: code is tool-call glue, so an interpreter's
225
- * speed is irrelevant while its isolation (plain WASM, zero native deps) is
226
- * the point. One fresh QuickJS context per execution; the WASM module itself
227
- * is cached across calls by quickjs-emscripten.
488
+ * Bounded Node QuickJS executor. Admission is acquired before connecta builds
489
+ * providers; one lease maps to one child and carries execution, avoiding a
490
+ * double-acquire deadlock at concurrency 1.
228
491
  */
229
492
  export function quickJsExecutor(options = {}) {
230
- const timeoutMs = options.timeoutMs ?? 30_000;
231
- const memoryLimitBytes = options.memoryLimitBytes ?? 64 * 1024 * 1024;
232
- const maxStackSizeBytes = options.maxStackSizeBytes ?? 1024 * 1024;
233
- return {
234
- async execute(code, providers) {
235
- const QuickJS = await getQuickJS();
236
- const ctx = QuickJS.newContext();
237
- const deadline = Date.now() + timeoutMs;
238
- const timeoutError = `Execution timed out after ${timeoutMs}ms.`;
239
- ctx.runtime.setMemoryLimit(memoryLimitBytes);
240
- ctx.runtime.setMaxStackSize(maxStackSizeBytes);
241
- ctx.runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadline));
242
- const logs = [];
243
- const bridge = installBridge(ctx, providers, logs);
244
- const finish = (r) => {
245
- bridge.aborted = true;
246
- // Outstanding host calls still hold deferred-promise handles; their
247
- // callbacks free them and wake the drain, which disposes the context
248
- // once the last straggler settles. Re-arm before every wait so the
249
- // deferred never resolves-and-stays-resolved between settles — that
250
- // would spin the microtask queue and starve the very timers the
251
- // stragglers are waiting on. Arming before the pending check (and
252
- // synchronously, before the first await) closes the settle-before-arm
253
- // window: any settle after this point resolves the promise we await.
254
- if (bridge.pending === 0)
255
- ctx.dispose();
256
- else {
257
- void (async () => {
258
- armWake(bridge);
259
- while (bridge.pending > 0) {
260
- await bridge.waitForSettle;
261
- armWake(bridge);
262
- }
263
- ctx.dispose();
264
- })();
265
- }
266
- return logs.length > 0 ? { ...r, logs } : r;
267
- };
268
- const fail = (error) => finish({ result: undefined, error });
269
- const setup = ctx.evalCode(setupScript(providers));
270
- if (setup.error) {
271
- const detail = formatGuestError(ctx.dump(setup.error));
272
- setup.error.dispose();
273
- return fail(`Sandbox setup failed: ${detail}`);
274
- }
275
- setup.value.dispose();
276
- const evaluated = ctx.evalCode(`Promise.resolve((\n${normalizeCode(code)}\n)())`);
277
- if (evaluated.error) {
278
- const dumped = ctx.dump(evaluated.error);
279
- evaluated.error.dispose();
280
- // A synchronous runaway (no await to yield on) trips the deadline
281
- // interrupt here; surface the friendly timeout, not "interrupted".
282
- if (isInterrupt(dumped) && Date.now() >= deadline)
283
- return fail(timeoutError);
284
- return fail(formatGuestError(dumped));
285
- }
286
- const promiseHandle = evaluated.value;
287
- // Drive the guest: run microtask jobs, inspect the result promise,
288
- // sleep until a host call settles (or the budget runs out), repeat.
289
- // Returns without touching the context lifecycle; disposal happens below.
290
- const drive = async () => {
291
- for (;;) {
292
- const jobs = ctx.runtime.executePendingJobs();
293
- if (jobs.error) {
294
- const dumped = ctx.dump(jobs.error);
295
- jobs.error.dispose();
296
- // A runaway inside a resumed continuation trips the interrupt here.
297
- if (isInterrupt(dumped) && Date.now() >= deadline) {
298
- return { result: undefined, error: timeoutError };
299
- }
300
- return { result: undefined, error: formatGuestError(dumped) };
301
- }
302
- const state = ctx.getPromiseState(promiseHandle);
303
- if (state.type === "fulfilled") {
304
- const result = ctx.dump(state.value);
305
- state.value.dispose();
306
- return { result };
307
- }
308
- if (state.type === "rejected") {
309
- const dumped = ctx.dump(state.error);
310
- state.error.dispose();
311
- // A synchronous runaway rejects the async fn's promise with the
312
- // interrupt; surface the friendly timeout, not "interrupted".
313
- if (isInterrupt(dumped) && Date.now() >= deadline) {
314
- return { result: undefined, error: timeoutError };
315
- }
316
- return { result: undefined, error: formatGuestError(dumped) };
317
- }
318
- if (bridge.pending === 0) {
319
- return {
320
- result: undefined,
321
- error: "Execution stalled: code awaits something that can never settle.",
322
- };
323
- }
324
- const remaining = deadline - Date.now();
325
- if (remaining <= 0) {
326
- return { result: undefined, error: timeoutError };
327
- }
328
- const settled = await waitForHostOrDeadline(bridge.waitForSettle, remaining);
329
- if (settled)
330
- armWake(bridge);
331
- else {
332
- return { result: undefined, error: timeoutError };
333
- }
334
- }
335
- };
336
- let outcome;
337
- try {
338
- outcome = await drive();
339
- }
340
- finally {
341
- promiseHandle.dispose();
342
- }
343
- return finish(outcome);
344
- },
345
- };
493
+ return new QuickJsChildPool(options);
346
494
  }
347
495
  //# sourceMappingURL=quickjs.js.map