@nodetool-ai/code-nodes 0.8.1

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.
@@ -0,0 +1,1010 @@
1
+ /**
2
+ * Universal Code Node — sandboxed JavaScript execution via QuickJS WASM.
3
+ *
4
+ * Runs user code in an isolated QuickJS WebAssembly guest (see
5
+ * `@nodetool-ai/agents/js-sandbox`) with standard JavaScript plus the bridge
6
+ * APIs: fetch(), workspace (text/bytes/stat/mkdir/remove/copy/move/root),
7
+ * getSecret(), sleep(), progress(), crypto (randomUUID/digest/hmac), format,
8
+ * media (read a media input's bytes, build a media output) and the base64/hex
9
+ * helpers. Dynamic inputs arrive on the `inputs` object.
10
+ *
11
+ * On a server host the code also gets the `nodetool` object model — the
12
+ * platform as objects (`nodetool.workflows`, `nodetool.assets`, …), backed by
13
+ * the agent toolbelt through imported capability modules. The belt is loaded
14
+ * lazily and only on Node: the in-browser runner bundles this module, so the
15
+ * toolbelt (native canvas, IMAP, execution) must never sit in its static
16
+ * import graph. Without a belt, `nodetool.capabilities()` reports `{}` and
17
+ * every method throws naming its missing tool instead of a ReferenceError.
18
+ *
19
+ * Outputs leave the body through two host calls: `await emit(name, value)`
20
+ * streams one value downstream immediately, `await output(name, value)` sets a
21
+ * handle's final value, delivered as one bag when the body completes. The
22
+ * body's return value carries no outputs.
23
+ *
24
+ * Example:
25
+ * // inputs: { x: 5, text: "hello" }
26
+ * // code:
27
+ * for (const word of inputs.text.split(" ")) await emit("word", word);
28
+ * await output("sum", inputs.x + 10);
29
+ * // streams { word: … } per word, then the final bag { sum: 15 }
30
+ *
31
+ * A body that calls neither runs the deprecated return/yield contract.
32
+ *
33
+ * Inputs arrive one of two ways. A body that never mentions `stream` runs once
34
+ * per incoming item with `inputs` holding that item's snapshot. A body that
35
+ * calls `stream` runs once for the whole stream and pulls values itself
36
+ * (`stream(name)`, `stream.any()`, `stream.first`, `stream.open`), executing
37
+ * through {@link CodeNode.run} — the kernel's streaming-input mode, which
38
+ * hydration selects per node from the body itself.
39
+ */
40
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
41
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
42
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
43
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
44
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
45
+ };
46
+ import { BaseNode, prop, CODE_INPUTS_GLOBAL, parseCodeBody, staticImportSpecifiers, hasReturnStatement, hasYieldStatement, wrapImplicitReturn, normalizeCodeOutput, usesEmitOutputContract, usesStreamInputContract } from "@nodetool-ai/node-sdk";
47
+ import { runInSandbox, TOOLS_PRELUDE, NODETOOL_API_PRELUDE_FULL } from "@nodetool-ai/agents/js-sandbox";
48
+ import { importHidden } from "@nodetool-ai/config";
49
+ import { ALL_PLATFORMS, SANDBOX_CAPABILITY_PACK } from "@nodetool-ai/protocol";
50
+ /** JS keywords that cannot be used as variable names. */
51
+ const JS_RESERVED = new Set([
52
+ "abstract",
53
+ "arguments",
54
+ "await",
55
+ "boolean",
56
+ "break",
57
+ "byte",
58
+ "case",
59
+ "catch",
60
+ "char",
61
+ "class",
62
+ "const",
63
+ "continue",
64
+ "debugger",
65
+ "default",
66
+ "delete",
67
+ "do",
68
+ "double",
69
+ "else",
70
+ "enum",
71
+ "eval",
72
+ "export",
73
+ "extends",
74
+ "false",
75
+ "final",
76
+ "finally",
77
+ "float",
78
+ "for",
79
+ "function",
80
+ "goto",
81
+ "if",
82
+ "implements",
83
+ "import",
84
+ "in",
85
+ "instanceof",
86
+ "int",
87
+ "interface",
88
+ "let",
89
+ "long",
90
+ "native",
91
+ "new",
92
+ "null",
93
+ "package",
94
+ "private",
95
+ "protected",
96
+ "public",
97
+ "return",
98
+ "short",
99
+ "static",
100
+ "super",
101
+ "switch",
102
+ "synchronized",
103
+ "this",
104
+ "throw",
105
+ "throws",
106
+ "transient",
107
+ "true",
108
+ "try",
109
+ "typeof",
110
+ "undefined",
111
+ "var",
112
+ "void",
113
+ "volatile",
114
+ "while",
115
+ "with",
116
+ "yield"
117
+ ]);
118
+ // ---------------------------------------------------------------------------
119
+ // The `nodetool` object model — toolbelt bridge
120
+ // ---------------------------------------------------------------------------
121
+ /**
122
+ * The guest prelude every run gets: the belt bridge over `__callTool`, then
123
+ * the `nodetool` object model on top of it. Both are plain strings, so
124
+ * prepending them costs nothing even where no belt exists — the object model
125
+ * degrades per namespace by design.
126
+ */
127
+ const NODETOOL_PRELUDE = `${TOOLS_PRELUDE}\n${NODETOOL_API_PRELUDE_FULL}`;
128
+ /**
129
+ * Bridge globals for a host with no toolbelt (browser runner, no context):
130
+ * the prelude builds zero wrappers, `nodetool.capabilities()` returns `{}`,
131
+ * and every `nodetool.*` method throws naming its missing tool.
132
+ */
133
+ const NO_TOOLS_GLOBALS = {
134
+ __toolNames: [],
135
+ __callTool: async () => ({
136
+ ok: false,
137
+ error: "no tools in this environment"
138
+ })
139
+ };
140
+ let agentsModulePromise = null;
141
+ let injectedAgentsModule = null;
142
+ /**
143
+ * Hand the Code node the agents package a host already holds.
144
+ *
145
+ * The bare specifier below resolves in a workspace checkout and in the CLI,
146
+ * and resolves nowhere in the bundled backend: `bundle-backend.mjs` inlines
147
+ * every workspace package into `server.mjs` and stages only third-party
148
+ * externals, so `node_modules/@nodetool-ai/agents` does not exist in the
149
+ * packaged desktop app or in the Docker/Fly image. Without this, a Code node
150
+ * body in production runs with no toolbelt (`nodetool.capabilities()` is `{}`,
151
+ * `yt_dlp` is missing) and cannot import
152
+ * `@nodetool-ai/sandbox-nodetool/*` at all.
153
+ *
154
+ * The server calls this at bootstrap with the copy esbuild already inlined,
155
+ * which also keeps the run on one module instance — a staged second copy would
156
+ * carry its own capability registry.
157
+ */
158
+ export function setCodeNodeAgentsModule(mod) {
159
+ injectedAgentsModule = mod;
160
+ agentsModulePromise = null;
161
+ }
162
+ /**
163
+ * The agents package index: the injected one, else loaded lazily and hidden
164
+ * from bundlers. `importHidden` answers `null` off Node, so a browser bundle
165
+ * never resolves the toolbelt's server-only dependencies, and a host that
166
+ * neither injects nor can resolve the specifier degrades the same way —
167
+ * no belt, no capability modules.
168
+ */
169
+ function loadAgentsModule() {
170
+ if (injectedAgentsModule)
171
+ return Promise.resolve(injectedAgentsModule);
172
+ if (!agentsModulePromise) {
173
+ agentsModulePromise = importHidden("@nodetool-ai/agents").catch(() => null);
174
+ }
175
+ return agentsModulePromise;
176
+ }
177
+ let toolOverride = null;
178
+ /**
179
+ * Test/host injection point: replace the assembled toolbelt with a fixed set
180
+ * (`[]` simulates a beltless host). Pass `null` to restore the default
181
+ * assembly.
182
+ */
183
+ export function setCodeNodeTools(tools) {
184
+ toolOverride = tools;
185
+ }
186
+ /**
187
+ * The belt a JS script gets, from the one function that assembles it
188
+ * (`assembleSandboxToolbelt` in `@nodetool-ai/agents`). This used to be a
189
+ * second copy of that assembly with the comment "must match" — and it drifted:
190
+ * the Apify and SerpAPI capabilities were added to the shared belt and not to
191
+ * this copy, so `run_apify_actor` inside a Code node resolved to nothing and
192
+ * calling it failed with a bare `TypeError: not a function`.
193
+ */
194
+ function assembleToolbelt(mod) {
195
+ return mod.assembleSandboxToolbelt();
196
+ }
197
+ /**
198
+ * The permission gate this run answers to: the one the host put on the
199
+ * context, or the headless gate when no host set one (invariant I-1).
200
+ *
201
+ * A kernel workflow run sets no key, so a shipped example with a Code node
202
+ * keeps running under `auto`. A body reached through a chat's `run_node`
203
+ * inherits that turn's live mode, so approving `run_node` is approval to run
204
+ * the node, not to run whatever its body calls.
205
+ */
206
+ function codeNodeGate(mod, context) {
207
+ return mod.gateFromContext(context, "Code node");
208
+ }
209
+ /**
210
+ * Globals wiring the `tools`/`nodetool` preludes to a real tool bridge, or
211
+ * the inert stub when no belt is constructible. Tool execution needs a
212
+ * ProcessingContext; without one the stub keeps the prelude harmless.
213
+ *
214
+ * The belt is wrapped in the run's gate before the bridge sees it: the bridge
215
+ * calls `process()` directly, so an unwrapped belt would let a body call a
216
+ * write capability the host's mode blocks.
217
+ */
218
+ async function toolBridgeGlobals(context) {
219
+ if (!context)
220
+ return NO_TOOLS_GLOBALS;
221
+ const mod = await loadAgentsModule();
222
+ if (!mod)
223
+ return NO_TOOLS_GLOBALS;
224
+ const tools = mod.gateTools(toolOverride ?? assembleToolbelt(mod), codeNodeGate(mod, context));
225
+ return mod.buildToolBridge({ tools, context }).globals;
226
+ }
227
+ /**
228
+ * Bags a node invocation may hold before `emit` blocks the guest. Exported so
229
+ * a test can drive the producer past the bound without guessing the number.
230
+ */
231
+ export const EMIT_CHANNEL_CAPACITY = 64;
232
+ /**
233
+ * A bounded async queue with one producer (the guest's `emit` bridge) and one
234
+ * consumer (the `genProcess` pump). `push` resolves once the item is in the
235
+ * queue, which is only below capacity — that resolution is the backpressure
236
+ * release. `take` resolves `null` when the channel is closed and drained, so
237
+ * the consumer cannot finish while bags are still queued.
238
+ */
239
+ class BoundedChannel {
240
+ capacity;
241
+ items = [];
242
+ takers = [];
243
+ pushers = [];
244
+ closed = false;
245
+ constructor(capacity) {
246
+ this.capacity = capacity;
247
+ }
248
+ async push(item) {
249
+ while (this.items.length >= this.capacity && !this.closed) {
250
+ await new Promise((resolve) => this.pushers.push(resolve));
251
+ }
252
+ if (this.closed)
253
+ return;
254
+ this.items.push(item);
255
+ this.takers.shift()?.();
256
+ }
257
+ async take() {
258
+ for (;;) {
259
+ if (this.items.length > 0) {
260
+ // SAFETY: the queue is non-empty here and nothing awaits before the
261
+ // shift. `T` may itself include `undefined`, so no runtime check can
262
+ // tell "empty" from "the item was undefined".
263
+ const item = this.items.shift();
264
+ this.pushers.shift()?.();
265
+ return item;
266
+ }
267
+ if (this.closed)
268
+ return null;
269
+ await new Promise((resolve) => this.takers.push(resolve));
270
+ }
271
+ }
272
+ /** No further pushes; queued items still drain. Wakes everyone waiting. */
273
+ close() {
274
+ this.closed = true;
275
+ while (this.takers.length > 0)
276
+ this.takers.shift()?.();
277
+ while (this.pushers.length > 0)
278
+ this.pushers.shift()?.();
279
+ }
280
+ }
281
+ /** Answer the guest gets for a take that never resolved into a value. */
282
+ const END_OF_STREAM = { done: true };
283
+ /**
284
+ * What the cancellation promise resolves to. Shaped as an exhausted iterator
285
+ * result rather than as a take, so racing it against `iterator.next()` yields
286
+ * one `IteratorResult` union that `done` narrows on its own.
287
+ */
288
+ const CANCELLED_RESULT = {
289
+ done: true,
290
+ value: undefined
291
+ };
292
+ /**
293
+ * The host side of the guest's `stream` global: one inbox iterator per handle
294
+ * (plus one `any()` iterator), created on first use and driven one take at a
295
+ * time.
296
+ *
297
+ * Two takes on the same handle cannot run concurrently — an async generator
298
+ * rejects a `next()` while another is pending — so calls are serialized on a
299
+ * per-key promise chain. Distinct handles keep their own chain and proceed in
300
+ * parallel, which is what lets a body await two streams at once.
301
+ */
302
+ class InputStreamBridge {
303
+ inputs;
304
+ perHandle = new Map();
305
+ anyIterator;
306
+ /** Per-key serialization: `null` is the `any()` iterator's own chain. */
307
+ chains = new Map();
308
+ cancelled;
309
+ constructor(inputs) {
310
+ this.inputs = inputs;
311
+ // A cancelled run must not leave the guest parked on a take that can no
312
+ // longer be answered: every pending and future take resolves as EOS, the
313
+ // body unwinds, and the sandbox's own abort ends the run.
314
+ this.cancelled = new Promise((resolve) => {
315
+ const signal = inputs.signal;
316
+ if (signal.aborted) {
317
+ resolve(CANCELLED_RESULT);
318
+ return;
319
+ }
320
+ signal.addEventListener("abort", () => resolve(CANCELLED_RESULT), {
321
+ once: true
322
+ });
323
+ });
324
+ }
325
+ take(handle) {
326
+ const previous = this.chains.get(handle);
327
+ const next = previous
328
+ ? previous.then(() => this.pull(handle), () => this.pull(handle))
329
+ : this.pull(handle);
330
+ this.chains.set(handle, next);
331
+ return next;
332
+ }
333
+ /** `stream.open(name)`: could this handle still produce a value? */
334
+ isOpen(handle) {
335
+ return (this.inputs.hasStream(handle) ||
336
+ this.inputs.hasBuffered?.(handle) === true);
337
+ }
338
+ async pull(handle) {
339
+ if (this.inputs.signal.aborted)
340
+ return END_OF_STREAM;
341
+ if (handle === null) {
342
+ const iterator = (this.anyIterator ??= this.inputs.any());
343
+ const result = await Promise.race([iterator.next(), this.cancelled]);
344
+ if (result.done === true)
345
+ return END_OF_STREAM;
346
+ const [source, value] = result.value;
347
+ return { done: false, handle: source, value: jsonSafe(value) };
348
+ }
349
+ let iterator = this.perHandle.get(handle);
350
+ if (!iterator) {
351
+ iterator = this.inputs.stream(handle);
352
+ this.perHandle.set(handle, iterator);
353
+ }
354
+ const result = await Promise.race([iterator.next(), this.cancelled]);
355
+ if (result.done === true)
356
+ return END_OF_STREAM;
357
+ return {
358
+ done: false,
359
+ handle,
360
+ value: jsonSafe(result.value)
361
+ };
362
+ }
363
+ }
364
+ export class CodeNode extends BaseNode {
365
+ static nodeType = "nodetool.code.Code";
366
+ static platforms = ALL_PLATFORMS;
367
+ static title = "Code";
368
+ static description = "Execute vanilla JavaScript in a sandboxed environment. " +
369
+ "APIs: fetch(), workspace.read/write/list/readBytes/writeBytes/stat/mkdir/remove/copy/move/root(), " +
370
+ "getSecret(), sleep(), progress(), crypto.digest/hmac/randomUUID/getRandomValues, " +
371
+ "format.number/date/relativeTime/list, " +
372
+ "media.bytes/text/info to read a document, image, audio or video input and " +
373
+ "media.toDocument/toImage/toAudio/toVideo to return one, " +
374
+ "toBase64/fromBase64/toHex/fromHex, parallelMap, plus the `nodetool` object model " +
375
+ "(workflows, assets, jobs, …) backed by platform tools on server hosts — " +
376
+ "nodetool.capabilities() reports what is live. Tool-backed calls can spend money " +
377
+ "(media generation, workflow runs) and reach the web; each tool applies its own " +
378
+ "permission gating. " +
379
+ "Dynamic inputs arrive on the `inputs` object; outputs leave through " +
380
+ "await emit(name, value) to stream a value now and await output(name, value) " +
381
+ "to set a handle's final value. The return value carries no outputs. " +
382
+ "A body that calls stream(name), stream.any(), stream.first(name) or " +
383
+ "stream.open(name) runs once and reads its connected inputs as they " +
384
+ "arrive, instead of once per item." +
385
+ "\n code, javascript, script, function, dynamic, custom, logic, " +
386
+ "parse, transform, convert, format, compute, calculate, filter, map, " +
387
+ "merge, extract, json, csv, text, string, math, " +
388
+ "pdf, document, read document, image bytes, audio, video, file, media";
389
+ static inlineFields = ["code"];
390
+ static inputFields = [];
391
+ static supportsDynamicInputs = true;
392
+ static supportsDynamicOutputs = true;
393
+ /**
394
+ * Whether this node streams its inputs is a property of its body, not of the
395
+ * type: a body that calls `stream` drains its own inbox in {@link run},
396
+ * every other body keeps the buffered per-item invocation. Both hydration
397
+ * paths re-read the body through this hook, so editing the last `stream`
398
+ * call out flips the mode back with no saved flag involved.
399
+ */
400
+ static resolveStreamingInput = (node) => usesStreamInputContract(String(node?.properties?.code ?? ""));
401
+ /**
402
+ * Persistent state across streaming invocations; reset each workflow run.
403
+ * The guest owns the contents and the sandbox syncs them back marshalled, so
404
+ * what survives a call is what survives a JSON round trip.
405
+ */
406
+ _state = {};
407
+ async initialize() {
408
+ this._state = {};
409
+ }
410
+ /**
411
+ * Forward guest `progress(percent, message)` calls to the kernel as
412
+ * `node_progress` messages — the same channel the Python worker and the
413
+ * ComfyUI node use, so the editor's node progress bar picks them up.
414
+ */
415
+ progressSink(context) {
416
+ if (!context || !this.__node_id)
417
+ return undefined;
418
+ return (progress, message) => {
419
+ context.postMessage({
420
+ type: "node_progress",
421
+ node_id: this.__node_id,
422
+ progress,
423
+ total: 100,
424
+ chunk: message,
425
+ workflow_id: context.workflowId
426
+ });
427
+ };
428
+ }
429
+ /**
430
+ * Forward guest `console.*` calls as `log_update` messages — the same
431
+ * channel package-resolution warnings already use, so the editor's log
432
+ * panel shows them as they happen.
433
+ */
434
+ logSink(context) {
435
+ if (!context || !this.__node_id)
436
+ return undefined;
437
+ return (level, message) => {
438
+ this.postLog(context, message, consoleSeverity(level));
439
+ };
440
+ }
441
+ /** Post on the node's log channel, where the run has one. */
442
+ postLog(context, content, severity) {
443
+ if (!context || !this.__node_id)
444
+ return;
445
+ context.postMessage({
446
+ type: "log_update",
447
+ node_id: this.__node_id,
448
+ node_name: CodeNode.title,
449
+ content,
450
+ severity,
451
+ workflow_id: context.workflowId
452
+ });
453
+ }
454
+ /**
455
+ * Sandbox policy from the node's props. The sandbox clamps
456
+ * `maxResponseBodyBytes` to its own ceiling, so no clamping here.
457
+ *
458
+ * The two capability switches default to the restrictive value and are only
459
+ * ever widened by an explicit choice on this node — nothing the guest code
460
+ * can reach.
461
+ */
462
+ sandboxLimits(envelope) {
463
+ const mb = Number(this.max_response_mb ?? 1);
464
+ const declared = envelope.secrets
465
+ .map((name) => String(name).trim())
466
+ .filter((name) => name !== "");
467
+ return {
468
+ maxResponseBodyBytes: Number.isFinite(mb)
469
+ ? Math.round(mb * 1024 * 1024)
470
+ : undefined,
471
+ allowPrivateNetwork: this.allow_local_network === true,
472
+ filesystemAccess: this.allow_host_filesystem === true ? "host" : "workspace",
473
+ // An empty list is "no scope declared", not "no secrets": scoping is
474
+ // opt-in, so a node authored before it existed keeps the reach it had.
475
+ secretScope: declared.length > 0 ? declared : null
476
+ };
477
+ }
478
+ /**
479
+ * Resolve the packs the body imports through the host's catalog.
480
+ *
481
+ * The imports are the declaration: a static `import` of an installed pack is
482
+ * served, and nothing else is — same rule a JS script runs under, so a body
483
+ * moved between the two behaves identically. A body that imports no pack gets
484
+ * no loader at all. An import that cannot be served — no catalog in this
485
+ * process, an uninstalled pack, a module the host refused — fails the node
486
+ * here rather than as a resolve error inside the guest. Version and digest
487
+ * drift only warn: resolution uses what is installed, and saying so on the
488
+ * node's log is the whole remedy.
489
+ *
490
+ * NodeTool's own guest modules (`@nodetool-ai/sandbox-nodetool/*`) are not
491
+ * packs; {@link CodeNode.resolveCapabilities} mounts those.
492
+ */
493
+ resolveModules(envelope, context) {
494
+ const statements = importableStatements(envelope.code);
495
+ if (statements === null)
496
+ return undefined;
497
+ const specifiers = [
498
+ ...new Set(staticImportSpecifiers(statements).filter((specifier) => specifier !== SANDBOX_CAPABILITY_PACK &&
499
+ !specifier.startsWith(`${SANDBOX_CAPABILITY_PACK}/`)))
500
+ ];
501
+ if (specifiers.length === 0)
502
+ return undefined;
503
+ const catalog = context?.sandboxModuleCatalog;
504
+ if (!catalog) {
505
+ throw new Error(`Sandbox packages are not available in this process, so ${specifiers
506
+ .map((specifier) => `"${specifier}"`)
507
+ .join(", ")} cannot be imported.`);
508
+ }
509
+ const resolution = catalog.resolveForExecution(specifiers.map((specifier) => ({ specifier })));
510
+ const errors = resolution.statuses.filter((status) => status.status === "error");
511
+ if (errors.length > 0) {
512
+ throw new Error(errors
513
+ .map((status) => `${status.message} (pack "${status.packName}")`)
514
+ .join(" "));
515
+ }
516
+ for (const status of resolution.statuses) {
517
+ if (status.status !== "warning")
518
+ continue;
519
+ this.logWarning(context, `${status.message} (pack "${status.packName}")`);
520
+ }
521
+ return resolution;
522
+ }
523
+ /** Post a warning on the node's log channel, where the run has one. */
524
+ logWarning(context, content) {
525
+ this.postLog(context, content, "warning");
526
+ }
527
+ /**
528
+ * The globals every run gets, on either contract. Declared inputs arrive on
529
+ * one `inputs` object rather than as globals of their own name: sharing the
530
+ * global namespace with the sandbox API let an input called `env` or `data`
531
+ * shadow a bridge, and made every undeclared identifier ambiguous between a
532
+ * typo and a missing slot. State stays a direct reference so mutations
533
+ * persist across calls.
534
+ */
535
+ async sandboxGlobals(context) {
536
+ const allInputs = {
537
+ ...this.serialize(),
538
+ ...Object.fromEntries(this.dynamicProps)
539
+ };
540
+ return {
541
+ [CODE_INPUTS_GLOBAL]: deepCopyInputs(extractDynamicInputs(allInputs)),
542
+ state: this._state,
543
+ ...(await toolBridgeGlobals(context))
544
+ };
545
+ }
546
+ /** Milliseconds the guest may run, or undefined for no limit. */
547
+ timeoutMs(envelope) {
548
+ return envelope.timeoutSeconds > 0
549
+ ? envelope.timeoutSeconds * 1000
550
+ : undefined;
551
+ }
552
+ /**
553
+ * What this invocation runs: the node's own properties, always.
554
+ *
555
+ * Linking a script *materializes* it — the pinned version's code, secrets
556
+ * and timeout are copied onto the node when the link is made or
557
+ * updated, and the `script` property records only which version they came
558
+ * from. So execution needs no database, no resolver, and no await: the body
559
+ * a run executes is the body the graph carries, which is also the body
560
+ * hydration read to decide whether this node streams its inputs.
561
+ */
562
+ envelope() {
563
+ return {
564
+ code: String(this.code ?? "return {};"),
565
+ secrets: Array.isArray(this.secrets) ? this.secrets : [],
566
+ timeoutSeconds: Number(this.timeout ?? 30)
567
+ };
568
+ }
569
+ /**
570
+ * Everything a run of this node's body needs regardless of contract: the
571
+ * prelude, the node's own policy, its globals and its module resolution.
572
+ * Every execution path starts from this and adds only what its own contract
573
+ * needs (an emit sink, an input bridge, a cancellation signal).
574
+ */
575
+ async sandboxOptions(code, envelope, context) {
576
+ return {
577
+ code: `${NODETOOL_PRELUDE}\n${code}`,
578
+ context,
579
+ timeoutMs: this.timeoutMs(envelope),
580
+ globals: await this.sandboxGlobals(context),
581
+ limits: this.sandboxLimits(envelope),
582
+ onProgress: this.progressSink(context),
583
+ onLog: this.logSink(context),
584
+ modules: this.resolveModules(envelope, context),
585
+ capabilities: await this.resolveCapabilities(code, context)
586
+ };
587
+ }
588
+ /**
589
+ * NodeTool's own guest modules (`@nodetool-ai/sandbox-nodetool/*`) for the
590
+ * namespaces this body imports, or nothing when it imports none.
591
+ *
592
+ * They are not packs, so they never reach the catalog: the host decides what
593
+ * a run can reach, and it says so with the gate it put on the context. The
594
+ * run carries that gate, exactly as the belt does — the import path and the
595
+ * belt bridge are two doors onto the same capabilities, so one gated and the
596
+ * other not would be no gate at all. It is the wiring
597
+ * `mountJsScriptSandbox` builds for a JS script, over the Code node's own
598
+ * module resolution.
599
+ */
600
+ async resolveCapabilities(code, context) {
601
+ if (!context)
602
+ return undefined;
603
+ const mod = await loadAgentsModule();
604
+ if (!mod)
605
+ return undefined;
606
+ const mounted = await mod.mountCapabilityModules(code, mod.createCapabilityRun({
607
+ context,
608
+ gate: codeNodeGate(mod, context),
609
+ availableSecrets: mod.contextSecretAvailability(context)
610
+ }));
611
+ if (!mounted.ok) {
612
+ throw new Error(mounted.error.replace("The action imports", "The code imports"));
613
+ }
614
+ return mounted.mount;
615
+ }
616
+ async process(context) {
617
+ const envelope = this.envelope();
618
+ const code = envelope.code;
619
+ if (!usesEmitOutputContract(code)) {
620
+ // The legacy return/yield contract runs unwarned: the deprecation
621
+ // notice lives in validate_code, not in every run's message stream —
622
+ // reliability goldens and downstream consumers pin that stream.
623
+ return this.runLegacySingleShot(envelope, context);
624
+ }
625
+ const result = await runInSandbox(await this.sandboxOptions(code, envelope, context));
626
+ if (!result.success) {
627
+ throw new Error(result.error ?? "Code execution failed");
628
+ }
629
+ // Single-shot callers see no stream, so an emitted value would be lost.
630
+ // Precedence: a handle's final `output` value wins; where there is none,
631
+ // the LAST value emitted for that handle stands in.
632
+ const merged = {};
633
+ for (const { name, value } of result.emitted ?? []) {
634
+ merged[name] = value;
635
+ }
636
+ return { ...merged, ...(result.outputs ?? {}) };
637
+ }
638
+ /**
639
+ * Streaming-input execution: one invocation that drains its own inbox.
640
+ *
641
+ * The kernel calls this instead of the per-item path when the hydrated
642
+ * `is_streaming_input` flag is true, which for this node means the body
643
+ * calls `stream` (see {@link CodeNode.resolveStreamingInput}). The body runs
644
+ * once; `stream(name)` / `stream.any()` pull values out of `inputs` one take
645
+ * at a time, `emit` routes live through `outputs.emit`, and the `output`
646
+ * finals post as one bag when the body ends.
647
+ */
648
+ async run(inputs, outputs, context) {
649
+ const envelope = this.envelope();
650
+ const code = envelope.code;
651
+ if (!usesStreamInputContract(code)) {
652
+ // The kernel only routes here when hydration said the body streams, and
653
+ // hydration re-reads the body every time. Reaching this means a stale
654
+ // saved flag was trusted (a graph built with `withExplicitNodeFlags` and
655
+ // never hydrated), and there is no safe recovery: this path never
656
+ // delivers per-item invocations, so a buffered body would silently see
657
+ // none of its connected inputs. Fail loudly instead.
658
+ throw new Error("Code node is flagged streaming-input but its body never calls " +
659
+ "stream(...). Hydrate the graph against the node registry, or add " +
660
+ "a stream(name) call to consume the connected inputs.");
661
+ }
662
+ const bridge = new InputStreamBridge(inputs);
663
+ const abort = new AbortController();
664
+ const onCancel = () => abort.abort();
665
+ if (inputs.signal.aborted) {
666
+ abort.abort();
667
+ }
668
+ else {
669
+ inputs.signal.addEventListener("abort", onCancel, { once: true });
670
+ }
671
+ try {
672
+ const result = await runInSandbox({
673
+ ...(await this.sandboxOptions(code, envelope, context)),
674
+ signal: abort.signal,
675
+ // Awaited, so a downstream inbox at its bound blocks the guest's
676
+ // `emit` — the same backpressure the buffered pump gets from its
677
+ // channel, one link earlier.
678
+ onEmit: async (name, value) => {
679
+ await outputs.emit(name, value);
680
+ },
681
+ onTakeInput: (handle) => bridge.take(handle),
682
+ onStreamOpen: (handle) => bridge.isOpen(handle)
683
+ });
684
+ // A cancelled run has nothing to report: the takes were unparked as
685
+ // end-of-stream and the body unwound on a signal it did not choose.
686
+ if (inputs.signal.aborted)
687
+ return;
688
+ if (!result.success) {
689
+ throw new Error(result.error ?? "Code execution failed");
690
+ }
691
+ // One bag, one frame: the finals of a single invocation belong together,
692
+ // so sibling handles share lineage instead of arriving as N items.
693
+ const finals = result.outputs ?? {};
694
+ if (Object.keys(finals).length > 0) {
695
+ await outputs.emitGroup(finals);
696
+ }
697
+ }
698
+ finally {
699
+ inputs.signal.removeEventListener("abort", onCancel);
700
+ abort.abort();
701
+ }
702
+ }
703
+ /** The deprecated return-bag path: implicit return, normalized output. */
704
+ async runLegacySingleShot(envelope, context) {
705
+ const code = envelope.code;
706
+ const body = hasReturnStatement(code) ? code : wrapImplicitReturn(code);
707
+ const result = await runInSandbox(await this.sandboxOptions(body, envelope, context));
708
+ if (!result.success) {
709
+ throw new Error(result.error ?? "Code execution failed");
710
+ }
711
+ return normalizeCodeOutput(result.result);
712
+ }
713
+ async *genProcess(context) {
714
+ const envelope = this.envelope();
715
+ if (usesEmitOutputContract(envelope.code)) {
716
+ yield* this.pumpEmitContract(envelope, context);
717
+ return;
718
+ }
719
+ if (!hasYieldStatement(envelope.code)) {
720
+ yield await this.runLegacySingleShot(envelope, context);
721
+ return;
722
+ }
723
+ yield* this.runLegacyStream(envelope, context);
724
+ }
725
+ /**
726
+ * The emit/output pump: start the run, hand every `emit` bag downstream as
727
+ * it happens, then post the final `output` bag once the run succeeds.
728
+ *
729
+ * The guest's `emit` promise resolves only when the channel has accepted its
730
+ * bag, so a producer faster than the kernel blocks at the channel bound —
731
+ * backpressure with no new kernel concept.
732
+ */
733
+ async *pumpEmitContract(envelope, context) {
734
+ const channel = new BoundedChannel(EMIT_CHANNEL_CAPACITY);
735
+ const abort = new AbortController();
736
+ let result;
737
+ let failure;
738
+ // Never rejects: the run's outcome is read after the drain, so a rejection
739
+ // parked on the promise would otherwise look unhandled while we pump.
740
+ const run = (async () => {
741
+ try {
742
+ result = await runInSandbox({
743
+ ...(await this.sandboxOptions(envelope.code, envelope, context)),
744
+ signal: abort.signal,
745
+ onEmit: (name, value) => channel.push({ [name]: value })
746
+ });
747
+ }
748
+ catch (err) {
749
+ failure = err;
750
+ }
751
+ finally {
752
+ // Closing here is what ends the drain, and it happens only after the
753
+ // run has settled — so every bag pushed before that is still taken.
754
+ channel.close();
755
+ }
756
+ })();
757
+ try {
758
+ for (;;) {
759
+ const bag = await channel.take();
760
+ if (!bag)
761
+ break;
762
+ yield bag;
763
+ }
764
+ await run;
765
+ if (failure)
766
+ throw failure;
767
+ if (!result || !result.success) {
768
+ throw new Error(result?.error ?? "Code execution failed");
769
+ }
770
+ // A failed run discards finals; only a successful one posts its bag.
771
+ const outputs = result.outputs ?? {};
772
+ if (Object.keys(outputs).length > 0) {
773
+ yield outputs;
774
+ }
775
+ }
776
+ finally {
777
+ // The consumer may abandon the generator mid-stream (a downstream
778
+ // routing error, or a `break`). Aborting unblocks the guest, closing the
779
+ // channel releases any `emit` waiting for room, and awaiting the run
780
+ // leaves nothing in flight behind us.
781
+ abort.abort();
782
+ channel.close();
783
+ await run;
784
+ }
785
+ }
786
+ /** The deprecated `yield` path: the guest collects, the host replays. */
787
+ async *runLegacyStream(envelope, context) {
788
+ const code = envelope.code;
789
+ // The prelude sits outside the yield_ rewrite: only user code streams.
790
+ const wrappedBody = `${NODETOOL_PRELUDE}
791
+ const __yielded = [];
792
+ function yield_(value) { __yielded.push(value); }
793
+ ${code.replace(/\byield\b/g, "yield_")}
794
+ return __yielded;
795
+ `;
796
+ const result = await runInSandbox({
797
+ // The body itself, not the rewritten one: `code` is overridden below,
798
+ // but the options are also what decide which platform modules this run
799
+ // mounts, and that answer is read off the imports the user wrote.
800
+ ...(await this.sandboxOptions(envelope.code, envelope, context)),
801
+ code: wrappedBody
802
+ });
803
+ if (!result.success) {
804
+ throw new Error(result.error ?? "Code execution failed");
805
+ }
806
+ // The guest returns the array `yield_` collected. A body that replaced the
807
+ // return value with something else streams nothing.
808
+ const items = Array.isArray(result.result)
809
+ ? result.result
810
+ : [];
811
+ for (const item of items) {
812
+ if (item !== null && item !== undefined) {
813
+ yield normalizeCodeOutput(item);
814
+ }
815
+ }
816
+ }
817
+ }
818
+ __decorate([
819
+ prop({
820
+ type: "str",
821
+ default: "return {};",
822
+ title: "Code",
823
+ description: "JavaScript code to execute. " +
824
+ "Dynamic inputs arrive on the `inputs` object. " +
825
+ "APIs: fetch(url, options), workspace.read/write/list/readBytes/writeBytes/stat/mkdir/remove/copy/move/root, " +
826
+ "workspace.stat(path) returns {exists, size, isDirectory, isFile, isSymlink, modifiedMs, createdMs, accessedMs}, " +
827
+ "nodetool.secrets.get(name) (throws when unset; limited to the names in " +
828
+ "`secrets` when that is set), getSecret(name), sleep(ms), progress(percent, message), " +
829
+ "crypto.randomUUID/getRandomValues/digest/hmac, " +
830
+ "format.number/date/relativeTime/list, " +
831
+ "media.bytes(ref)/media.text(ref, {encoding})/media.info(ref) to read a document, " +
832
+ "image, audio or video input, and media.toDocument/toImage/toAudio/toVideo(bytes, " +
833
+ "{mimeType, filename}) to build one to return, " +
834
+ "toBase64/fromBase64/toHex/fromHex. Libraries — CSV, HTML, XML, XLSX, YAML, zip, dates, " +
835
+ "diffs — are sandbox packages: `import` one at the top and the host resolves it " +
836
+ "against the installed packs. " +
837
+ "console.log/warn/error/info appear on the node log as they run. " +
838
+ "Await fetch, sleep, workspace, getSecret, format, media, an imported package's " +
839
+ "calls, and crypto.digest/hmac; the rest are synchronous. " +
840
+ "Concurrent calls run in parallel: use Promise.all or " +
841
+ "parallelMap(items, fn, concurrency) to fan out fetches. " +
842
+ "The `nodetool` object model exposes platform tools (nodetool.workflows, " +
843
+ "nodetool.assets, …) where the host carries them — nodetool.capabilities() " +
844
+ "reports live namespaces; elsewhere a method throws naming its missing tool. " +
845
+ "A persistent `state` object survives across streaming invocations. " +
846
+ "Outputs: `await emit(name, value)` streams one value to output handle " +
847
+ "`name` right away and can be called any number of times; " +
848
+ "`await output(name, value)` records that handle's final value, delivered " +
849
+ "as one bag when the body ends. `return` is control flow only — the " +
850
+ "returned value carries no outputs. " +
851
+ "Inputs: `for await (const item of stream(name))` reads a connected " +
852
+ "handle as values arrive, `stream.any()` yields [handle, value] across " +
853
+ "all handles in arrival order, `stream.first(name)` takes one value " +
854
+ "(undefined at end of stream), and `stream.open(name)` says whether more " +
855
+ "can still arrive. A body using any of them runs once for the whole " +
856
+ "stream — waiting on input does not spend the timeout — and `inputs.*` " +
857
+ "then carries only the values configured on the node. A body that never " +
858
+ "mentions `stream` runs once per incoming item, as before. " +
859
+ "Deprecated: a body that calls neither emit nor output still runs the old " +
860
+ "contract, where a returned object's keys become output handles and " +
861
+ "yield(value) streams."
862
+ })
863
+ ], CodeNode.prototype, "code", void 0);
864
+ __decorate([
865
+ prop({
866
+ type: "list[str]",
867
+ default: [],
868
+ title: "Secrets",
869
+ description: "Secret names this code may read, e.g. [\"NOTION_API_KEY\"]. " +
870
+ "nodetool.secrets.get(name) and getSecret(name) refuse every other name, " +
871
+ "so a node that talks to one service cannot read the credentials of " +
872
+ "another. Empty means unscoped — the whole secret store, which is what a " +
873
+ "node written before scopes existed still gets."
874
+ })
875
+ ], CodeNode.prototype, "secrets", void 0);
876
+ __decorate([
877
+ prop({
878
+ type: "dict",
879
+ default: {},
880
+ title: "Script",
881
+ // The editor renders this with the script picker; an unlinked node stores
882
+ // the empty object.
883
+ json_schema_extra: { type: "js_script_link" },
884
+ description: "Which JS script version this node's body was copied from, as " +
885
+ '{"id": "<script id>", "version": <n>}. Linking materializes the pinned ' +
886
+ "version: its code, secrets and timeout become the node's own, " +
887
+ "so editing the script does not silently change this workflow and a run " +
888
+ "needs no script storage. \"Update to latest\" re-copies and re-pins. " +
889
+ "Empty means the node was never linked."
890
+ })
891
+ ], CodeNode.prototype, "script", void 0);
892
+ __decorate([
893
+ prop({
894
+ type: "int",
895
+ default: 30,
896
+ title: "Timeout",
897
+ description: "Max seconds before execution is aborted (0 = no limit)."
898
+ })
899
+ ], CodeNode.prototype, "timeout", void 0);
900
+ __decorate([
901
+ prop({
902
+ type: "int",
903
+ default: 1,
904
+ title: "Max Response Size",
905
+ description: "Megabytes of response body a single fetch() may read before it is aborted."
906
+ })
907
+ ], CodeNode.prototype, "max_response_mb", void 0);
908
+ __decorate([
909
+ prop({
910
+ type: "bool",
911
+ default: false,
912
+ title: "Allow Local Network",
913
+ description: "Permit fetch() to reach localhost and private IP ranges. Off by default — " +
914
+ "sandboxed code is untrusted, so the SSRF guard is the norm. Turn it on " +
915
+ "only for a node that has to reach a service on this machine or network."
916
+ })
917
+ ], CodeNode.prototype, "allow_local_network", void 0);
918
+ __decorate([
919
+ prop({
920
+ type: "bool",
921
+ default: false,
922
+ title: "Allow Host Filesystem",
923
+ description: "Let workspace.* reach any path the process can, with ~ expanded, instead " +
924
+ "of being confined to the workspace. Off by default — host mode can read " +
925
+ "credential files. Turn it on only for a node that has to work outside " +
926
+ "the workspace."
927
+ })
928
+ ], CodeNode.prototype, "allow_host_filesystem", void 0);
929
+ // ---------------------------------------------------------------------------
930
+ // Helpers
931
+ // ---------------------------------------------------------------------------
932
+ /**
933
+ * The body's top-level statements, or null when it does not parse.
934
+ *
935
+ * A body on the legacy `yield` contract is not valid module syntax — a
936
+ * top-level `yield` is a reserved word outside a generator — so it parses only
937
+ * after the same rewrite {@link CodeNode.runLegacyStream} applies before
938
+ * running it. Reading the imports off the rewritten body keeps a legacy
939
+ * streaming body able to import a pack.
940
+ */
941
+ function importableStatements(code) {
942
+ const parsed = parseCodeBody(code);
943
+ if (!("error" in parsed))
944
+ return parsed.statements;
945
+ const rewritten = parseCodeBody(code.replace(/\byield\b/g, "yield_"));
946
+ return "error" in rewritten ? null : rewritten.statements;
947
+ }
948
+ /** Map a guest `console.*` method onto a `log_update` severity. */
949
+ function consoleSeverity(level) {
950
+ if (level === "warn")
951
+ return "warning";
952
+ if (level === "error")
953
+ return "error";
954
+ return "info";
955
+ }
956
+ /** Extract dynamic inputs, filtering reserved/invalid keys. */
957
+ function extractDynamicInputs(inputs) {
958
+ const reserved = new Set([
959
+ "code",
960
+ "script",
961
+ // Retired: the node no longer declares packages (they come from the body's
962
+ // imports), but a graph saved before that still carries the key and must
963
+ // not turn it into a dynamic input.
964
+ "packages",
965
+ "secrets",
966
+ "timeout",
967
+ "max_response_mb",
968
+ "allow_local_network",
969
+ "allow_host_filesystem"
970
+ ]);
971
+ const result = {};
972
+ for (const [key, value] of Object.entries(inputs)) {
973
+ if (reserved.has(key) || key.startsWith("_"))
974
+ continue;
975
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key))
976
+ continue;
977
+ if (JS_RESERVED.has(key))
978
+ continue;
979
+ result[key] = value;
980
+ }
981
+ return result;
982
+ }
983
+ /**
984
+ * One value, deep-copied into JSON-safe form for the guest. The marshal rule
985
+ * for a streamed item is the buffered one: plain data crosses, anything that
986
+ * cannot be serialized becomes `null`. Media inputs are refs — plain objects —
987
+ * so they survive and stay readable through `media.*`.
988
+ */
989
+ function jsonSafe(value) {
990
+ if (value === null || value === undefined)
991
+ return value;
992
+ try {
993
+ return JSON.parse(JSON.stringify(value));
994
+ }
995
+ catch {
996
+ return null;
997
+ }
998
+ }
999
+ /**
1000
+ * Deep-copy all input values to make them safe for the sandbox.
1001
+ * Strips functions, symbols, and other non-serializable types.
1002
+ */
1003
+ function deepCopyInputs(inputs) {
1004
+ const result = {};
1005
+ for (const [key, value] of Object.entries(inputs)) {
1006
+ result[key] = jsonSafe(value);
1007
+ }
1008
+ return result;
1009
+ }
1010
+ //# sourceMappingURL=code-node.js.map