@noodleseed/one 0.139.4 → 0.141.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 (100) hide show
  1. package/dist/metrics-render.d.ts +8 -0
  2. package/dist/metrics-render.d.ts.map +1 -1
  3. package/dist/metrics-render.js +7 -1
  4. package/dist/metrics-render.js.map +1 -1
  5. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +3 -3
  6. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +9 -1
  7. package/node_modules/@noodle-borg/app-package/dist/index.d.ts +1 -0
  8. package/node_modules/@noodle-borg/app-package/dist/index.js +1 -0
  9. package/node_modules/@noodle-borg/{compiler → app-package}/dist/schema-validation.d.ts +2 -1
  10. package/node_modules/@noodle-borg/app-package/package.json +2 -0
  11. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-app-sandbox.d.ts +13 -0
  12. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-app-sandbox.js +204 -0
  13. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.d.ts +22 -0
  14. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.js +10 -7
  15. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-transcript.d.ts +18 -0
  16. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-transcript.js +6 -0
  17. package/node_modules/@noodle-borg/assistant-gateway/dist/elevation.d.ts +7 -0
  18. package/node_modules/@noodle-borg/assistant-gateway/dist/elevation.js +1 -0
  19. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +2 -0
  20. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +2 -0
  21. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.d.ts +21 -0
  22. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.js +39 -0
  23. package/node_modules/@noodle-borg/assistant-gateway/dist/session-resume.d.ts +7 -0
  24. package/node_modules/@noodle-borg/assistant-gateway/dist/session-resume.js +12 -0
  25. package/node_modules/@noodle-borg/auth/dist/control-plane-token-exchange.d.ts +110 -0
  26. package/node_modules/@noodle-borg/auth/dist/control-plane-token-exchange.js +217 -0
  27. package/node_modules/@noodle-borg/auth/dist/index.d.ts +1 -0
  28. package/node_modules/@noodle-borg/auth/dist/index.js +1 -0
  29. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +5 -0
  30. package/node_modules/@noodle-borg/compiler/dist/index.d.ts +1 -2
  31. package/node_modules/@noodle-borg/compiler/dist/index.js +1 -2
  32. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +16 -0
  33. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +19 -2
  34. package/node_modules/@noodle-borg/compiler/package.json +0 -2
  35. package/node_modules/@noodle-borg/compute/dist/code-connector.js +44 -1
  36. package/node_modules/@noodle-borg/compute/dist/quickjs-engine.js +82 -266
  37. package/node_modules/@noodle-borg/compute/dist/sandbox-eval.js +276 -0
  38. package/node_modules/@noodle-borg/compute/dist/worker-pool.js +262 -0
  39. package/node_modules/@noodle-borg/{service/dist/auth/deploy-gate.d.ts → control-plane/dist/deploy-gates.d.ts} +10 -6
  40. package/node_modules/@noodle-borg/control-plane/dist/deploy-gates.js +266 -0
  41. package/node_modules/@noodle-borg/control-plane/dist/portable.d.ts +1 -0
  42. package/node_modules/@noodle-borg/control-plane/dist/portable.js +1 -0
  43. package/node_modules/@noodle-borg/developer-mcp/dist/capabilities.js +11 -1
  44. package/node_modules/@noodle-borg/developer-mcp/dist/index.js +1 -1
  45. package/node_modules/@noodle-borg/module/dist/contract.d.ts +0 -64
  46. package/node_modules/@noodle-borg/module/dist/contract.js +3 -2
  47. package/node_modules/@noodle-borg/module/dist/index.d.ts +1 -0
  48. package/node_modules/@noodle-borg/module/dist/index.js +1 -0
  49. package/node_modules/@noodle-borg/module/dist/intent.d.ts +1 -1
  50. package/node_modules/@noodle-borg/module/dist/request-analytics.d.ts +80 -0
  51. package/node_modules/@noodle-borg/module/dist/request-analytics.js +10 -0
  52. package/node_modules/@noodle-borg/observability/dist/intent-capture.js +4 -0
  53. package/node_modules/@noodle-borg/observability/dist/request-event-buffer.js +4 -0
  54. package/node_modules/@noodle-borg/observability/dist/request-event-metrics.js +15 -0
  55. package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +19 -3
  56. package/node_modules/@noodle-borg/observability/dist/request-events.js +3 -0
  57. package/node_modules/@noodle-borg/observability/dist/runtime.js +61 -1
  58. package/node_modules/@noodle-borg/protocol/dist/execution-error-mapping.js +13 -1
  59. package/node_modules/@noodle-borg/protocol/dist/handlers/prompts.js +2 -2
  60. package/node_modules/@noodle-borg/protocol/dist/handlers/resources.js +2 -2
  61. package/node_modules/@noodle-borg/protocol/dist/handlers/tools.js +3 -3
  62. package/node_modules/@noodle-borg/protocol/dist/observation.d.ts +18 -0
  63. package/node_modules/@noodle-borg/protocol/dist/observation.js +15 -0
  64. package/node_modules/@noodle-borg/protocol/dist/v2/server.js +5 -5
  65. package/node_modules/@noodle-borg/runtime/dist/connector/failure-details.d.ts +1 -1
  66. package/node_modules/@noodle-borg/runtime/dist/connector/failure-details.js +5 -0
  67. package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +15 -1
  68. package/node_modules/@noodle-borg/runtime/dist/connector/types.js +6 -0
  69. package/node_modules/@noodle-borg/runtime/dist/index.d.ts +1 -1
  70. package/node_modules/@noodle-borg/runtime/dist/operation-execution.d.ts +1 -1
  71. package/node_modules/@noodle-borg/runtime/dist/operation-execution.js +35 -6
  72. package/node_modules/@noodle-borg/runtime/dist/result.d.ts +18 -0
  73. package/node_modules/@noodle-borg/service/dist/auth/developer-grant-guard.js +2 -1
  74. package/node_modules/@noodle-borg/service/dist/modules/route-dispatch.js +1 -1
  75. package/node_modules/@noodle-borg/service/dist/registry-types.d.ts +1 -1
  76. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +15 -3
  77. package/node_modules/@noodle-borg/service/dist/routes/assistant-clients.js +116 -0
  78. package/node_modules/@noodle-borg/service/dist/routes/assistant-dispatch.js +12 -3
  79. package/node_modules/@noodle-borg/service/dist/routes/assistant-elevation.js +1 -0
  80. package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-stream.js +4 -1
  81. package/node_modules/@noodle-borg/service/dist/routes/assistant-interactions.js +8 -1
  82. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +3 -9
  83. package/node_modules/@noodle-borg/service/dist/routes/assistant-route-http.js +11 -0
  84. package/node_modules/@noodle-borg/service/dist/routes/assistant-sandbox.js +12 -47
  85. package/node_modules/@noodle-borg/service/dist/routes/assistant-session-target.js +34 -8
  86. package/node_modules/@noodle-borg/service/dist/routes/assistant-transcript.js +27 -0
  87. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +28 -130
  88. package/node_modules/@noodle-borg/service/dist/routes/config-values.js +1 -1
  89. package/node_modules/@noodle-borg/service/dist/routes/control-plane.js +8 -2
  90. package/node_modules/@noodle-borg/service/dist/service-resource-cleanup.js +1 -6
  91. package/node_modules/@noodle-borg/service/dist/service.js +15 -1
  92. package/node_modules/@noodle-borg/transport-http/dist/client-identity.js +124 -0
  93. package/node_modules/@noodle-borg/transport-http/dist/identity-authorization.js +8 -8
  94. package/node_modules/@noodle-borg/transport-http/dist/request-capture.js +97 -10
  95. package/node_modules/@noodle-borg/transport-http/dist/serve-request.js +40 -10
  96. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.d.ts +17 -0
  97. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.js +25 -0
  98. package/node_modules/@noodleseed/assistant/README.md +4 -9
  99. package/package.json +1 -1
  100. /package/node_modules/@noodle-borg/{compiler → app-package}/dist/schema-validation.js +0 -0
