runline 0.10.0 → 0.11.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.
@@ -9,6 +9,28 @@ export interface EngineOptions {
9
9
  timeoutMs?: number;
10
10
  memoryLimitBytes?: number;
11
11
  }
12
+ /**
13
+ * Whether to arm the host-side RSS watchdog for a worker run.
14
+ *
15
+ * node enforces resourceLimits.maxOldGenerationSizeMb natively, and the
16
+ * watchdog measures *whole-process* RSS — arming it there would risk false
17
+ * kills from unrelated host allocations (e.g. a concurrent execute). bun
18
+ * ignores resourceLimits, so the watchdog is the only memory backstop.
19
+ */
20
+ export declare function shouldArmRssWatchdog(versions?: Partial<Record<string, string>>): boolean;
21
+ /**
22
+ * Executes agent code in a node:worker_threads worker.
23
+ *
24
+ * The worker is an ergonomic coding surface, not a security sandbox: agent
25
+ * code gets the full host JS runtime (Buffer, crypto, etc.) plus injected
26
+ * action proxies. Isolation properties we do enforce, fail-soft:
27
+ *
28
+ * - timeout: worker.terminate() — interrupts even `while(true){}`
29
+ * - memory: resourceLimits.maxOldGenerationSizeMb (node) + an RSS-delta
30
+ * watchdog fallback; both surface as a clean "Memory limit exceeded" error
31
+ * - crash containment: a dead worker never takes the host process down, and
32
+ * each execute() gets a fresh worker, so the engine stays usable
33
+ */
12
34
  export declare class ExecutionEngine {
13
35
  private registry;
14
36
  private config;
@@ -16,6 +38,4 @@ export declare class ExecutionEngine {
16
38
  execute(code: string, options?: EngineOptions): Promise<ExecuteResult>;
17
39
  private invokeAction;
18
40
  private resolveConnection;
19
- private drainAsync;
20
- private drainJobs;
21
41
  }
@@ -1,7 +1,34 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { getQuickJS, shouldInterruptAfterDeadline, } from "quickjs-emscripten";
2
+ import { Worker } from "node:worker_threads";
3
3
  import { applyEnvOverrides, updateConnectionConfig } from "../config/loader.js";
4
4
  import { formatValidationError, helpInputs, isTypedInputSchema, validateTypedInput, } from "../plugin/schema.js";
5
+ /**
6
+ * Whether to arm the host-side RSS watchdog for a worker run.
7
+ *
8
+ * node enforces resourceLimits.maxOldGenerationSizeMb natively, and the
9
+ * watchdog measures *whole-process* RSS — arming it there would risk false
10
+ * kills from unrelated host allocations (e.g. a concurrent execute). bun
11
+ * ignores resourceLimits, so the watchdog is the only memory backstop.
12
+ */
13
+ export function shouldArmRssWatchdog(versions = process.versions) {
14
+ return Boolean(versions.bun);
15
+ }
16
+ // Extra slack on top of the configured memory limit for the RSS watchdog,
17
+ // since whole-process RSS includes the host's own working set.
18
+ const RSS_WATCHDOG_SLACK_BYTES = 128 * 1024 * 1024;
19
+ /**
20
+ * Executes agent code in a node:worker_threads worker.
21
+ *
22
+ * The worker is an ergonomic coding surface, not a security sandbox: agent
23
+ * code gets the full host JS runtime (Buffer, crypto, etc.) plus injected
24
+ * action proxies. Isolation properties we do enforce, fail-soft:
25
+ *
26
+ * - timeout: worker.terminate() — interrupts even `while(true){}`
27
+ * - memory: resourceLimits.maxOldGenerationSizeMb (node) + an RSS-delta
28
+ * watchdog fallback; both surface as a clean "Memory limit exceeded" error
29
+ * - crash containment: a dead worker never takes the host process down, and
30
+ * each execute() gets a fresh worker, so the engine stays usable
31
+ */
5
32
  export class ExecutionEngine {
6
33
  registry;
7
34
  config;
@@ -12,119 +39,121 @@ export class ExecutionEngine {
12
39
  async execute(code, options) {
13
40
  const timeoutMs = options?.timeoutMs ?? this.config.timeoutMs;
14
41
  const memoryLimitBytes = options?.memoryLimitBytes ?? this.config.memoryLimitBytes;
15
- const deadlineMs = Date.now() + timeoutMs;
16
42
  const logs = [];
17
- const pendingDeferreds = new Set();
18
- const QuickJS = await getQuickJS();
19
- const runtime = QuickJS.newRuntime();
20
- try {
21
- runtime.setMemoryLimit(memoryLimitBytes);
22
- runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadlineMs));
23
- const context = runtime.newContext();
43
+ const plugins = this.registry.listPlugins();
44
+ const source = buildWorkerSource(code, plugins.map((p) => p.name), buildHelpData(plugins));
45
+ return new Promise((resolve) => {
46
+ const memoryLimitMb = Math.max(8, Math.floor(memoryLimitBytes / (1024 * 1024)));
47
+ let worker;
24
48
  try {
25
- // Inject log bridge
26
- const logBridge = context.newFunction("__runline_log", (levelHandle, lineHandle) => {
27
- const level = context.getString(levelHandle);
28
- const line = context.getString(lineHandle);
29
- logs.push(`[${level}] ${line}`);
30
- return context.undefined;
49
+ worker = new Worker(source, {
50
+ eval: true,
51
+ resourceLimits: { maxOldGenerationSizeMb: memoryLimitMb },
31
52
  });
32
- context.setProp(context.global, "__runline_log", logBridge);
33
- logBridge.dispose();
34
- // Inject action bridge
35
- const actionBridge = context.newFunction("__runline_invoke", (pathHandle, argsHandle) => {
36
- const path = context.getString(pathHandle);
37
- const args = argsHandle === undefined ||
38
- context.typeof(argsHandle) === "undefined"
39
- ? undefined
40
- : context.dump(argsHandle);
41
- const deferred = context.newPromise();
42
- pendingDeferreds.add(deferred);
43
- deferred.settled.finally(() => pendingDeferreds.delete(deferred));
44
- this.invokeAction(path, args).then((value) => {
45
- if (!deferred.alive)
46
- return;
47
- if (value === undefined) {
48
- deferred.resolve();
49
- return;
50
- }
51
- const serialized = JSON.stringify(value);
52
- const handle = context.newString(serialized);
53
- deferred.resolve(handle);
54
- handle.dispose();
55
- }, (err) => {
56
- if (!deferred.alive)
57
- return;
58
- const msg = err instanceof Error ? err.message : String(err);
59
- const handle = context.newError(msg);
60
- deferred.reject(handle);
61
- handle.dispose();
62
- });
63
- return deferred.handle;
53
+ }
54
+ catch (err) {
55
+ resolve({ result: null, error: formatError(err), logs });
56
+ return;
57
+ }
58
+ let settled = false;
59
+ const finish = (r) => {
60
+ if (settled)
61
+ return;
62
+ settled = true;
63
+ clearTimeout(timeoutTimer);
64
+ clearInterval(rssTimer);
65
+ void worker.terminate();
66
+ resolve(r);
67
+ };
68
+ const timeoutTimer = setTimeout(() => {
69
+ finish({
70
+ result: null,
71
+ error: `Execution timed out after ${timeoutMs}ms`,
72
+ logs,
64
73
  });
65
- context.setProp(context.global, "__runline_invoke", actionBridge);
66
- actionBridge.dispose();
67
- const plugins = this.registry.listPlugins();
68
- const pluginNames = plugins.map((p) => p.name);
69
- const helpData = buildHelpData(plugins);
70
- const source = buildExecutionSource(code, pluginNames, helpData);
71
- const evaluated = context.evalCode(source, "runline-sandbox.js");
72
- if (evaluated.error) {
73
- const error = context.dump(evaluated.error);
74
- evaluated.error.dispose();
75
- return { result: null, error: formatError(error), logs };
76
- }
77
- // Set up promise tracking
78
- context.setProp(context.global, "__runline_result", evaluated.value);
79
- evaluated.value.dispose();
80
- const stateResult = context.evalCode(`(function(p){ var s = { v: void 0, e: void 0, settled: false };
81
- var fmtErr = function(e){ if (e && typeof e === 'object') { var m = typeof e.message === 'string' ? e.message : ''; var st = typeof e.stack === 'string' ? e.stack : ''; if (m && st) return st.indexOf(m) === -1 ? m + '\\n' + st : st; if (m) return m; if (st) return st; } return String(e); };
82
- p.then(function(v){ s.v = v; s.settled = true; }, function(e){ s.e = fmtErr(e); s.settled = true; }); return s; })(__runline_result)`);
83
- if (stateResult.error) {
84
- const error = context.dump(stateResult.error);
85
- stateResult.error.dispose();
86
- return { result: null, error: formatError(error), logs };
87
- }
88
- const stateHandle = stateResult.value;
89
- try {
90
- await this.drainAsync(context, runtime, pendingDeferreds, deadlineMs, timeoutMs);
91
- const settled = readProp(context, stateHandle, "settled") === true;
92
- if (!settled) {
93
- return {
74
+ }, timeoutMs);
75
+ let rssTimer;
76
+ if (shouldArmRssWatchdog()) {
77
+ const baselineRss = process.memoryUsage().rss;
78
+ rssTimer = setInterval(() => {
79
+ const delta = process.memoryUsage().rss - baselineRss;
80
+ if (delta > memoryLimitBytes + RSS_WATCHDOG_SLACK_BYTES) {
81
+ finish({
94
82
  result: null,
95
- error: `Execution timed out after ${timeoutMs}ms`,
83
+ error: `Memory limit exceeded (${memoryLimitMb}MB)`,
96
84
  logs,
97
- };
98
- }
99
- const error = readProp(context, stateHandle, "e");
100
- if (error !== undefined) {
101
- return { result: null, error: formatError(error), logs };
85
+ });
102
86
  }
103
- return { result: readProp(context, stateHandle, "v"), logs };
104
- }
105
- finally {
106
- stateHandle.dispose();
107
- }
87
+ }, 100);
88
+ rssTimer.unref?.();
108
89
  }
109
- finally {
110
- for (const d of pendingDeferreds) {
111
- if (d.alive)
112
- d.dispose();
90
+ // A reply can race the worker's death; losing it is fine — the run is
91
+ // over either way but it must never surface as an unhandled
92
+ // rejection in the host.
93
+ const reply = (message) => {
94
+ if (settled)
95
+ return;
96
+ try {
97
+ worker.postMessage(message);
98
+ }
99
+ catch {
100
+ // worker already gone
113
101
  }
114
- pendingDeferreds.clear();
115
- context.dispose();
116
- }
117
- }
118
- catch (err) {
119
- return {
120
- result: null,
121
- error: formatError(err),
122
- logs,
123
102
  };
124
- }
125
- finally {
126
- runtime.dispose();
127
- }
103
+ worker.on("message", (msg) => {
104
+ if (settled)
105
+ return;
106
+ if (msg.t === "log") {
107
+ logs.push(`[${msg.level}] ${msg.line}`);
108
+ }
109
+ else if (msg.t === "invoke") {
110
+ this.invokeAction(msg.path, msg.args).then((value) => {
111
+ let serialized;
112
+ try {
113
+ serialized = toPlainJson(value);
114
+ }
115
+ catch (err) {
116
+ reply({
117
+ t: "result",
118
+ id: msg.id,
119
+ ok: false,
120
+ error: `Action result not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`,
121
+ });
122
+ return;
123
+ }
124
+ reply({ t: "result", id: msg.id, ok: true, value: serialized });
125
+ }, (err) => {
126
+ reply({
127
+ t: "result",
128
+ id: msg.id,
129
+ ok: false,
130
+ error: err instanceof Error ? err.message : String(err),
131
+ });
132
+ });
133
+ }
134
+ else if (msg.t === "done") {
135
+ finish(msg.ok
136
+ ? { result: msg.result, logs }
137
+ : { result: null, error: msg.error ?? "Unknown error", logs });
138
+ }
139
+ });
140
+ worker.on("error", (err) => {
141
+ finish({
142
+ result: null,
143
+ error: err.code === "ERR_WORKER_OUT_OF_MEMORY"
144
+ ? `Memory limit exceeded (${memoryLimitMb}MB)`
145
+ : formatError(err),
146
+ logs,
147
+ });
148
+ });
149
+ worker.on("exit", (exitCode) => {
150
+ finish({
151
+ result: null,
152
+ error: `Worker exited unexpectedly (code ${exitCode})`,
153
+ logs,
154
+ });
155
+ });
156
+ });
128
157
  }
129
158
  async invokeAction(path, args) {
130
159
  const resolved = this.registry.resolveAction(path);
@@ -164,53 +193,17 @@ export class ExecutionEngine {
164
193
  };
165
194
  return applyEnvOverrides(base, plugin.connectionConfigSchema);
166
195
  }
167
- async drainAsync(context, runtime, pendingDeferreds, deadlineMs, timeoutMs) {
168
- this.drainJobs(context, runtime, deadlineMs, timeoutMs);
169
- while (pendingDeferreds.size > 0) {
170
- const remainingMs = deadlineMs - Date.now();
171
- if (remainingMs <= 0) {
172
- throw new Error(`Execution timed out after ${timeoutMs}ms`);
173
- }
174
- let timer;
175
- try {
176
- await Promise.race([
177
- Promise.race([...pendingDeferreds].map((d) => d.settled)),
178
- new Promise((_, reject) => {
179
- timer = setTimeout(() => reject(new Error(`Execution timed out after ${timeoutMs}ms`)), remainingMs);
180
- }),
181
- ]);
182
- }
183
- finally {
184
- if (timer)
185
- clearTimeout(timer);
186
- }
187
- this.drainJobs(context, runtime, deadlineMs, timeoutMs);
188
- }
189
- this.drainJobs(context, runtime, deadlineMs, timeoutMs);
190
- }
191
- drainJobs(context, runtime, deadlineMs, timeoutMs) {
192
- while (runtime.hasPendingJob()) {
193
- if (Date.now() >= deadlineMs) {
194
- throw new Error(`Execution timed out after ${timeoutMs}ms`);
195
- }
196
- const pending = runtime.executePendingJobs();
197
- if (pending.error) {
198
- const error = context.dump(pending.error);
199
- pending.error.dispose();
200
- throw error instanceof Error ? error : new Error(String(error));
201
- }
202
- }
203
- }
204
196
  }
205
197
  // ── Helpers ──────────────────────────────────────────────
206
- function readProp(context, handle, key) {
207
- const prop = context.getProp(handle, key);
208
- try {
209
- return context.dump(prop);
210
- }
211
- finally {
212
- prop.dispose();
213
- }
198
+ /**
199
+ * JSON round-trip to (a) guarantee structured-clone compatibility and
200
+ * (b) preserve the previous engine's value semantics, where every action
201
+ * result crossed a JSON boundary (Dates → ISO strings, no Maps, etc.).
202
+ */
203
+ function toPlainJson(value) {
204
+ if (value === undefined)
205
+ return undefined;
206
+ return JSON.parse(JSON.stringify(value));
214
207
  }
215
208
  function formatError(cause) {
216
209
  if (cause instanceof Error)
@@ -224,13 +217,13 @@ function formatError(cause) {
224
217
  return String(cause);
225
218
  }
226
219
  // MiniSearch UMD bundle, vendored at the package root and inlined into the
227
- // sandbox source. UMD attaches `MiniSearch` to globalThis in a non-CJS /
228
- // non-AMD env (QuickJS), so pasting the file is enough.
220
+ // worker source. Inside the worker it is evaluated in a local scope with a
221
+ // fresh `module`/`exports` pair so the UMD takes its CommonJS branch.
229
222
  //
230
223
  // `../../vendor/...` resolves identically from src/core/engine.ts (dev) and
231
224
  // dist/core/engine.js (published) because tsc preserves the `core/` subdir.
232
225
  // See vendor/README.md for the upgrade procedure.
233
- const __minisearchSource = readFileSync(new URL("../../vendor/minisearch.umd.js", import.meta.url), "utf8");
226
+ const minisearchSource = readFileSync(new URL("../../vendor/minisearch.umd.js", import.meta.url), "utf8");
234
227
  function buildHelpData(plugins) {
235
228
  const data = {};
236
229
  for (const p of plugins) {
@@ -242,7 +235,7 @@ function buildHelpData(plugins) {
242
235
  }
243
236
  return data;
244
237
  }
245
- function buildExecutionSource(code, pluginNames = [], helpData = {}) {
238
+ function buildWorkerSource(code, pluginNames = [], helpData = {}) {
246
239
  const trimmed = code.trim();
247
240
  const looksLikeArrow = (trimmed.startsWith("async") || trimmed.startsWith("(")) &&
248
241
  trimmed.includes("=>");
@@ -250,18 +243,68 @@ function buildExecutionSource(code, pluginNames = [], helpData = {}) {
250
243
  ? `const __fn = (${trimmed});\nif (typeof __fn !== 'function') throw new Error('Code must evaluate to a function');\nreturn await __fn();`
251
244
  : code;
252
245
  const wrapped = `"use strict";
253
- const __invoke = __runline_invoke;
254
- const __log = __runline_log;
255
- try { delete globalThis.__runline_invoke; } catch {}
256
- try { delete globalThis.__runline_log; } catch {}
246
+ const { parentPort: __port } = require("node:worker_threads");
247
+
248
+ // process.exit would be runtime-divergent here: node kills the worker
249
+ // synchronously, bun lets the completion message race the exit and can
250
+ // report a silent undefined success. Make it a regular, catchable error.
251
+ process.exit = (code) => {
252
+ throw new Error('process.exit(' + (code ?? 0) + ') is not available in the runline sandbox; return a value instead');
253
+ };
254
+
255
+ // ── host bridge ──
256
+ let __seq = 0;
257
+ const __pending = new Map();
258
+ __port.on("message", (m) => {
259
+ if (!m || m.t !== "result") return;
260
+ const p = __pending.get(m.id);
261
+ if (!p) return;
262
+ __pending.delete(m.id);
263
+ if (m.ok) p.resolve(m.value);
264
+ else p.reject(new Error(m.error));
265
+ });
266
+ const __invoke = (path, args) => new Promise((resolve, reject) => {
267
+ const id = ++__seq;
268
+ __pending.set(id, { resolve, reject });
269
+ try {
270
+ __port.postMessage({ t: "invoke", id, path, args });
271
+ } catch (e) {
272
+ __pending.delete(id);
273
+ reject(e);
274
+ }
275
+ });
276
+ const __log = (level, line) => {
277
+ try { __port.postMessage({ t: "log", level, line }); } catch {}
278
+ };
257
279
 
258
280
  const __fmt = (v) => {
259
281
  if (typeof v === 'string') return v;
260
282
  try { return JSON.stringify(v); } catch { return String(v); }
261
283
  };
262
284
 
263
- // Inlined MiniSearch UMD attaches MiniSearch to globalThis inside the sandbox.
264
- ${__minisearchSource}
285
+ const __fmtErr = (e) => {
286
+ if (e && typeof e === 'object') {
287
+ const m = typeof e.message === 'string' ? e.message : '';
288
+ const st = typeof e.stack === 'string' ? e.stack : '';
289
+ if (m && st) return st.indexOf(m) === -1 ? m + '\\n' + st : st;
290
+ if (m) return m;
291
+ if (st) return st;
292
+ }
293
+ return String(e);
294
+ };
295
+
296
+ // JSON round-trip mirrors the host's toPlainJson: keeps results
297
+ // structured-clone-safe and preserves JSON value semantics.
298
+ const __toJson = (v) => v === undefined ? undefined : JSON.parse(JSON.stringify(v));
299
+
300
+ // Inlined MiniSearch UMD, evaluated with a local module/exports so the UMD
301
+ // takes its CommonJS branch regardless of the worker's module scope.
302
+ const MiniSearch = (function () {
303
+ const module = { exports: {} };
304
+ const exports = module.exports;
305
+ ${minisearchSource}
306
+ return module.exports;
307
+ })();
265
308
 
266
309
  const __help = ${JSON.stringify(helpData)};
267
310
 
@@ -273,8 +316,7 @@ const __makeProxy = (path = []) => new Proxy(() => undefined, {
273
316
  apply(_t, _this, args) {
274
317
  const p = path.join('.');
275
318
  if (!p) throw new Error('Action path missing');
276
- return Promise.resolve(__invoke(p, args[0]))
277
- .then((raw) => raw === undefined ? undefined : JSON.parse(raw));
319
+ return __invoke(p, args[0]);
278
320
  },
279
321
  });
280
322
 
@@ -296,7 +338,7 @@ const __formatSignature = (plugin, entry) => {
296
338
  return plugin + '.' + entry.action + (fields ? '({ ' + fields + ' })' : '()');
297
339
  };
298
340
 
299
- // Build a MiniSearch index over every action path. Indexed at sandbox
341
+ // Build a MiniSearch index over every action path. Indexed at worker
300
342
  // startup, queried by actions.find().
301
343
  const __search = (() => {
302
344
  const docs = [];
@@ -416,6 +458,18 @@ const fetch = () => { throw new Error('fetch is disabled in runline sandbox'); }
416
458
 
417
459
  (async () => {
418
460
  ${body}
419
- })()`;
461
+ })().then(
462
+ (v) => {
463
+ try {
464
+ __port.postMessage({ t: "done", ok: true, result: __toJson(v) });
465
+ } catch (e) {
466
+ __port.postMessage({ t: "done", ok: false, error: __fmtErr(e) });
467
+ }
468
+ },
469
+ (e) => {
470
+ __port.postMessage({ t: "done", ok: false, error: __fmtErr(e) });
471
+ },
472
+ );
473
+ `;
420
474
  return wrapped;
421
475
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,236 @@
1
+ // Test harness for engine robustness scenarios (large payloads, OOM,
2
+ // timeouts, crashes). Runs one named scenario in this process and prints a
3
+ // JSON report to stdout. Tests spawn this file so that a hard process
4
+ // abort or exit kills the harness, not the test runner.
5
+ //
6
+ // Usage: bun run engine-harness.ts <scenario>
7
+ import { createHash } from "node:crypto";
8
+ import { DEFAULT_CONFIG } from "../../config/types.js";
9
+ import { ExecutionEngine } from "../../core/engine.js";
10
+ import { createPluginAPI } from "../../plugin/api.js";
11
+ import { PluginRegistry } from "../../plugin/registry.js";
12
+ const BLOB_MB = 8;
13
+ // ~8MB of raw bytes → ~10.7MB base64
14
+ const base64 = Buffer.alloc(BLOB_MB * 1024 * 1024, 0xab).toString("base64");
15
+ const base64Sha = createHash("sha256").update(base64).digest("hex");
16
+ // Captures what the host-side actions actually received, so tests can
17
+ // verify that data crossing the execution boundary arrives byte-identical.
18
+ const received = {};
19
+ function makeFilesPlugin() {
20
+ const { api, resolve } = createPluginAPI("test");
21
+ api.setName("files");
22
+ api.setVersion("0.0.0");
23
+ api.registerAction("getAttachment", {
24
+ description: "Returns a large base64 payload",
25
+ async execute() {
26
+ await new Promise((r) => setTimeout(r, 5));
27
+ return { filename: "msa.pdf", data: base64 };
28
+ },
29
+ });
30
+ api.registerAction("upload", {
31
+ description: "Accepts a large base64 payload",
32
+ async execute(input) {
33
+ await new Promise((r) => setTimeout(r, 5));
34
+ const { data } = input;
35
+ received.uploadSha =
36
+ typeof data === "string"
37
+ ? createHash("sha256").update(data).digest("hex")
38
+ : `not-a-string:${typeof data}`;
39
+ received.uploadBytes = typeof data === "string" ? data.length : -1;
40
+ return { id: "file_123", bytes: received.uploadBytes };
41
+ },
42
+ });
43
+ api.registerAction("send", {
44
+ description: "Accepts a large attachment",
45
+ async execute(input) {
46
+ await new Promise((r) => setTimeout(r, 5));
47
+ const { attachment } = input;
48
+ received.sendBytes =
49
+ typeof attachment === "string" ? attachment.length : -1;
50
+ return { messageId: "msg_456", bytes: received.sendBytes };
51
+ },
52
+ });
53
+ api.registerAction("append", {
54
+ description: "Small side effect",
55
+ async execute() {
56
+ await new Promise((r) => setTimeout(r, 5));
57
+ return { updatedRows: 1 };
58
+ },
59
+ });
60
+ api.registerAction("slow", {
61
+ description: "Sleeps for the given ms, then returns",
62
+ async execute(input) {
63
+ const { ms } = input;
64
+ await new Promise((r) => setTimeout(r, ms));
65
+ return { waited: ms };
66
+ },
67
+ });
68
+ api.registerAction("circular", {
69
+ description: "Returns a non-JSON-serializable (circular) value",
70
+ async execute() {
71
+ const obj = { name: "loop" };
72
+ obj.self = obj;
73
+ return obj;
74
+ },
75
+ });
76
+ return resolve();
77
+ }
78
+ function makeEngine(memoryMb = 64, timeoutMs = 30_000) {
79
+ const registry = new PluginRegistry();
80
+ registry.register(makeFilesPlugin());
81
+ return new ExecutionEngine(registry, {
82
+ ...DEFAULT_CONFIG,
83
+ timeoutMs,
84
+ memoryLimitBytes: memoryMb * 1024 * 1024,
85
+ });
86
+ }
87
+ const scenarios = {
88
+ // The agent's original failure: multi-step chain with a large payload,
89
+ // default 64MB memory limit. Must complete cleanly.
90
+ async "chain-default"() {
91
+ const engine = makeEngine(64);
92
+ const out = await engine.execute(`
93
+ const att = await files.getAttachment({ messageId: "m1" });
94
+ const up = await files.upload({ name: att.filename, data: att.data });
95
+ const sent = await files.send({ to: "x@y.z", attachment: att.data });
96
+ const row = await files.append({ values: [["Triple-A MSA", "May"]] });
97
+ return { up, sent, row };
98
+ `);
99
+ return { error: out.error ?? null, result: out.result, received };
100
+ },
101
+ // Integrity: the bytes the upload action receives must be byte-identical
102
+ // to what getAttachment produced.
103
+ async integrity() {
104
+ const engine = makeEngine(64);
105
+ const out = await engine.execute(`
106
+ const att = await files.getAttachment({ messageId: "m1" });
107
+ await files.upload({ name: att.filename, data: att.data });
108
+ return "done";
109
+ `);
110
+ return {
111
+ error: out.error ?? null,
112
+ expectedSha: base64Sha,
113
+ expectedBytes: base64.length,
114
+ received,
115
+ };
116
+ },
117
+ // Ergonomics: inside the sandbox a large payload is a plain string the
118
+ // agent can measure, slice, and pass around — no tokens, no proxies.
119
+ async "string-surface"() {
120
+ const engine = makeEngine(64);
121
+ const out = await engine.execute(`
122
+ const att = await files.getAttachment({ messageId: "m1" });
123
+ const d = att.data;
124
+ return { type: typeof d, bytes: d.length, head: d.slice(0, 8) };
125
+ `);
126
+ return {
127
+ error: out.error ?? null,
128
+ result: out.result,
129
+ expectedBytes: base64.length,
130
+ expectedHead: base64.slice(0, 8),
131
+ };
132
+ },
133
+ // A large value in the final return reaches the host caller intact.
134
+ async "final-result-large"() {
135
+ const engine = makeEngine(64);
136
+ const out = await engine.execute(`
137
+ const att = await files.getAttachment({ messageId: "m1" });
138
+ return { data: att.data };
139
+ `);
140
+ const data = out.result?.data;
141
+ return {
142
+ error: out.error ?? null,
143
+ resultBytes: typeof data === "string" ? data.length : -1,
144
+ resultSha: typeof data === "string"
145
+ ? createHash("sha256").update(data).digest("hex")
146
+ : null,
147
+ expectedSha: base64Sha,
148
+ expectedBytes: base64.length,
149
+ };
150
+ },
151
+ // An action still in flight when the run times out must not crash the
152
+ // host when its result arrives after the worker is gone — no unhandled
153
+ // rejections, no uncaught exceptions, clean timeout error.
154
+ async "timeout-inflight-action"() {
155
+ const unhandled = [];
156
+ process.on("unhandledRejection", (e) => unhandled.push(String(e)));
157
+ process.on("uncaughtException", (e) => unhandled.push(String(e)));
158
+ const engine = makeEngine(64, 150);
159
+ const out = await engine.execute(`
160
+ await files.slow({ ms: 1000 });
161
+ return "unreachable";
162
+ `);
163
+ // let the in-flight action resolve against the dead worker
164
+ await new Promise((r) => setTimeout(r, 1200));
165
+ return { error: out.error ?? null, unhandled };
166
+ },
167
+ // An action returning a non-JSON-serializable value must surface as a
168
+ // clean per-call error inside the sandbox — catchable by agent code —
169
+ // not a hang or a crash.
170
+ async "circular-result"() {
171
+ const engine = makeEngine(64);
172
+ const out = await engine.execute(`
173
+ try {
174
+ await files.circular({});
175
+ return { caught: false };
176
+ } catch (e) {
177
+ return { caught: true, message: e.message };
178
+ }
179
+ `);
180
+ return { error: out.error ?? null, result: out.result };
181
+ },
182
+ // Two executes on the same engine running concurrently must not
183
+ // interfere — separate workers, separate logs, correct results.
184
+ async concurrent() {
185
+ const engine = makeEngine(64);
186
+ const [a, b] = await Promise.all([
187
+ engine.execute(`console.log("run-a"); const r = await files.slow({ ms: 50 }); return { tag: "a", waited: r.waited };`),
188
+ engine.execute(`console.log("run-b"); const r = await files.slow({ ms: 30 }); return { tag: "b", waited: r.waited };`),
189
+ ]);
190
+ return {
191
+ aError: a.error ?? null,
192
+ bError: b.error ?? null,
193
+ aResult: a.result,
194
+ bResult: b.result,
195
+ aLogs: a.logs,
196
+ bLogs: b.logs,
197
+ };
198
+ },
199
+ // Agent code killing its own worker (it has the full runtime, so it can)
200
+ // must fail soft with a descriptive error and leave the engine usable.
201
+ async "worker-suicide"() {
202
+ const engine = makeEngine(64);
203
+ const out = await engine.execute(`process.exit(7);`);
204
+ const after = await engine.execute("return 1 + 1");
205
+ return {
206
+ error: out.error ?? null,
207
+ afterError: after.error ?? null,
208
+ afterResult: after.result,
209
+ };
210
+ },
211
+ // Sandbox code that genuinely exhausts the memory limit must fail soft:
212
+ // a clean error returned from execute(), no process abort, and the engine
213
+ // must remain usable for a subsequent run.
214
+ async "sandbox-oom"() {
215
+ const engine = makeEngine(32);
216
+ const out = await engine.execute(`
217
+ const hog = [];
218
+ while (true) hog.push(new Array(1e6).fill(1));
219
+ `);
220
+ const after = await engine.execute("return 1 + 1");
221
+ return {
222
+ error: out.error ?? null,
223
+ afterError: after.error ?? null,
224
+ afterResult: after.result,
225
+ };
226
+ },
227
+ };
228
+ const name = process.argv[2];
229
+ const scenario = scenarios[name];
230
+ if (!scenario) {
231
+ console.error(`Unknown scenario: ${name}`);
232
+ process.exit(2);
233
+ }
234
+ const report = await scenario();
235
+ console.log(JSON.stringify(report));
236
+ process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Code mode for agents — turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -75,7 +75,6 @@
75
75
  "commander": "^14.0.3",
76
76
  "jiti": "^2.7.0",
77
77
  "proper-lockfile": "^4.1.2",
78
- "quickjs-emscripten": "^0.32.0",
79
78
  "rrule": "^2.8.1",
80
79
  "typebox": "^1.1.35"
81
80
  }