Repository navigation
fix(core): bound the workflow meta evaluation #9136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
4d249cc
8a4b227
aec7704
e49a3b5
908ea66
ab0ba69
c09ce9b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -266,6 +266,210 @@ describe('extractAndStripMeta', () => { | |
| expect(() => extractAndStripMeta(src)).toThrow(/unbalanced/i); | ||
| }); | ||
|
|
||
| // The meta literal is model-authored source, and every caller reaches it on | ||
| // a path where a wedged thread is unrecoverable: the run path (before the | ||
| // sandbox's own 30s body timeout is armed) and, in follow-up work, the tool | ||
| // confirmation dialog and the saved-workflow palette. A field value that | ||
| // never returns must surface as an ordinary malformed-meta error. | ||
| // | ||
| // Each case asserts a generous wall-clock bound rather than a precise one, | ||
| // so the assertion stays stable on a loaded CI runner. Without the bound | ||
| // these hang the worker until vitest's own timeout kills it. | ||
| describe('bounded evaluation', () => { | ||
| const BOUND_MS = 5_000; | ||
|
|
||
| function timed(fn: () => unknown): number { | ||
| const startedAt = Date.now(); | ||
| try { | ||
| fn(); | ||
| } catch { | ||
| /* the throw is asserted separately */ | ||
| } | ||
| return Date.now() - startedAt; | ||
| } | ||
|
|
||
| it('bounds a field value that loops on evaluation', () => { | ||
| const src = `export const meta = { name: (function () { while (true) {} })(), description: 'd' }\nreturn 1`; | ||
| expect(() => extractAndStripMeta(src)).toThrow( | ||
| /failed to evaluate meta object literal/, | ||
| ); | ||
| expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); | ||
| }); | ||
|
|
||
| // The case a timeout on the literal's own evaluation does NOT catch: a | ||
| // getter defers its work to property-read time, so the literal itself | ||
| // evaluates instantly and only spins when the value is walked. Walking on | ||
| // the host would run it on the host thread, unbounded. | ||
| it('bounds a getter that loops when the value is walked', () => { | ||
| const src = `export const meta = { name: 'x', description: 'd', get phases() { while (true) {} } }\nreturn 1`; | ||
| expect(() => extractAndStripMeta(src)).toThrow( | ||
| /failed to evaluate meta object literal/, | ||
| ); | ||
| expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); | ||
| }); | ||
|
|
||
| it('bounds a getter nested inside phases', () => { | ||
| const src = `export const meta = { name: 'x', description: 'd', phases: [{ get title() { while (true) {} } }] }\nreturn 1`; | ||
| expect(() => extractAndStripMeta(src)).toThrow( | ||
| /failed to evaluate meta object literal/, | ||
| ); | ||
| expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); | ||
| }); | ||
|
|
||
| it('refuses a meta literal that exceeds the serialized size cap', () => { | ||
| const src = `export const meta = { name: 'x'.repeat(200000), description: 'd' }\nreturn 1`; | ||
| expect(() => extractAndStripMeta(src)).toThrow( | ||
| /failed to evaluate meta object literal/, | ||
| ); | ||
| }); | ||
|
|
||
| it('enforces the cap after JSON escaping', () => { | ||
| const src = `export const meta = { name: '\\0'.repeat(20000), description: 'd' }\nreturn 1`; | ||
| expect(() => extractAndStripMeta(src)).toThrow( | ||
| /failed to evaluate meta object literal/, | ||
| ); | ||
| }); | ||
|
|
||
| it('enforces the cap for string-free containers', () => { | ||
| const src = `export const meta = { name: 'x', description: 'd', phases: Array.from({ length: 40000 }, () => ({})) }\nreturn 1`; | ||
| expect(() => extractAndStripMeta(src)).toThrow( | ||
| /failed to evaluate meta object literal/, | ||
| ); | ||
| }); | ||
|
|
||
| it('bounds a looping then getter', () => { | ||
| const src = `export const meta = { name: 'x', description: 'd', extra: { get then() { while (true) {} } } }\nreturn 1`; | ||
| expect(() => extractAndStripMeta(src)).toThrow( | ||
| /failed to evaluate meta object literal/, | ||
| ); | ||
| expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); | ||
| }); | ||
|
|
||
| it('does not invoke a phases iterator', () => { | ||
| const src = `export const meta = { | ||
| name: 'x', | ||
| description: 'd', | ||
| phases: Object.assign([{ title: 'one' }], { | ||
| [Symbol.iterator]: function () { while (true) {} }, | ||
| }), | ||
| }\nreturn 1`; | ||
| expect(extractAndStripMeta(src).meta?.phases).toEqual([{ title: 'one' }]); | ||
| }); | ||
|
|
||
| it('leaves a well-formed meta literal unaffected', () => { | ||
| const src = `export const meta = { name: 'w', description: 'd', phases: [{ title: 'One' }] }\nreturn 1`; | ||
| const { meta } = extractAndStripMeta(src); | ||
| expect(meta).toEqual({ | ||
| name: 'w', | ||
| description: 'd', | ||
| phases: [{ title: 'One' }], | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| // The literal is evaluated as its own program, so it never shares a lexical | ||
| // scope with the serializer that walks it. Interpolating it into the | ||
| // serializer's scope would let it read the helpers and overwrite the flag | ||
| // that decides whether a thenable was found — i.e. disarm the check that | ||
| // keeps a stray rejected Promise from killing the host process. | ||
| it('meta source cannot observe or mutate the serializer scope', () => { | ||
| const src = `export const meta = { name: String(typeof copy) + ':' + String(typeof hasThenable), description: 'd' }\nreturn 1`; | ||
| const { meta } = extractAndStripMeta(src); | ||
| expect(meta?.name).toBe('undefined:undefined'); | ||
| }); | ||
|
|
||
| it('a thenable stays rejected even when the literal predefines the flag name', () => { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已修复:测试 fixture 现显式设置 hasThenable: false,覆盖预定义标志仍不能隐藏 Promise 的场景。验证:workflow-sandbox.test.ts 167/167 通过。 |
||
| const src = `export const meta = { name: 'x', description: 'd', hasThenable: false, phases: Promise.resolve(1) }\nreturn 1`; | ||
| expect(() => extractAndStripMeta(src)).toThrow( | ||
| /meta values must not be Promises/, | ||
| ); | ||
| }); | ||
|
|
||
| it('isolates serializer intrinsics from the meta literal', () => { | ||
| const src = `export const meta = { | ||
| name: (JSON.stringify = () => ({ toString() { return '@'; } }), 'x'), | ||
| description: 'd', | ||
| }\nreturn 1`; | ||
| expect(extractAndStripMeta(src).meta).toEqual({ | ||
| name: 'x', | ||
| description: 'd', | ||
| }); | ||
| }); | ||
|
|
||
| it('rejects Promises after the literal mutates serializer helpers', () => { | ||
| const src = `export const meta = { | ||
| name: ( | ||
| Object.keys = () => ['name', 'description'], | ||
| Promise.prototype.then = () => undefined, | ||
| 'x' | ||
| ), | ||
| description: 'd', | ||
| extra: Promise.resolve(1), | ||
| }\nreturn 1`; | ||
| expect(() => extractAndStripMeta(src)).toThrow( | ||
| /meta values must not be Promises/, | ||
| ); | ||
| }); | ||
|
|
||
| it('does not expose host helpers to serializer getters', () => { | ||
| const src = `export const meta = { | ||
| name: 'x', | ||
| description: 'd', | ||
| extra: Object.defineProperty({}, 'value', { enumerable: true, get: function () { | ||
| const serializerGlobal = arguments.callee.caller.constructor('return globalThis')(); | ||
| if (serializerGlobal.__qwenWorkflowMetaIsPromise) throw new Error('host helper exposed'); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已修复。验证证据:workflow-sandbox 183/183 通过;隔离探测现检查 serializer realm 的 copy helper。 |
||
| return 'safe'; | ||
| } }), | ||
| }\nreturn 1`; | ||
| expect(extractAndStripMeta(src).meta).toEqual({ | ||
| name: 'x', | ||
| description: 'd', | ||
| }); | ||
| }); | ||
|
|
||
| it('prefers a Promise error after the size budget is exceeded', () => { | ||
| const src = `export const meta = { | ||
| name: 'x'.repeat(200000), | ||
| description: 'd', | ||
| extra: Promise.resolve(1), | ||
| }\nreturn 1`; | ||
| expect(() => extractAndStripMeta(src)).toThrow( | ||
| /meta values must not be Promises/, | ||
| ); | ||
| }); | ||
|
|
||
| it('copies shared phase objects at each array position', () => { | ||
| const src = `export const meta = { | ||
| name: 'x', | ||
| description: 'd', | ||
| phases: (function () { | ||
| const phase = { title: 'one' }; | ||
| return [phase, phase]; | ||
| })(), | ||
| }\nreturn 1`; | ||
| expect(extractAndStripMeta(src).meta?.phases).toEqual([ | ||
| { title: 'one' }, | ||
| { title: 'one' }, | ||
| ]); | ||
| }); | ||
|
|
||
| it.each([ | ||
| [ | ||
| `export const meta = { name: 'x', description: 'd', whenToUse: () => {} }\nreturn 1`, | ||
| /meta.whenToUse must be a string/, | ||
| ], | ||
| [ | ||
| `export const meta = { name: 'x', description: 'd', phases: [{ title: 'one', detail: Symbol('x') }] }\nreturn 1`, | ||
| /meta.phases\[\].detail must be a string/, | ||
| ], | ||
| [ | ||
| `export const meta = { name: 'x', description: 'd', phases: [{ title: 'one', model: 1n }] }\nreturn 1`, | ||
| /meta.phases\[\].model must be a string/, | ||
| ], | ||
| ])('preserves invalid optional field types for validation', (src, error) => { | ||
| expect(() => extractAndStripMeta(src)).toThrow(error); | ||
| }); | ||
|
|
||
| // P4a adversarial review (HIGH × 3 lenses): the docstring at | ||
| // workflow-sandbox.ts:283-294 promises the returned meta is HOST-realm — | ||
| // a per-field copy that defends against T1/T8/T14-style vm-realm escape | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
已修复:新增无限循环 then getter 的超时回归,以及 phases 自定义 Symbol.iterator 不被调用的回归。验证:workflow-sandbox.test.ts 167/167 通过。