Skip to content

Commit 81a0a2e

Browse files
committed
fix(workflow): parse module syntax without raw scans
1 parent bc387a1 commit 81a0a2e

2 files changed

Lines changed: 187 additions & 20 deletions

File tree

src/workflow-script.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,31 @@ return { ok: true, name: meta.name }
3737
);
3838
}
3939

40+
{
41+
const parsed = parseWorkflowScript(`
42+
export const meta = {
43+
name: 'literal-text',
44+
description: 'Text such as noCall( and export is data, not executable syntax',
45+
}
46+
return meta.description
47+
`);
48+
assert.match(parsed.meta.description, /noCall\(/);
49+
}
50+
51+
{
52+
assert.throws(
53+
() =>
54+
parseWorkflowScript(`
55+
export const meta = { name: 'wrong-type', description: 'd', concurrency: 'many' }
56+
return 1
57+
`),
58+
(error: unknown) =>
59+
error instanceof WorkflowScriptError &&
60+
/meta\.concurrency/.test(error.message) &&
61+
!/is required/.test(error.message),
62+
);
63+
}
64+
4065
{
4166
assert.throws(
4267
() =>
@@ -109,6 +134,54 @@ return { v: 1 + 1, fromAgent: await agent('p') }
109134
assert.deepEqual(result, { v: 2, fromAgent: "agent-result" });
110135
}
111136

137+
{
138+
const result = await runBody(`
139+
export const meta = { name: 'module-words', description: 'd' }
140+
// import and export in comments are harmless
141+
const text = 'Explain export syntax and import maps'
142+
const template = \`import/export: \${text}\`
143+
const pattern = /import|export/
144+
return { text, template, matches: pattern.test(text) }
145+
`);
146+
assert.deepEqual(result, {
147+
text: "Explain export syntax and import maps",
148+
template: "import/export: Explain export syntax and import maps",
149+
matches: true,
150+
});
151+
}
152+
153+
{
154+
assert.throws(
155+
() =>
156+
parseWorkflowScript(`
157+
export const meta = { name: 'static-import', description: 'd' }
158+
import value from './value.js'
159+
return value
160+
`),
161+
(error: unknown) => error instanceof WorkflowScriptError && error.kind === "syntax",
162+
);
163+
assert.throws(
164+
() =>
165+
parseWorkflowScript(`
166+
export const meta = { name: 'extra-export', description: 'd' }
167+
export const value = 1
168+
return value
169+
`),
170+
(error: unknown) => error instanceof WorkflowScriptError && error.kind === "syntax",
171+
);
172+
}
173+
174+
{
175+
await assert.rejects(
176+
() =>
177+
runBody(`
178+
export const meta = { name: 'dynamic-import', description: 'd' }
179+
return import('node:fs')
180+
`),
181+
/dynamic import callback/i,
182+
);
183+
}
184+
112185
{
113186
await assert.rejects(
114187
() =>

src/workflow-script.ts

Lines changed: 114 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,6 @@ export function parseWorkflowScript(
4848
// Strip only the leading `export ` so line numbers stay aligned (7 spaces).
4949
const body = normalized.replace(META_EXPORT, " const meta =");
5050

51-
// Reject further imports / exports after transform
52-
if (/\bimport\s+/.test(body) || /\bexport\s+/.test(body)) {
53-
throw new WorkflowScriptError(
54-
"syntax",
55-
"Workflow scripts may not use import or additional export statements",
56-
);
57-
}
58-
5951
// Workflow APIs are installed as context-realm globals by the sandbox child.
6052
// Keeping the factory argument-free avoids handing host-realm functions or
6153
// constructors directly to model-authored workflow code.
@@ -114,27 +106,31 @@ function extractMetaLiteral(source: string): { metaLiteral: string; metaEndIndex
114106
const end = scanBalancedObject(source, objectStart);
115107
const metaLiteral = source.slice(objectStart, end + 1);
116108

117-
// Purity: no calls, spreads, templates inside meta (rough static checks)
118-
if (/[`$]/.test(metaLiteral) && /\$\{/.test(metaLiteral)) {
119-
throw new WorkflowScriptError("meta", "meta must be a pure literal (no template interpolation)");
120-
}
121-
if (/\.\.\./.test(metaLiteral)) {
122-
throw new WorkflowScriptError("meta", "meta must be a pure literal (no spreads)");
123-
}
124-
// Disallow identifier references that look like calls: word(
125-
if (/\b[A-Za-z_$][\w$]*\s*\(/.test(metaLiteral)) {
126-
throw new WorkflowScriptError("meta", "meta must be a pure literal (no function calls)");
127-
}
109+
assertPureMetaLiteral(metaLiteral);
128110

129111
return { metaLiteral, metaEndIndex: end + 1 };
130112
}
131113

132114
function scanBalancedObject(source: string, start: number): number {
133115
let depth = 0;
134116
let inString: '"' | "'" | null = null;
117+
let inLineComment = false;
118+
let inBlockComment = false;
135119
let escape = false;
136120
for (let i = start; i < source.length; i += 1) {
137121
const ch = source[i]!;
122+
const next = source[i + 1];
123+
if (inLineComment) {
124+
if (ch === "\n") inLineComment = false;
125+
continue;
126+
}
127+
if (inBlockComment) {
128+
if (ch === "*" && next === "/") {
129+
inBlockComment = false;
130+
i += 1;
131+
}
132+
continue;
133+
}
138134
if (inString) {
139135
if (escape) {
140136
escape = false;
@@ -147,6 +143,16 @@ function scanBalancedObject(source: string, start: number): number {
147143
if (ch === inString) inString = null;
148144
continue;
149145
}
146+
if (ch === "/" && next === "/") {
147+
inLineComment = true;
148+
i += 1;
149+
continue;
150+
}
151+
if (ch === "/" && next === "*") {
152+
inBlockComment = true;
153+
i += 1;
154+
continue;
155+
}
150156
if (ch === '"' || ch === "'") {
151157
inString = ch;
152158
continue;
@@ -160,6 +166,94 @@ function scanBalancedObject(source: string, start: number): number {
160166
throw new WorkflowScriptError("meta", "Unclosed meta object literal");
161167
}
162168

169+
function assertPureMetaLiteral(literal: string): void {
170+
for (let i = 0; i < literal.length; i += 1) {
171+
const ch = literal[i]!;
172+
const next = literal[i + 1];
173+
if (ch === '"' || ch === "'") {
174+
i = skipQuoted(literal, i, ch);
175+
continue;
176+
}
177+
if (ch === "/" && next === "/") {
178+
i = skipLineComment(literal, i + 2);
179+
continue;
180+
}
181+
if (ch === "/" && next === "*") {
182+
i = skipBlockComment(literal, i + 2);
183+
continue;
184+
}
185+
if (ch === "`") {
186+
throw new WorkflowScriptError("meta", "meta must be a pure literal (no templates)");
187+
}
188+
if (literal.startsWith("...", i)) {
189+
throw new WorkflowScriptError("meta", "meta must be a pure literal (no spreads)");
190+
}
191+
if (literal.startsWith("=>", i)) {
192+
throw new WorkflowScriptError("meta", "meta must be a pure literal (no functions)");
193+
}
194+
if (!/[A-Za-z_$]/.test(ch)) continue;
195+
196+
const identifierStart = i;
197+
i += 1;
198+
while (i < literal.length && /[\w$]/.test(literal[i]!)) i += 1;
199+
const identifier = literal.slice(identifierStart, i);
200+
if (identifier === "function" || identifier === "class" || identifier === "new") {
201+
throw new WorkflowScriptError("meta", "meta must be a pure literal (no executable values)");
202+
}
203+
i = skipTrivia(literal, i) - 1;
204+
if (literal[i + 1] === "(") {
205+
throw new WorkflowScriptError("meta", "meta must be a pure literal (no function calls)");
206+
}
207+
}
208+
}
209+
210+
function skipQuoted(source: string, start: number, quote: '"' | "'"): number {
211+
let escape = false;
212+
for (let i = start + 1; i < source.length; i += 1) {
213+
const ch = source[i]!;
214+
if (escape) {
215+
escape = false;
216+
continue;
217+
}
218+
if (ch === "\\") {
219+
escape = true;
220+
continue;
221+
}
222+
if (ch === quote) return i;
223+
}
224+
return source.length - 1;
225+
}
226+
227+
function skipLineComment(source: string, start: number): number {
228+
const newline = source.indexOf("\n", start);
229+
return newline < 0 ? source.length - 1 : newline;
230+
}
231+
232+
function skipBlockComment(source: string, start: number): number {
233+
const end = source.indexOf("*/", start);
234+
return end < 0 ? source.length - 1 : end + 1;
235+
}
236+
237+
function skipTrivia(source: string, start: number): number {
238+
let i = start;
239+
while (i < source.length) {
240+
if (/\s/.test(source[i]!)) {
241+
i += 1;
242+
continue;
243+
}
244+
if (source.startsWith("//", i)) {
245+
i = skipLineComment(source, i + 2) + 1;
246+
continue;
247+
}
248+
if (source.startsWith("/*", i)) {
249+
i = skipBlockComment(source, i + 2) + 1;
250+
continue;
251+
}
252+
break;
253+
}
254+
return i;
255+
}
256+
163257
function isOnlyPreamble(text: string): boolean {
164258
// strip block comments, line comments, whitespace
165259
const stripped = text
@@ -185,7 +279,7 @@ function evaluateMetaLiteral(literal: string, filename: string): unknown {
185279
}
186280

187281
function validateMeta(value: unknown): WorkflowMeta {
188-
const parsed = workflowMetaSchema.safeParse(value);
282+
const parsed = workflowMetaSchema.safeParse(value, { reportInput: true });
189283
if (parsed.success) return parsed.data;
190284

191285
const issue = parsed.error.issues[0];

0 commit comments

Comments
 (0)