@@ -1,5 +1,6 @@
1
- import { getQuickJS, newAsyncContext, shouldInterruptAfterDeadline, } from 'quickjs-emscripten';
2
1
  import { ComputeError, digestSource, } from './engine.js';
2
+ import { hostErrorEnvelope } from './sandbox-eval.js';
3
+ import { ComputeWorkerPool, sharedComputeWorkerPool, } from './worker-pool.js';
3
4
  /**
4
5
  * The first {@link ComputeEngine} backend: a QuickJS interpreter compiled to WebAssembly
5
6
  * (`quickjs-emscripten`). It runs tenant-authored JavaScript with **no ambient authority** — a fresh
@@ -7,6 +8,11 @@ import { ComputeError, digestSource, } from './engine.js';
7
8
  * `require`, or other host bindings), nondeterministic globals removed (`Date`, `Math.random`), and hard,
8
9
  * fail-closed bounds (wall-clock timeout, memory cap, output-size cap).
9
10
  *
11
+ * Execution happens on a bounded {@link ComputeWorkerPool} of worker threads rather than the main
12
+ * event loop, so one slow invocation cannot serialize independent invocations behind it or starve
13
+ * unrelated requests on the instance (issue #1307). The `timeoutMs` budget covers queue wait plus
14
+ * execution, and both durations are reported independently through the invoke `onTimings` callback.
15
+ *
10
16
  * The unit of code is **a JavaScript expression that evaluates to a function** `fn(input) -> output`
11
17
  * (e.g. `(input) => { ... }`). Input is JSON-marshaled in, output JSON-marshaled out — the boundary never
12
18
  * shares object references. When a host is provided, `callOperation` is the only injected capability and
@@ -19,7 +25,23 @@ import { ComputeError, digestSource, } from './engine.js';
19
25
  export class QuickJsComputeEngine {
20
26
  kind = 'interpreter';
21
27
  #sources = new Map();
22
- #wasm;
28
+ #pool;
29
+ #ownsPool;
30
+ /**
31
+ * With no options the engine shares the process-wide worker pool, so many per-deployment engines
32
+ * stay within one bounded worker set. Passing options creates a private pool owned (and closed)
33
+ * by this engine.
34
+ */
35
+ constructor(options) {
36
+ if (options === undefined) {
37
+ this.#pool = sharedComputeWorkerPool();
38
+ this.#ownsPool = false;
39
+ }
40
+ else {
41
+ this.#pool = new ComputeWorkerPool(options);
42
+ this.#ownsPool = true;
43
+ }
44
+ }
23
45
  /** Hash and retain the source. (An AOT backend would emit and store a WASM module here instead.) */
