From c962d3766b1a250ba960e413556890d5919ebc94 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:28:46 +0200 Subject: [PATCH 01/39] fix: evaluate field conditions and step test inputs in QuickJS (#11487) * fix: evaluate field conditions and step test inputs in QuickJS Co-Authored-By: Claude Opus 5.5 * fix: answer single-quoted field conditions without loading the engine Co-Authored-By: Claude Opus 5.5 * fix: bound expression recursion and ignore leading blank space Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- frontend/package-lock.json | 26 ++ frontend/package.json | 2 + .../lib/components/InputTransformForm.svelte | 8 +- .../lib/components/ModulePreviewForm.svelte | 6 +- frontend/src/lib/components/ModuleTest.svelte | 4 +- frontend/src/lib/components/SchemaForm.svelte | 8 +- .../components/copilot/chat/global/core.ts | 2 + .../flows/propPicker/InputPickerInner.svelte | 5 +- .../src/lib/components/flows/utils.svelte.ts | 25 +- frontend/src/lib/utils.ts | 23 -- frontend/src/lib/utils/quickjsEval.svelte.ts | 249 ++++++++++++++++++ frontend/src/lib/utils/quickjsEval.test.ts | 87 ++++++ 12 files changed, 392 insertions(+), 53 deletions(-) create mode 100644 frontend/src/lib/utils/quickjsEval.svelte.ts create mode 100644 frontend/src/lib/utils/quickjsEval.test.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7fa720990a..0334f5e756 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@codingame/monaco-vscode-standalone-languages": "=25.0.0", "@codingame/monaco-vscode-standalone-typescript-language-features": "=25.0.0", "@floating-ui/core": "^1.3.1", + "@jitl/quickjs-wasmfile-release-sync": "^0.32.0", "@json2csv/plainjs": "^7.0.6", "@leeoniya/ufuzzy": "^1.0.8", "@melt-ui/svelte": "^0.86.2", @@ -70,6 +71,7 @@ "p-limit": "^6.1.0", "panzoom": "^9.4.3", "pdfjs-dist": "4.8.69", + "quickjs-emscripten-core": "^0.32.0", "quicktype-core": "^23.2.6", "quill": "^2.0.3", "rehype-github-alerts": "^3.0.0", @@ -1431,6 +1433,21 @@ "node": ">=18.0.0" } }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "license": "MIT" + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -11432,6 +11449,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/quickselect": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1c0eec4435..04ac888374 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -86,6 +86,7 @@ "@codingame/monaco-vscode-standalone-languages": "=25.0.0", "@codingame/monaco-vscode-standalone-typescript-language-features": "=25.0.0", "@floating-ui/core": "^1.3.1", + "@jitl/quickjs-wasmfile-release-sync": "^0.32.0", "@json2csv/plainjs": "^7.0.6", "@leeoniya/ufuzzy": "^1.0.8", "@melt-ui/svelte": "^0.86.2", @@ -136,6 +137,7 @@ "p-limit": "^6.1.0", "panzoom": "^9.4.3", "pdfjs-dist": "4.8.69", + "quickjs-emscripten-core": "^0.32.0", "quicktype-core": "^23.2.6", "quill": "^2.0.3", "rehype-github-alerts": "^3.0.0", diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index 8d758ebf71..9f8c77ce77 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -12,7 +12,7 @@ import type { Schema } from '$lib/common' import type { InputCat, DynamicInput as DynamicInputTypes } from '$lib/utils' import { createEventDispatcher, getContext, onDestroy, untrack, type Snippet } from 'svelte' - import { computeShow } from '$lib/utils' + import { computeShow, quickjsSettled } from '$lib/utils/quickjsEval.svelte' import ArgInput from './ArgInput.svelte' import FieldHeader from './FieldHeader.svelte' @@ -479,8 +479,9 @@ contextArgs[key] = otherArgValue }) - const shouldShow = computeShow(argName, schemaProperty.showExpr, contextArgs) - if (shouldShow || hasJavascript) { + const shouldShow = hasJavascript || computeShow(schemaProperty.showExpr, contextArgs) + if (shouldShow === undefined) return + if (shouldShow) { hidden = false } else if (!hidden) { hidden = true @@ -596,6 +597,7 @@ JSON.stringify(schema) JSON.stringify(arg) JSON.stringify(otherArgs) + quickjsSettled() untrack(() => handleFieldVisibility(schema, arg, otherArgs)) }) diff --git a/frontend/src/lib/components/ModulePreviewForm.svelte b/frontend/src/lib/components/ModulePreviewForm.svelte index 093661c1e8..31fb46c97a 100644 --- a/frontend/src/lib/components/ModulePreviewForm.svelte +++ b/frontend/src/lib/components/ModulePreviewForm.svelte @@ -8,6 +8,7 @@ import { getContext, untrack } from 'svelte' import type { FlowEditorContext } from './flows/types' import { evalValue } from './flows/utils.svelte' + import { quickjsReady, quickjsSettled } from '$lib/utils/quickjsEval.svelte' import { memoryPropertyFor } from './flows/flowInfers' import type { FlowModule } from '$lib/gen' import type { PickableProperties } from './flows/previousResults' @@ -155,9 +156,12 @@ loadResourceTypes() let initialized = $state(false) + quickjsReady() + // Inputs mounted before the engine can evaluate fill in their defaults, which then read as + // manual edits and win over the evaluated values. $effect.pre(() => { - if (!initialized) { + if (!initialized && quickjsSettled()) { if (stepsInputArgs) { stepsInputArgs?.updateStepArgs(mod.id, flowStateStore.val, flowStore?.val, previewArgs?.val) initialized = true diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index e35ac291c9..41375a04f6 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -21,6 +21,7 @@ import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils' import { agentTestInputTransforms } from './flows/agentFormFields' import { sendUserToast } from '$lib/toast' + import { quickjsReady } from '$lib/utils/quickjsEval.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -67,7 +68,8 @@ // that follow each flow edit stay quiet, so without this a failing expression is silently // `undefined` in what the run is built from. Manually edited args are preserved across the // refresh by `initializeFromSchema`. - export function runTestWithStepArgs() { + export async function runTestWithStepArgs() { + await quickjsReady() stepsInputArgs?.updateStepArgs( mod.id, flowStateStore.val, diff --git a/frontend/src/lib/components/SchemaForm.svelte b/frontend/src/lib/components/SchemaForm.svelte index 872ed9711e..d05c3cce72 100644 --- a/frontend/src/lib/components/SchemaForm.svelte +++ b/frontend/src/lib/components/SchemaForm.svelte @@ -4,7 +4,8 @@ const bubble = createBubbler() import type { Schema } from '$lib/common' import { VariableService } from '$lib/gen' - import { allTrue, computeShow, type DynamicInput } from '$lib/utils' + import { allTrue, type DynamicInput } from '$lib/utils' + import { computeShow, quickjsSettled } from '$lib/utils/quickjsEval.svelte' import { Button } from './common' import ItemPicker from './ItemPicker.svelte' import VariableEditor from './VariableEditor.svelte' @@ -232,7 +233,9 @@ continue } if (prop?.showExpr) { - if (computeShow(x.value, prop.showExpr, args)) { + const show = computeShow(prop.showExpr, args) + if (show === undefined) continue + if (show) { hidden[x.value] = false } else if (!hidden[x.value]) { hidden[x.value] = true @@ -277,6 +280,7 @@ for (const key in args) { args[key] } + quickjsSettled() } untrack(() => handleHiddenFields(schema, args ?? {})) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 603c0abe36..68c7be1342 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -51,6 +51,7 @@ import { withAgentDrafts } from '$lib/components/flows/linkedAgentDrafts' import { resolveLinkedAgentTools } from '$lib/components/flows/flowState' import { enabledToolNames, type AgentTool } from '$lib/components/flows/agentToolUtils' import { evalValue } from '$lib/components/flows/utils.svelte' +import { quickjsReady } from '$lib/utils/quickjsEval.svelte' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { FRAMEWORK_TEMPLATES, @@ -5951,6 +5952,7 @@ async function agentStepRunForm( .sort((a, b) => (position.get(a) ?? Infinity) - (position.get(b) ?? Infinity)) const evaluated: Record = {} + await quickjsReady() for (const key of keys) { const value = evalValue(key, module, undefined, false) if (value !== undefined) evaluated[key] = value diff --git a/frontend/src/lib/components/flows/propPicker/InputPickerInner.svelte b/frontend/src/lib/components/flows/propPicker/InputPickerInner.svelte index 1ed9be896a..b643c62f8b 100644 --- a/frontend/src/lib/components/flows/propPicker/InputPickerInner.svelte +++ b/frontend/src/lib/components/flows/propPicker/InputPickerInner.svelte @@ -4,6 +4,7 @@ import ObjectViewer from '$lib/components/propertyPicker/ObjectViewer.svelte' import { twMerge } from 'tailwind-merge' import { DollarSign, Pencil, RefreshCw, SquareFunction } from 'lucide-svelte' + import { quickjsReady } from '$lib/utils/quickjsEval.svelte' interface Props { inputTransform: Record | undefined @@ -17,7 +18,9 @@ getContext('FlowEditorContext') || {} onMount(() => { - stepsInputArgs?.updateStepArgs(id, flowStateStore?.val, flowStore?.val, previewArgs?.val) + quickjsReady().then(() => + stepsInputArgs?.updateStepArgs(id, flowStateStore?.val, flowStore?.val, previewArgs?.val) + ) }) const input = $derived(stepsInputArgs?.getStepArgs(id)) diff --git a/frontend/src/lib/components/flows/utils.svelte.ts b/frontend/src/lib/components/flows/utils.svelte.ts index 7104deb382..3efb4b30f0 100644 --- a/frontend/src/lib/components/flows/utils.svelte.ts +++ b/frontend/src/lib/components/flows/utils.svelte.ts @@ -12,6 +12,7 @@ import { import { workspaceStore } from '$lib/stores' import { cleanExpr, emptySchema } from '$lib/utils' import { unescapeTemplateBackticks } from '$lib/utils/templateLiteral' +import { evalSandboxed } from '$lib/utils/quickjsEval.svelte' import { get } from 'svelte/store' import type { FlowModuleState } from './flowState' import { type PickableProperties, dfs } from './previousResults' @@ -23,25 +24,6 @@ import type { ExtendedOpenFlow } from './types' import type { GraphModuleState } from '../graph' import type { ModulesTestStates } from '../modulesTest.svelte' -function create_context_function_template(eval_string: string, context: Record) { - return ` -return function (context) { -"use strict"; -${ - Object.keys(context).length > 0 - ? `let ${Object.keys(context).map((key) => ` ${key} = context['${key}']`)};` - : `` -} -return ${eval_string} -}` -} - -function make_context_evaluator(eval_string, context): (context) => any { - let template = create_context_function_template(eval_string, context) - let functor = Function(template) - return functor() -} - export function evalValue( k: string, mod: FlowModule, @@ -56,11 +38,10 @@ export function evalValue( v = t.value } else if (t?.type == 'javascript') { try { - let context = { + v = evalSandboxed(t.expr, { flow_input: pickableProperties?.flow_input, results: pickableProperties?.priorIds - } - v = make_context_evaluator(t.expr, context)(context) + }) } catch (e) { if (showError) { sendUserToast(`Error evaluating ${k}: ${e.message}`, true) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 23dbacea21..b771b4966e 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1500,29 +1500,6 @@ export function orderedJsonStringify(obj: any, space?: string | number) { return JSON.stringify(obj, (Array.from(allKeys) as string[]).sort(), space) } -function evalJs(expr: string) { - let template = ` -return function (fields) { -"use strict"; -return ${expr.startsWith('return ') ? expr.substring(7) : expr} -} -` - let functor = Function(template) - return functor() -} -export function computeShow(argName: string, expr: string | undefined, args: any) { - if (expr) { - try { - let r = evalJs(expr)(args ?? {}) - return r - } catch (e) { - console.error(`Impossible to eval ${expr}:`, e) - return true - } - } - return true -} - function urlizeTokenInternal(token: string, formatter: 'html' | 'md'): string { if (token.startsWith('http://') || token.startsWith('https://')) { if (formatter == 'html') { diff --git a/frontend/src/lib/utils/quickjsEval.svelte.ts b/frontend/src/lib/utils/quickjsEval.svelte.ts new file mode 100644 index 0000000000..d7ae2002af --- /dev/null +++ b/frontend/src/lib/utils/quickjsEval.svelte.ts @@ -0,0 +1,249 @@ +/** + * Runs the JS expressions a schema or flow carries (a field's `showExpr`, a step input's test + * prefill). They are written by whoever can edit the item but run in the browser of whoever + * opens it, so they must not run in the page: there they would act with the viewer's session. + * + * They run in QuickJS compiled to WebAssembly, a separate engine with no page, storage, cookie or + * network to reach. Each evaluation gets a fresh runtime, bounded in time and memory. Values cross + * as JSON both ways. + */ +import type { QuickJSWASMModule } from 'quickjs-emscripten-core' + +const TIMEOUT_MS = 1000 +// WebAssembly memory never shrinks, so one greedy expression would keep the engine's peak forever. +const MEMORY_LIMIT = 256 * 1024 * 1024 +// The engine runs on the page's own stack. Past this, it raises a catchable "stack overflow"; +// without it, the page's stack overflows mid-step and leaves the runtime unfreeable. 192 KB already +// lets deeply nested JSON through to the page's limit. +const STACK_LIMIT = 128 * 1024 + +let engine = $state.raw() +let failed = $state(false) +let loading: Promise | undefined + +/** Resolves once expressions can be evaluated; starts loading the engine on first call. */ +export function quickjsReady(): Promise { + loading ??= Promise.all([ + import('quickjs-emscripten-core'), + import('@jitl/quickjs-wasmfile-release-sync'), + import('@jitl/quickjs-wasmfile-release-sync/wasm?url') + ]) + .then(([core, variant, wasm]) => + core.newQuickJSWASMModuleFromVariant( + // Outside a browser (tests) the URL is not fetchable; the variant reads its own file. + import.meta.env.SSR + ? variant.default + : core.newVariant(variant.default, { wasmLocation: wasm.default }) + ) + ) + .then( + (m) => (engine = m), + (e) => { + console.error('Could not load the expression engine', e) + failed = true + } + ) + return loading +} + +/** Whether `evalSandboxed` can answer; reactive. Starts no load: `quickjsReady` does. */ +export function quickjsSettled(): boolean { + return engine !== undefined || failed +} + +// Copying into the engine costs about 10ms per MB on the page thread, on every evaluation. +const MAX_TEXT = 1024 * 1024 +const MAX_COPY = 5 * 1024 * 1024 + +const IDENT = '[A-Za-z_$][\\w$]*' +// Only escapes JSON accepts: `literal` reads the match with `JSON.parse`, and `"\x41"` is left to +// the engine. +const STRING = String.raw`(?:"(?:[^"\\\u0000-\u001f]|\\["\\/bfnrt]|\\u[0-9a-fA-F]{4})*"|'(?:[^'\\\u0000-\u001f]|\\['"\\/bfnrt]|\\u[0-9a-fA-F]{4})*')` +const KEY = `\\s*(?:\\.\\s*(${IDENT})|\\[\\s*(${STRING})\\s*\\])` +const ANY_KEY = `\\s*(?:\\.\\s*${IDENT}|\\[\\s*${STRING}\\s*\\])` +const LOOKUP = new RegExp( + `^\\s*(${IDENT})((?:${ANY_KEY})*)\\s*` + + `(?:(===|!==|==|!=)\\s*(${STRING}|-?(?:0|[1-9]\\d*)(?:\\.\\d+)?|true|false|null)\\s*)?;?\\s*$` +) + +/** What `expr` returns. A leading line break would otherwise follow `return` and end the + * statement, while `LOOKUP` skips it: the two paths would disagree. */ +function bodyOf(expr: string): string { + return (expr.startsWith('return ') ? expr.slice(7) : expr).trimStart() +} + +/** A literal `LOOKUP` matched; a single-quoted string is rewritten as JSON first. */ +function literal(text: string): unknown { + if (text[0] !== "'") return JSON.parse(text) + const body = text + .slice(1, -1) + .replace(/\\'|\\.|"/g, (m) => (m === "\\'" ? "'" : m === '"' ? '\\"' : m)) + return JSON.parse(`"${body}"`) +} + +/** + * Answers `name.key["key"]…`, optionally compared to a JSON literal, straight from `scope`: reading + * data and comparing it to a constant runs no code, so it needs no sandbox and copies nothing. + * Anything else, including a key that is not the data's own, is left to the engine. + */ +function cheapAnswer(body: string, scope: Record): { value: unknown } | undefined { + // `LOOKUP` backtracks quadratically on long malformed input, and runs before the engine's time + // limit: an author could freeze the viewer's page. Real conditions are far shorter. + if (body.length > 1000) return undefined + const m = LOOKUP.exec(body) + if (!m || !Object.hasOwn(scope, m[1])) return undefined + let value = scope[m[1]] + for (const k of m[2].matchAll(new RegExp(KEY, 'g'))) { + const key = k[1] ?? (literal(k[2]) as string) + if (value === null || typeof value !== 'object' || !Object.hasOwn(value, key)) return undefined + value = value[key] + } + if (!m[3]) return { value } + if (value !== null && typeof value === 'object') return undefined + const other = literal(m[4]) + const equal = m[3].length === 3 ? value === other : value == other + return { value: m[3].startsWith('=') ? equal : !equal } +} + +/** Text over `MAX_TEXT`, such as an uploaded file, crosses as this instead of its content. */ +function tooLarge(text: string) { + return { tooLarge: true, length: text.length, size: `${(text.length / 2 ** 20).toFixed(1)} MB` } +} + +/** What is left of `budget` once `value` is copied in; stops counting below zero. */ +function remaining(value: unknown, budget: number, placeholders: boolean): number { + if (typeof value === 'string') { + return budget - (placeholders && value.length > MAX_TEXT ? 64 : value.length + 2) + } + if (value === null || typeof value !== 'object') return budget - 8 + for (const key in value) { + budget = remaining(value[key], budget - key.length - 4, placeholders) + if (budget < 0) break + } + return budget +} + +/** `value` as JSON for the engine, refused past `MAX_COPY`. */ +function copyIn(value: unknown, name: string, placeholders: boolean): string { + if (remaining(value, MAX_COPY, placeholders) < 0) { + throw new Error(`${name} is over 5 MB, too large to evaluate in the browser: enter it by hand`) + } + return ( + JSON.stringify(value, (_, v) => + placeholders && typeof v === 'string' && v.length > MAX_TEXT ? tooLarge(v) : v + ) ?? 'undefined' + ) +} + +// Each name an object holds becomes a read-only shell that copies an entry in when it is read, as +// the backend's `results` proxy does: a large step result or upload costs only the expressions +// that read it, and what an expression reads is never guessed from its text. +const SHELLS = `"use strict" +// JSON has no undefined, so the host sends it as this word. +const __decode = (json) => (json === 'undefined' ? undefined : JSON.parse(json)) +function __shell(name) { + const keys = JSON.parse(__keys(name)) + if (keys === null) return __decode(__whole(name)) + const loaded = new Map() + const read = (k) => { + if (!loaded.has(k)) loaded.set(k, __decode(__entry(name, k))) + return loaded.get(k) + } + return new Proxy({}, { + get: (_, k) => (typeof k === 'string' && keys.includes(k) ? read(k) : Object.prototype[k]), + has: (_, k) => keys.includes(k) || k in Object.prototype, + ownKeys: () => keys, + getOwnPropertyDescriptor: (_, k) => + keys.includes(k) ? { get: () => read(k), enumerable: true, configurable: true } : undefined, + set: () => false, + defineProperty: () => false, + deleteProperty: () => false + }) +} +` + +/** + * Evaluates `expr` with the keys of `scope` as its only names. Throws on any error, and while the + * engine is loading: read it in an effect, which re-runs once the engine is in. `placeholders` + * swaps text over 1 MB for its size, for callers that only take the answer's truthiness: a step + * input would keep the placeholder as its value. + */ +export function evalSandboxed( + expr: string, + scope: Record, + placeholders = false +): any { + const body = bodyOf(expr) + const cheap = cheapAnswer(body, scope) + if (cheap) return cheap.value + quickjsReady() + if (!engine) { + throw new Error(`The expression engine ${failed ? 'failed to load' : 'is still loading'}`) + } + const rt = engine.newRuntime() + rt.setMemoryLimit(MEMORY_LIMIT) + rt.setMaxStackSize(STACK_LIMIT) + const deadline = Date.now() + TIMEOUT_MS + rt.setInterruptHandler(() => Date.now() > deadline) + const vm = rt.newContext() + try { + const host: Record string> = { + __keys: (name: string) => { + const v = scope[name] + return v && typeof v === 'object' && !Array.isArray(v) + ? JSON.stringify(Object.keys(v)) + : 'null' + }, + __whole: (name: string) => copyIn(scope[name], name, placeholders), + __entry: (name: string, key: string) => + copyIn((scope[name] as Record)[key], `${name}.${key}`, placeholders) + } + for (const [fnName, fn] of Object.entries(host)) { + // The interrupt handler runs only between engine steps, not during these copies. + const handle = vm.newFunction(fnName, (...args) => { + if (Date.now() > deadline) throw new Error('Interrupted') + return vm.newString(fn(...args.map((a) => vm.getString(a)))) + }) + vm.setProp(vm.global, fnName, handle) + handle.dispose() + } + const names = Object.keys(scope) + const code = `${SHELLS} +${names.map((n) => `const ${n} = __shell(${JSON.stringify(n)})`).join('\n')} +;(() => { +return ${body} +})()` + const result = vm.unwrapResult(vm.evalCode(code)) + try { + return vm.dump(result) + } finally { + result.dispose() + } + } finally { + // A runtime interrupted by the page's own stack overflow fails its leak check here, and that + // error would replace the one the expression raised. Its memory is lost either way. + try { + vm.dispose() + rt.dispose() + } catch {} + } +} + +/** + * Whether a field with `expr` as its `showExpr` is shown for `args`, or undefined while the engine + * loads. Undefined means decide nothing: hiding a field deletes its value. + */ +export function computeShow(expr: string | undefined, args: any): boolean | undefined { + if (!expr) return true + const scope = { fields: args ?? {} } + const cheap = cheapAnswer(bodyOf(expr), scope) + if (cheap) return Boolean(cheap.value) + quickjsReady() + if (!quickjsSettled()) return undefined + try { + return Boolean(evalSandboxed(expr, scope, true)) + } catch (e) { + console.error(`Impossible to eval ${expr}:`, e) + return true + } +} diff --git a/frontend/src/lib/utils/quickjsEval.test.ts b/frontend/src/lib/utils/quickjsEval.test.ts new file mode 100644 index 0000000000..505ecc1b6e --- /dev/null +++ b/frontend/src/lib/utils/quickjsEval.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { computeShow, evalSandboxed, quickjsReady } from './quickjsEval.svelte' + +describe('quickjsEval', () => { + it('answers plain lookups and comparisons without the engine', () => { + const results = { 'my step': { rows: [1, 2] }, a: { mode: 'x' } } + expect(computeShow('fields.mode === "advanced"', { mode: 'advanced' })).toBe(true) + expect(computeShow('return fields.n != 0', { n: 0 })).toBe(false) + expect(evalSandboxed('results["my step"].rows', { results })).toBe(results['my step'].rows) + expect(evalSandboxed('results.a.mode == "x"', { results })).toBe(true) + expect(computeShow("fields.output_type !== 'image'", { output_type: 'image' })).toBe(false) + expect(computeShow(`fields['q'] === 'it\\'s "x"'`, { q: `it's "x"` })).toBe(true) + const started = performance.now() + expect(computeShow('fields' + ' '.repeat(30_000) + '!', {})).toBeUndefined() + expect(performance.now() - started).toBeLessThan(50) + }) + + it('decides nothing until the engine is in', async () => { + expect(computeShow('fields.a + 1 === 2', { a: 1 })).toBeUndefined() + expect(computeShow('fields.constructor', { a: 1 })).toBeUndefined() + await quickjsReady() + expect(computeShow('fields.a + 1 === 2', { a: 1 })).toBe(true) + expect(computeShow('\nfields.a + 1 === 2', { a: 1 })).toBe(true) + expect(computeShow('fields.x === "\\x41"', { x: 'A' })).toBe(true) + expect(computeShow('fields["\\x41"] === 1', { A: 1 })).toBe(true) + expect(computeShow('fields.', {})).toBe(true) + expect(computeShow('fields.n === 01', { n: 2 })).toBe(true) + }) + + it('reaches nothing of the page and stops runaway expressions', async () => { + await quickjsReady() + const scope = { fields: {} } + expect(evalSandboxed('typeof window + typeof document + typeof fetch', scope)).toBe( + 'undefinedundefinedundefined' + ) + expect(() => evalSandboxed('(() => { while (true) {} })()', scope)).toThrow() + expect(evalSandboxed('1 + 1', scope)).toBe(2) + expect(evalSandboxed('1 + 1', {})).toBe(2) + expect(() => + evalSandboxed('(() => { function f() { return f() } return f() })()', scope) + ).toThrow(/stack overflow/) + const rows = Array.from({ length: 110_000 }, (_, i) => `item-${i}-xxxxxx`) + const started = performance.now() + expect(() => + evalSandboxed('(() => { for (let i = 0; i < 300; i++) __entry("r", "rows") })()', { + r: { rows } + }) + ).toThrow() + expect(performance.now() - started).toBeLessThan(3000) + }) + + it('copies in only the entries an expression reads', async () => { + await quickjsReady() + const results = { + a: { x: 1 }, + b: { y: 2 }, + big: { rows: Array.from({ length: 300_000 }, (_, i) => ({ i, s: 'xxxxxxxxxx' })) } + } + expect(evalSandboxed('`${results.a.x}`', { results })).toBe('1') + expect(evalSandboxed('((k) => results[k].y)("b")', { results })).toBe(2) + expect(evalSandboxed('Object.keys(results).length', { results })).toBe(3) + expect(evalSandboxed('({ ...small }).b.y', { small: { a: results.a, b: results.b } })).toBe(2) + expect(evalSandboxed('results.hasOwnProperty("a") && "a" in results', { results })).toBe(true) + expect(evalSandboxed('JSON.stringify({ a: results.a, b: results.b })', { results })).toBe( + '{"a":{"x":1},"b":{"y":2}}' + ) + expect(() => evalSandboxed('results.big.rows.length + 0', { results })).toThrow( + /results.big is over 5 MB/ + ) + expect(() => evalSandboxed('(results.a = 1)', { results })).toThrow() + expect(evalSandboxed('"gpt-" + 4', { flow_input: undefined, results: undefined })).toBe('gpt-4') + expect( + evalSandboxed('fields.optional ?? "fallback"', { fields: { optional: undefined } }) + ).toBe('fallback') + }) + + it('stands in for large text only where the answer is a yes or no', async () => { + await quickjsReady() + const file = 'x'.repeat(2 * 2 ** 20) + expect(computeShow('fields.file.tooLarge && fields.file.length > 0', { file })).toBe(true) + expect(evalSandboxed('flow_input.file || ""', { flow_input: { file } })).toBe(file) + const huge = 'x'.repeat(6 * 2 ** 20) + expect(() => evalSandboxed('flow_input.file || ""', { flow_input: { file: huge } })).toThrow( + /flow_input.file is over 5 MB/ + ) + }) +}) From f7c7d7ffd874533200b96823db891cf6b7114f85 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 5 Oct 2026 12:33:01 +0200 Subject: [PATCH 02/39] refactor: use the session tab strip for workflow sdk module tabs (#11522) * refactor: use the session tab strip for workflow sdk module tabs Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep module deletion on the explicit button and tab order view-only Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: use the design-system button for module deletion Co-Authored-By: Claude Opus 5.5 (1M context) * fix: let modified keys through file tabs and keep keyboard rename off the tab Co-Authored-By: Claude Opus 5.5 (1M context) * fix: route modified enter past the dnd zone so page shortcuts fire from draggable tabs Co-Authored-By: Claude Opus 5.5 (1M context) * fix: forward modified enter from dnd item wrappers too Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/lib/components/ScriptEditor.svelte | 154 ++++++++++-------- .../common/tabs/DraggableTabs.svelte | 22 ++- 2 files changed, 103 insertions(+), 73 deletions(-) diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index e3215aa9cf..1cabcf8265 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -81,6 +81,7 @@ import type { ScriptEditorWhitelabelCustomUi } from './custom_ui' import Tabs from './common/tabs/Tabs.svelte' import Tab from './common/tabs/Tab.svelte' + import DraggableTabs, { type TabItem } from './common/tabs/DraggableTabs.svelte' import { slide } from 'svelte/transition' import CaptureTable from '$lib/components/triggers/CaptureTable.svelte' import CaptureButton from './triggers/CaptureButton.svelte' @@ -407,6 +408,30 @@ let supportsModules = $derived((lang === 'bun' || lang === 'python3') && isWacV2) let mainFileName = $derived('script.' + langToExt(scriptLangToEditorLang(lang))) + // A module path always ends in a file extension, so this id can't collide with one. + const MAIN_FILE_TAB_ID = '__main__' + // Tab order is view state only: `modules` is stored as JSONB, which re-sorts its keys on + // deploy, so an order written into it would not survive. + let moduleTabOrder: string[] = $state([]) + let fileTabs = $derived.by(() => { + const paths = Object.keys(modules ?? {}) + const rank = new Map(paths.map((p, i) => [p, i + moduleTabOrder.length])) + moduleTabOrder.forEach((p, i) => rank.has(p) && rank.set(p, i)) + return [ + { id: MAIN_FILE_TAB_ID, label: mainFileName, closable: false, pinned: 'left' }, + // Not closable through the strip: closing deletes the module's code, so it stays on + // the explicit × rather than Delete/Backspace and middle-click. + ...paths + .sort((a, b) => rank.get(a)! - rank.get(b)!) + .map((modulePath) => ({ + id: modulePath, + label: modulePath, + title: modulePath, + closable: false + })) + ] + }) + let modulePathInput = $state('') let showAddModulePopover = $state(false) let modulePathInputEl: HTMLInputElement | undefined = $state(undefined) @@ -561,6 +586,7 @@ delete modules[modulePath] delete moduleTestState[modulePath] modules = { ...modules } + moduleTabOrder = moduleTabOrder.filter((p) => p !== modulePath) } function validateRenameModulePath(newPath: string, oldPath: string): string { @@ -591,6 +617,11 @@ return 'path' in canonical && canonical.path === oldPath } + function prefillRename(modulePath: string) { + renameModuleInput = modulePath + renameModuleError = '' + } + function renameModule(oldPath: string) { if (!renameModuleInput.trim()) return const canonical = canonicalModulePath(renameModuleInput) @@ -611,6 +642,7 @@ delete modules[oldPath] modules[newPath] = { ...mod, language: newLang ?? mod.language } modules = { ...modules } + moduleTabOrder = moduleTabOrder.map((p) => (p === oldPath ? newPath : p)) if (moduleTestState[oldPath]) { moduleTestState[newPath] = moduleTestState[oldPath] delete moduleTestState[oldPath] @@ -2563,93 +2595,71 @@ {#snippet editorContent()}
{#if supportsModules} -
(id === MAIN_FILE_TAB_ID ? switchToMain() : switchToModule(id))} + onReorder={(next) => (moduleTabOrder = next.map((t) => t.id))} + class="shrink-0 border-b border-light bg-surface-secondary/50" > - - {#each Object.keys(modules ?? {}) as modulePath} -
- -
+ {#snippet tabAccessory(tab)} + {#if tab.id !== MAIN_FILE_TAB_ID} + + e.stopPropagation()}> prefillRename(tab.id), + onkeydown: (e: KeyboardEvent) => + (e.key === 'Enter' || e.key === ' ') && prefillRename(tab.id) + }} > {#snippet trigger()} { - e.stopPropagation() - renameModuleInput = modulePath - renameModuleError = '' - }} - onkeydown={(e) => { - if (e.key === 'Enter') { - e.stopPropagation() - renameModuleInput = modulePath - renameModuleError = '' - } - }} + class="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 rounded hover:bg-surface-hover w-4 h-4 inline-flex items-center justify-center" > - + {/snippet} {#snippet content({ close })} - {@render renameModuleForm(modulePath, close)} + {@render renameModuleForm(tab.id, close)} {/snippet} - { - e.stopPropagation() - removeModule(modulePath) - }} - onkeydown={(e) => { - if (e.key === 'Enter') { - e.stopPropagation() - removeModule(modulePath) - } - }} - > - - -
-
- {/each} - - {#snippet trigger()} - - - {/snippet} - {#snippet content({ close })} - {@render addModuleForm(close)} - {/snippet} - -
+
- {/if} - {/each} + ... + + ...... + + {row.count} {row.kind === 'collapsed' ? 'unchanged lines' : 'more lines'} + + {:else} +
+ {row.oldLine ?? ''} + + {row.newLine ?? ''}{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''} + + + {#each row.changedRanges ?? [] as range} + + {/each} + {@html highlightedLine(row) || ' '} + +
+ {/if} + {/each} +