@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.
- package/README.md +25 -0
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/nodes/code-node.d.ts +218 -0
- package/dist/nodes/code-node.d.ts.map +1 -0
- package/dist/nodes/code-node.js +1010 -0
- package/dist/nodes/code-node.js.map +1 -0
- package/package.json +81 -0
|
@@ -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
|