24
46
  async compile(source) {
25
47
  return this.registerSource(source);
@@ -39,289 +61,83 @@ export class QuickJsComputeEngine {
39
61
  if (source === undefined) {
40
62
  throw new ComputeError('unknown_module', `no source registered for module ${module.digest}`);
41
63
  }
42
- this.#wasm ??= await getQuickJS();
43
- return new QuickJsComputeInstance(this.#wasm, source);
64
+ return new QuickJsComputeInstance(this.#pool, source);
65
+ }
66
+ /** Terminate a private pool's workers; a shared-pool engine has nothing to close. */
67
+ async close() {
68
+ if (this.#ownsPool)
69
+ await this.#pool.close();
44
70
  }
45
71
  }
46
- /** A runnable instance: each {@link invoke} spins a fresh, disposable QuickJS runtime for isolation. */
72
+ /** A runnable instance: each {@link invoke} runs on a fresh QuickJS runtime in a pool worker. */
47
73
  class QuickJsComputeInstance {
48
- #wasm;
74
+ #pool;
49
75
  #source;
50
- constructor(wasm, source) {
51
- this.#wasm = wasm;
76
+ constructor(pool, source) {
77
+ this.#pool = pool;
52
78
  this.#source = source;
53
79
  }
54
- async invoke(input, limits, host) {
55
- if (host)
56
- return this.invokeWithHost(input, limits, host);
57
- const runtime = this.#wasm.newRuntime();
58
- runtime.setMemoryLimit(limits.memoryBytes);
59
- runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + limits.timeoutMs));
60
- const context = runtime.newContext();
80
+ invoke(input, limits, host, onTimings) {
81
+ // Serialize before submission: only JSON strings cross the thread boundary, and a hostile
82
+ // input must fail as a typed error here — never strand a worker slot or crash the queue pump.
83
+ // JSON.stringify returns undefined (not a string) for a top-level function or symbol.
84
+ let inputJson;
61
85
  try {
62
- const wrapper = buildWrapper(this.#source, input);
63
- const result = context.evalCode(wrapper, 'module.js');
64
- if (result.error) {
65
- const described = describeError(context, result.error);
66
- result.error.dispose();
67
- throw classifyError(described);
68
- }
69
- const out = readResult(context, result.value, limits.maxOutputBytes);
70
- result.value.dispose();
71
- return out;
72
- }
73
- finally {
74
- context.dispose();
75
- runtime.dispose();
86
+ inputJson = JSON.stringify(input ?? null);
76
87
  }
77
- }
78
- dispose() {
79
- // No retained per-instance resources; runtimes are created and disposed per invoke.
80
- }
81
- async invokeWithHost(input, limits, host) {
82
- const context = await newAsyncContext();
83
- const runtime = context.runtime;
84
- runtime.setMemoryLimit(limits.memoryBytes);
85
- runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + limits.timeoutMs));
86
- try {
87
- installHostCall(context, host, limits);
88
- if (host.log !== undefined)
89
- installConsole(context, host);
90
- const result = await context.evalCodeAsync(buildWrapper(this.#source, input, true, host.log !== undefined), 'module.js');
91
- if (result.error) {
92
- const described = describeError(context, result.error);
93
- result.error.dispose();
94
- throw classifyError(described);
95
- }
96
- const pendingResult = context.resolvePromise(result.value);
97
- await drainPendingJobs(runtime, context);
98
- const resolved = await pendingResult;
99
- result.value.dispose();
100
- if (resolved.error) {
101
- const described = describeError(context, resolved.error);
102
- resolved.error.dispose();
103
- throw classifyError(described);
104
- }
105
- const out = readResult(context, resolved.value, limits.maxOutputBytes);
106
- resolved.value.dispose();
107
- return out;
88
+ catch {
89
+ return Promise.reject(new ComputeError('runtime_error', 'compute input is not JSON-serializable'));
108
90
  }
109
- finally {
110
- context.setProp(context.global, '__hostCallOperation', context.undefined);
111
- context.setProp(context.global, '__hostLog', context.undefined);
112
- context.setProp(context.global, 'console', context.undefined);
113
- context.dispose();
114
- runtime.dispose();
91
+ if (typeof inputJson !== 'string') {
92
+ return Promise.reject(new ComputeError('runtime_error', 'compute input is not JSON-serializable'));
115
93
  }
94
+ return this.#pool.execute({
95
+ source: this.#source,
96
+ inputJson,
97
+ limits,
98
+ ...(host === undefined ? {} : { host: envelopeBridge(host) }),
99
+ consoleEnabled: host?.log !== undefined,
100
+ ...(onTimings === undefined ? {} : { onTimings }),
101
+ });
116
102
  }
117
- }
118
- async function drainPendingJobs(runtime, context) {
119
- while (runtime.hasPendingJob()) {
120
- const jobs = await runtime.executePendingJobs();
121
- if (jobs.error) {
122
- const described = describeError(context, jobs.error);
123
- jobs.error.dispose();
124
- throw classifyError(described);
125
- }
103
+ dispose() {
104
+ // No retained per-instance resources; runtimes live and die inside the pool workers.
126
105
  }
127
106
  }
128
107
  /**
129
- * Build the script run inside the sandbox. The handler source is wrapped as an expression, called with
130
- * the JSON-marshaled input, and its result re-serialized to a string. A prelude removes nondeterministic
131
- * globals so "deterministic by default" holds for the future resume/idempotency model
132
- * ([ADR 0004](../../../docs/decisions/0004-code-as-sandboxed-connector.md)).
108
+ * Adapt a {@link ComputeHost} to the pool's JSON-string bridge: parse the sandbox's argument JSON,
109
+ * run the host capability, and serialize the result (or a bounded, truncated failure) as the
110
+ * envelope the sandbox prelude understands. Host state never crosses in object form.
133
111
  */
134
- function buildWrapper(source, input, hostEnabled = false, consoleEnabled = false) {
135
- const callOperationPrelude = hostEnabled
136
- ? `
137
- var callOperation = function(name, args) {
138
- var __hostEnvelope = JSON.parse(globalThis.__hostCallOperation(String(name), JSON.stringify(args ?? {})));
139
- if (!__hostEnvelope.ok) {
140
- var __err = new Error(__hostEnvelope.error.message || "host call failed");
141
- __err.name = __hostEnvelope.error.code || "host_call_failed";
142
- __err.code = __hostEnvelope.error.code;
143
- __err.path = __hostEnvelope.error.path;
144
- throw __err;
145
- }
146
- return __hostEnvelope.output;
147
- };
148
- `
149
- : '';
150
- const handlerArgs = hostEnabled
151
- ? `${jsLiteral(input ?? null)}, { callOperation: callOperation }`
152
- : jsLiteral(input ?? null);
153
- const consolePrelude = consoleEnabled
154
- ? `
155
- var console = Object.freeze({
156
- debug: function() { return globalThis.__hostLog("debug", JSON.stringify(Array.prototype.slice.call(arguments))); },
157
- info: function() { return globalThis.__hostLog("info", JSON.stringify(Array.prototype.slice.call(arguments))); },
158
- log: function() { return globalThis.__hostLog("info", JSON.stringify(Array.prototype.slice.call(arguments))); },
159
- warn: function() { return globalThis.__hostLog("warn", JSON.stringify(Array.prototype.slice.call(arguments))); },
160
- error: function() { return globalThis.__hostLog("error", JSON.stringify(Array.prototype.slice.call(arguments))); }
161
- });
162
- `
163
- : '';
164
- const bodyPrefix = `"use strict";
165
- delete globalThis.Date;
166
- delete Math.random;
167
- ${callOperationPrelude}
168
- ${consolePrelude}
169
- var __handler = (${source});
170
- if (typeof __handler !== "function") { throw new TypeError("compute module must evaluate to a function"); }`;
171
- if (hostEnabled) {
172
- return `${bodyPrefix}
173
- Promise.resolve(__handler(${handlerArgs})).then(function(__value) {
174
- var __out = JSON.stringify(__value);
175
- return (typeof __out === "string") ? __out : "null";
176
- });`;
177
- }
178
- return `${bodyPrefix}
179
- var __out = JSON.stringify(__handler(${handlerArgs}));
180
- (typeof __out === "string") ? __out : "null";`;
181
- }
182
- function installHostCall(context, host, limits) {
183
- let calls = 0;
184
- const fn = context.newAsyncifiedFunction('__hostCallOperation', async (nameHandle, argsJsonHandle) => {
185
- const envelope = async () => {
186
- if (calls >= limits.maxHostCalls) {
187
- return {
188
- ok: false,
189
- error: {
190
- code: 'host_call_denied',
191
- message: 'host call budget exceeded',
192
- },
193
- };
194
- }
195
- calls += 1;
196
- const name = context.getString(nameHandle);
197
- let args;
112
+ function envelopeBridge(host) {
113
+ return {
114
+ async callOperation(name, argsJson) {
115
+ const envelope = await (async () => {
116
+ try {
117
+ const args = JSON.parse(argsJson);
118
+ return { ok: true, output: await host.callOperation(name, args) };
119
+ }
120
+ catch (error) {
121
+ return hostErrorEnvelope(error);
122
+ }
123
+ })();
198
124
  try {
199
- args = JSON.parse(context.getString(argsJsonHandle));
125
+ return JSON.stringify(envelope);
200
126
  }
201
127
  catch {
202
- return {
203
- ok: false,
204
- error: { code: 'host_call_denied', message: 'host call arguments must be JSON' },
205
- };
206
- }
207
- if (args === null || typeof args !== 'object' || Array.isArray(args)) {
208
- return {
128
+ return JSON.stringify({
209
129
  ok: false,
210
- error: { code: 'host_call_denied', message: 'host call arguments must be an object' },
211
- };
130
+ error: { code: 'host_call_failed', message: 'host call output is not serializable' },
131
+ });
212
132
  }
213
- try {
214
- return {
215
- ok: true,
216
- output: await host.callOperation(name, args),
217
- };
218
- }
219
- catch (error) {
220
- const err = error;
221
- return {
222
- ok: false,
223
- error: {
224
- code: typeof err.code === 'string' ? err.code : 'host_call_failed',
225
- message: typeof err.message === 'string' && err.message !== ''
226
- ? err.message.slice(0, 256)
227
- : 'host call failed',
228
- ...(typeof err.path === 'string' ? { path: err.path } : {}),
229
- },
230
- };
231
- }
232
- };
233
- return context.newString(JSON.stringify(await envelope()));
234
- });
235
- context.setProp(context.global, '__hostCallOperation', fn);
236
- fn.dispose();
237
- }
238
- function installConsole(context, host) {
239
- const fn = context.newAsyncifiedFunction('__hostLog', async (levelHandle, argsJsonHandle) => {
240
- const level = normalizeLogLevel(context.getString(levelHandle));
241
- const args = parseLogArgs(context.getString(argsJsonHandle));
242
- const entry = formatLogEntry(level, args);
243
- await host.log?.(entry);
244
- return context.undefined;
245
- });
246
- context.setProp(context.global, '__hostLog', fn);
247
- fn.dispose();
248
- }
249
- function normalizeLogLevel(level) {
250
- return level === 'debug' || level === 'warn' || level === 'error' ? level : 'info';
251
- }
252
- function parseLogArgs(json) {
253
- try {
254
- const parsed = JSON.parse(json);
255
- return Array.isArray(parsed) ? parsed : [];
256
- }
257
- catch {
258
- return [];
259
- }
260
- }
261
- const MAX_LOG_MESSAGE_CHARS = 1024;
262
- function formatLogEntry(level, args) {
263
- const message = args.map(formatLogArg).join(' ');
264
- if (message.length <= MAX_LOG_MESSAGE_CHARS)
265
- return { level, message };
266
- return { level, message: message.slice(0, MAX_LOG_MESSAGE_CHARS), truncated: true };
267
- }
268
- function formatLogArg(value) {
269
- if (value === null || value === undefined)
270
- return '';
271
- const type = typeof value;
272
- if (type === 'string')
273
- return value;
274
- if (type === 'number' || type === 'boolean')
275
- return String(value);
276
- return '[unloggable]';
277
- }
278
- /** Read the string completion value, enforcing the output-size cap, and parse it as JSON. */
279
- function readResult(context, value, maxOutputBytes) {
280
- const text = context.getString(value);
281
- if (Buffer.byteLength(text, 'utf8') > maxOutputBytes) {
282
- throw new ComputeError('output_too_large', 'compute output exceeds the size limit');
283
- }
284
- try {
285
- return JSON.parse(text);
286
- }
287
- catch {
288
- throw new ComputeError('invalid_output', 'compute output is not valid JSON');
289
- }
290
- }
291
- /** Extract a bounded `{ name, message }` from a sandbox error handle without leaking host state. */
292
- function describeError(context, handle) {
293
- let dumped;
294
- try {
295
- dumped = context.dump(handle);
296
- }
297
- catch {
298
- return { name: 'Error', message: 'sandbox error' };
299
- }
300
- if (dumped !== null && typeof dumped === 'object') {
301
- const o = dumped;
302
- const name = typeof o.name === 'string' ? o.name : 'Error';
303
- const message = typeof o.message === 'string' ? o.message : '';
304
- return { name, message };
305
- }
306
- return { name: 'Error', message: String(dumped) };
307
- }
308
- /** Map a sandbox error to a stable {@link ComputeError} code; truncate the relayed message. */
309
- function classifyError(described) {
310
- const lower = described.message.toLowerCase();
311
- if (lower.includes('interrupted')) {
312
- return new ComputeError('timeout', 'compute exceeded its time budget');
313
- }
314
- if (lower.includes('out of memory')) {
315
- return new ComputeError('memory', 'compute exceeded its memory limit');
316
- }
317
- if (described.name === 'host_call_denied' || described.name === 'host_call_failed') {
318
- return new ComputeError(described.name, described.message.slice(0, 256) || 'host call failed');
319
- }
320
- const detail = `${described.name}: ${described.message}`.slice(0, 256);
321
- return new ComputeError('runtime_error', detail);
322
- }
323
- /** Embed a JSON-safe value as a JavaScript literal, escaping the line/paragraph separators. */
324
- function jsLiteral(value) {
325
- return JSON.stringify(value).replace(/[\u2028\u2029]/g, (c) => c === '\u2028' ? '\\u2028' : '\\u2029');
133
+ },
134
+ ...(host.log === undefined
135
+ ? {}
136
+ : {
137
+ log: async (entry) => {
138
+ await host.log?.(entry);
139
+ },
140
+ }),
141
+ };
326
142
  }
327
143
  //# sourceMappingURL=quickjs-engine.js.map
@@ -0,0 +1,276 @@
1
+ import { shouldInterruptAfterDeadline } from 'quickjs-emscripten';
2
+ import { ComputeError, } from './engine.js';
3
+ /**
4
+ * Normalize a host-call failure into a bounded error envelope. The code/path pass through only as
5
+ * strings and the message is truncated, so backend internals never re-enter sandbox code.
6
+ */
7
+ export function hostErrorEnvelope(error) {
8
+ const err = error;
9
+ return {
10
+ ok: false,
11
+ error: {
12
+ code: typeof err.code === 'string' ? err.code : 'host_call_failed',
13
+ message: typeof err.message === 'string' && err.message !== ''
14
+ ? err.message.slice(0, 256)
15
+ : 'host call failed',
16
+ ...(typeof err.path === 'string' ? { path: err.path } : {}),
17
+ },
18
+ };
19
+ }
20
+ /** Run a pure (no-host) invocation synchronously on a fresh, disposable QuickJS runtime. */
21
+ export function runPure(wasm, source, input, limits) {
22
+ const runtime = wasm.newRuntime();
23
+ runtime.setMemoryLimit(limits.memoryBytes);
24
+ runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + limits.timeoutMs));
25
+ const context = runtime.newContext();
26
+ try {
27
+ const wrapper = buildWrapper(source, input);
28
+ const result = context.evalCode(wrapper, 'module.js');
29
+ if (result.error) {
30
+ const described = describeError(context, result.error);
31
+ result.error.dispose();
32
+ throw classifyError(described);
33
+ }
34
+ const out = readResultString(context, result.value, limits.maxOutputBytes);
35
+ result.value.dispose();
36
+ return out;
37
+ }
38
+ finally {
39
+ context.dispose();
40
+ runtime.dispose();
41
+ }
42
+ }
43
+ /** Run a host-mediated invocation on a fresh asyncified context from the worker's shared module. */
44
+ export async function runHosted(wasm, source, input, limits, bridge) {
45
+ const context = wasm.newContext();
46
+ const runtime = context.runtime;
47
+ runtime.setMemoryLimit(limits.memoryBytes);
48
+ runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + limits.timeoutMs));
49
+ try {
50
+ installHostCall(context, bridge, limits);
51
+ if (bridge.log !== undefined)
52
+ installConsole(context, bridge);
53
+ const result = await context.evalCodeAsync(buildWrapper(source, input, true, bridge.log !== undefined), 'module.js');
54
+ if (result.error) {
55
+ const described = describeError(context, result.error);
56
+ result.error.dispose();
57
+ throw classifyError(described);
58
+ }
59
+ const pendingResult = context.resolvePromise(result.value);
60
+ await drainPendingJobs(runtime, context);
61
+ const resolved = await pendingResult;
62
+ result.value.dispose();
63
+ if (resolved.error) {
64
+ const described = describeError(context, resolved.error);
65
+ resolved.error.dispose();
66
+ throw classifyError(described);
67
+ }
68
+ const out = readResultString(context, resolved.value, limits.maxOutputBytes);
69
+ resolved.value.dispose();
70
+ return out;
71
+ }
72
+ finally {
73
+ // The module-created context owns its runtime: disposing the context frees the runtime and
74
+ // every global, so no per-global cleanup or separate runtime.dispose() belongs here.
75
+ context.dispose();
76
+ }
77
+ }
78
+ async function drainPendingJobs(runtime, context) {
79
+ while (runtime.hasPendingJob()) {
80
+ const jobs = await runtime.executePendingJobs();
81
+ if (jobs.error) {
82
+ const described = describeError(context, jobs.error);
83
+ jobs.error.dispose();
84
+ throw classifyError(described);
85
+ }
86
+ }
87
+ }
88
+ /**
89
+ * Build the script run inside the sandbox. The handler source is wrapped as an expression, called
90
+ * with the JSON-marshaled input, and its result re-serialized to a string. A prelude removes
91
+ * nondeterministic globals so "deterministic by default" holds for the future resume/idempotency
92
+ * model ([ADR 0004](../../../docs/decisions/0004-code-as-sandboxed-connector.md)).
93
+ */
94
+ function buildWrapper(source, input, hostEnabled = false, consoleEnabled = false) {
95
+ const callOperationPrelude = hostEnabled
96
+ ? `
97
+ var callOperation = function(name, args) {
98
+ var __hostEnvelope = JSON.parse(globalThis.__hostCallOperation(String(name), JSON.stringify(args ?? {})));
99
+ if (!__hostEnvelope.ok) {
100
+ var __err = new Error(__hostEnvelope.error.message || "host call failed");
101
+ __err.name = __hostEnvelope.error.code || "host_call_failed";
102
+ __err.code = __hostEnvelope.error.code;
103
+ __err.path = __hostEnvelope.error.path;
104
+ throw __err;
105
+ }
106
+ return __hostEnvelope.output;
107
+ };
108
+ `
109
+ : '';
110
+ const handlerArgs = hostEnabled
111
+ ? `${jsLiteral(input ?? null)}, { callOperation: callOperation }`
112
+ : jsLiteral(input ?? null);
113
+ const consolePrelude = consoleEnabled
114
+ ? `
115
+ var console = Object.freeze({
116
+ debug: function() { return globalThis.__hostLog("debug", JSON.stringify(Array.prototype.slice.call(arguments))); },
117
+ info: function() { return globalThis.__hostLog("info", JSON.stringify(Array.prototype.slice.call(arguments))); },
118
+ log: function() { return globalThis.__hostLog("info", JSON.stringify(Array.prototype.slice.call(arguments))); },
119
+ warn: function() { return globalThis.__hostLog("warn", JSON.stringify(Array.prototype.slice.call(arguments))); },
120
+ error: function() { return globalThis.__hostLog("error", JSON.stringify(Array.prototype.slice.call(arguments))); }
121
+ });
122
+ `
123
+ : '';
124
+ const bodyPrefix = `"use strict";
125
+ delete globalThis.Date;
126
+ delete Math.random;
127
+ ${callOperationPrelude}
128
+ ${consolePrelude}
129
+ var __handler = (${source});
130
+ if (typeof __handler !== "function") { throw new TypeError("compute module must evaluate to a function"); }`;
131
+ if (hostEnabled) {
132
+ return `${bodyPrefix}
133
+ Promise.resolve(__handler(${handlerArgs})).then(function(__value) {
134
+ var __out = JSON.stringify(__value);
135
+ return (typeof __out === "string") ? __out : "null";
136
+ });`;
137
+ }
138
+ return `${bodyPrefix}
139
+ var __out = JSON.stringify(__handler(${handlerArgs}));
140
+ (typeof __out === "string") ? __out : "null";`;
141
+ }
142
+ function installHostCall(context, bridge, limits) {
143
+ let calls = 0;
144
+ const fn = context.newAsyncifiedFunction('__hostCallOperation', async (nameHandle, argsJsonHandle) => {
145
+ const envelopeJson = await (async () => {
146
+ if (calls >= limits.maxHostCalls) {
147
+ return JSON.stringify({
148
+ ok: false,
149
+ error: { code: 'host_call_denied', message: 'host call budget exceeded' },
150
+ });
151
+ }
152
+ calls += 1;
153
+ const name = context.getString(nameHandle);
154
+ const argsJson = context.getString(argsJsonHandle);
155
+ let args;
156
+ try {
157
+ args = JSON.parse(argsJson);
158
+ }
159
+ catch {
160
+ return JSON.stringify({
161
+ ok: false,
162
+ error: { code: 'host_call_denied', message: 'host call arguments must be JSON' },
163
+ });
164
+ }
165
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) {
166
+ return JSON.stringify({
167
+ ok: false,
168
+ error: { code: 'host_call_denied', message: 'host call arguments must be an object' },
169
+ });
170
+ }
171
+ try {
172
+ return await bridge.callOperation(name, argsJson);
173
+ }
174
+ catch {
175
+ return JSON.stringify({
176
+ ok: false,
177
+ error: { code: 'host_call_failed', message: 'host call failed' },
178
+ });
179
+ }
180
+ })();
181
+ return context.newString(envelopeJson);
182
+ });
183
+ context.setProp(context.global, '__hostCallOperation', fn);
184
+ fn.dispose();
185
+ }
186
+ function installConsole(context, bridge) {
187
+ const fn = context.newAsyncifiedFunction('__hostLog', async (levelHandle, argsJsonHandle) => {
188
+ const level = normalizeLogLevel(context.getString(levelHandle));
189
+ const args = parseLogArgs(context.getString(argsJsonHandle));
190
+ const entry = formatLogEntry(level, args);
191
+ await bridge.log?.(entry);
192
+ return context.undefined;
193
+ });
194
+ context.setProp(context.global, '__hostLog', fn);
195
+ fn.dispose();
196
+ }
197
+ function normalizeLogLevel(level) {
198
+ return level === 'debug' || level === 'warn' || level === 'error' ? level : 'info';
199
+ }
200
+ function parseLogArgs(json) {
201
+ try {
202
+ const parsed = JSON.parse(json);
203
+ return Array.isArray(parsed) ? parsed : [];
204
+ }
205
+ catch {
206
+ return [];
207
+ }
208
+ }
209
+ const MAX_LOG_MESSAGE_CHARS = 1024;
210
+ function formatLogEntry(level, args) {
211
+ const message = args.map(formatLogArg).join(' ');
212
+ if (message.length <= MAX_LOG_MESSAGE_CHARS)
213
+ return { level, message };
214
+ return { level, message: message.slice(0, MAX_LOG_MESSAGE_CHARS), truncated: true };
215
+ }
216
+ function formatLogArg(value) {
217
+ if (value === null || value === undefined)
218
+ return '';
219
+ const type = typeof value;
220
+ if (type === 'string')
221
+ return value;
222
+ if (type === 'number' || type === 'boolean')
223
+ return String(value);
224
+ return '[unloggable]';
225
+ }
226
+ /** Read the string completion value, enforcing the output-size cap and JSON validity. */
227
+ function readResultString(context, value, maxOutputBytes) {
228
+ const text = context.getString(value);
229
+ if (Buffer.byteLength(text, 'utf8') > maxOutputBytes) {
230
+ throw new ComputeError('output_too_large', 'compute output exceeds the size limit');
231
+ }
232
+ try {
233
+ JSON.parse(text);
234
+ }
235
+ catch {
236
+ throw new ComputeError('invalid_output', 'compute output is not valid JSON');
237
+ }
238
+ return text;
239
+ }
240
+ /** Extract a bounded `{ name, message }` from a sandbox error handle without leaking host state. */
241
+ function describeError(context, handle) {
242
+ let dumped;
243
+ try {
244
+ dumped = context.dump(handle);
245
+ }
246
+ catch {
247
+ return { name: 'Error', message: 'sandbox error' };
248
+ }
249
+ if (dumped !== null && typeof dumped === 'object') {
250
+ const o = dumped;
251
+ const name = typeof o.name === 'string' ? o.name : 'Error';
252
+ const message = typeof o.message === 'string' ? o.message : '';
253
+ return { name, message };
254
+ }
255
+ return { name: 'Error', message: String(dumped) };
256
+ }
257
+ /** Map a sandbox error to a stable {@link ComputeError} code; truncate the relayed message. */
258
+ function classifyError(described) {
259
+ const lower = described.message.toLowerCase();
260
+ if (lower.includes('interrupted')) {
261
+ return new ComputeError('timeout', 'compute exceeded its time budget');
262
+ }
263
+ if (lower.includes('out of memory')) {
264
+ return new ComputeError('memory', 'compute exceeded its memory limit');
265
+ }
266
+ if (described.name === 'host_call_denied' || described.name === 'host_call_failed') {
267
+ return new ComputeError(described.name, described.message.slice(0, 256) || 'host call failed');
268
+ }
269
+ const detail = `${described.name}: ${described.message}`.slice(0, 256);
270
+ return new ComputeError('runtime_error', detail);
271
+ }
272
+ /** Embed a JSON-safe value as a JavaScript literal, escaping the line/paragraph separators. */
273
+ function jsLiteral(value) {
274
+ return JSON.stringify(value).replace(/[\u2028\u2029]/g, (c) => c === '\u2028' ? '\\u2028' : '\\u2029');
275
+ }
276
+ //# sourceMappingURL=sandbox-eval.js.map