Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions packages/core/src/agents/runtime/workflow-sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +294 to +295

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R5-1: BOUND_MS = 5_000 is looser than the child outer kill (META_CHILD_TIMEOUT_MS = 2_000), so no test pins the 250ms per-script vm timeout (META_EVAL_TIMEOUT_MS) — verified by mutation: with META_EVAL_TIMEOUT_MS = 1900 all 17 bounded-evaluation tests still pass, per-case durations swelling from ~565ms to 3,866–3,881ms. — Failure scenario: a future refactor drops or inflates timeout: META_EVAL_TIMEOUT_MS; every malformed meta literal then blocks the calling thread ~2–3.9s (outer SIGKILL + error-path drain) instead of ~0.25–0.6s on the run path, and CI stays green.

Fix: for the cases resolved by the vm timeout (all except the native-builtin case, which intentionally exercises the outer kill), assert a bound between the two timeouts, e.g. expect(timed(...)).toBeLessThan(1_500) — generous vs the production worst case (~2×250ms + spawn overhead ≈ 0.6s) but below META_CHILD_TIMEOUT_MS, so losing the per-script timeout turns the suite red.

中文说明

BOUND_MS = 5_000 比子进程外层强杀(META_CHILD_TIMEOUT_MS = 2_000)更宽松,因此没有任何测试固定 250ms 的单脚本 vm 超时(META_EVAL_TIMEOUT_MS)——变异验证:把 META_EVAL_TIMEOUT_MS 改为 1900 后,17 个 bounded-evaluation 测试全部通过,单例耗时从约 565ms 膨胀到 3,866–3,881ms。— 故障场景:未来重构删除或调大 timeout: META_EVAL_TIMEOUT_MS 后,每个恶意/畸形 meta 字面量都会在运行路径上阻塞调用线程约 2–3.9 秒(外层 SIGKILL + 错误路径排空),而不是约 0.25–0.6 秒,且 CI 保持绿色。

修复:对由 vm 超时收口的用例(除刻意验证外层强杀的 native builtin 用例外)断言一个介于两层超时之间的上界,例如 expect(timed(...)).toBeLessThan(1_500)——相对生产最坏情况(约 2×250ms + 启动开销 ≈ 0.6s)留有余量,又低于 META_CHILD_TIMEOUT_MS,这样单脚本超时一旦丢失套件即红。

— qwen3.8-max via Qwen Code /review (v0.21.12)


function timed(fn: () => unknown): number {
const startedAt = Date.now();
try {
fn();
} catch {
Comment on lines +297 to +301

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R6-1: timed() forces a second full extractAndStripMeta invocation (a second child-process spawn) purely to measure wall time; the same describe already uses a cheaper single-invocation inline pattern. — Failure scenario: every test calling expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS) spawns the isolated evaluator child twice per run — probe-measured at this commit: 563-576ms each for the ten timed tests vs 285ms after inlining the measurement on one test; roughly double the wall time for those ten tests on every run, and each extra spawn is an independent sample of CI startup jitter against the BOUND_MS bound the tests exist to pin.

witness: unmodified tree: 10 timed tests 563-576ms each; inline-measurement fix on one test: 563ms → 285ms, still passes.

const startedAt = Date.now();
expect(() => extractAndStripMeta(src)).toThrow(/failed to evaluate meta object literal/);
expect(Date.now() - startedAt).toBeLessThan(BOUND_MS);
中文说明

timed() 仅为测量墙钟时间就强制执行第二次完整的 extractAndStripMeta(第二次子进程 spawn);同一 describe 已有更便宜的单次调用内联模式。— 故障场景:每个调用 expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS) 的测试每次运行都 spawn 两次隔离求值子进程——本提交上实测:十个 timed 测试各 563-576ms,而将其中一个改为内联测量后为 285ms;这十个测试每次运行约双倍墙钟时间,且每次额外 spawn 都是针对 BOUND_MS(测试存在的意义就是固定它)的一次独立 CI 启动抖动采样。修复:改用内联测量(见上方代码)。

— qwen3.8-max via Qwen Code /review (v0.21.12)

/* 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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Two wedge shapes named in superseded PR 9097's Critical review have no regression test, though this PR positions itself as the converged replacement of that feedback. Probe-verified both are bounded today: a get then() { while (true) {} } literal throws at ~252 ms via the vm timeout, and the weaponized Symbol.iterator phases payload is disarmed in ~1 ms because the serializer walks arrays by index — this is a missing-test gap, not a live defect. — Concrete cost: the iterator payload is disarmed only by the structural choice to iterate by index; a future edit reintroducing iterator-protocol traversal over vm-realm values (or moving any part of the walk back to the host) would silently restore the unrecoverable event-loop wedge for that shape, with no test to fail.

Fix: add two cases to the bounded evaluation block — one with get then() { while (true) {} } asserting the malformed-meta throw within BOUND_MS, and one with the weaponized-iterator phases payload asserting bounded behaviour (throw or successful extraction) within BOUND_MS.

中文说明

被取代的 PR 9097 的 Critical 评审中点名的两种卡死形态没有回归测试,尽管本 PR 将自己定位为针对该反馈的收敛替代方案。已探测验证两者目前都有界:get then() { while (true) {} } 字面量约 252ms 经 vm 超时抛出;武器化的 Symbol.iterator phases 载荷在约 1ms 内被化解(因为序列化器按索引遍历数组)——这是缺测试的缺口,不是现行缺陷。— 具体代价:迭代器载荷之所以被化解,仅仅依赖于"按索引遍历"这一结构选择;未来若有改动重新引入对 vm realm 值的迭代器协议遍历(或把任何一部分遍历挪回宿主),该形态的不可恢复事件循环卡死会被静默恢复,且没有测试会失败。

修复:在 bounded evaluation 块中补充两个用例——一个用 get then() { while (true) {} } 断言在 BOUND_MS 内抛出 meta 格式错误;一个用武器化迭代器的 phases 载荷断言在 BOUND_MS 内有界结束(抛错或成功提取)。

— qwen3.8-max via Qwen Code /review (v0.21.11)

Copy link
Copy Markdown
Collaborator Author

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 通过。

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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The test title promises a scenario its fixture never sets up. 'a thenable stays rejected even when the literal predefines the flag name' contains no hasThenable field — it is a plain thenable case, so the regression its title claims to guard (a model-defined field shadowing the serializer's envelope flag) is untested. Mutant-probe verified: merging the walked value's fields into the envelope (jsonStringify({ hasThenable, ...value })) leaves this test green, while a corrected fixture fails through the mutant — the advertised guard is vacuous as written. — Concrete cost: a future change that lets a model-authored hasThenable: false shadow the envelope flag would silently accept a Promise-bearing meta (thenable field dropped, no error), and this test would pass green through the change.

Fix: put the claimed scenario in the fixture, still asserting /meta values must not be Promises/ — or rename the test to match its body:

const src = `export const meta = { name: 'x', description: 'd', hasThenable: false, phases: Promise.resolve(1) }\nreturn 1`;
中文说明

测试标题承诺了其 fixture 并未构造的场景。'a thenable stays rejected even when the literal predefines the flag name' 中没有 hasThenable 字段——它只是一个普通的 thenable 用例,因此标题声称要守护的回归(模型自定义字段遮蔽序列化器信封标志)实际并未被测试。突变体验证:把被遍历值的字段并入信封(jsonStringify({ hasThenable, ...value }))时本测试仍然为绿,而修正后的 fixture 能穿透该突变体使其变红——按现状,这个自称的护栏是空的。— 具体代价:未来若某个改动允许模型编写的 hasThenable: false 遮蔽信封标志,携带 Promise 的 meta 会被静默接受(thenable 字段被丢弃、不报错),而本测试会一路绿灯地放行该改动。

修复:把标题声称的场景放进 fixture,仍然断言 /meta values must not be Promises/——或者把测试改名为与其内容一致。

— qwen3.8-max via Qwen Code /review (v0.21.11)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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`;
Comment on lines +589 to +590

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R5-13: Because the fixture's hostile value is a real Promise (phases: Promise.resolve(1)), the child's async-hooks gate throws META_PROMISE_ERROR independently of the serializer, leaving the defense the test names — predefining the hasThenable flag name cannot hide a thenable from the serializer's structural detection — unexercised; breaking the serializer-side detection for flag-predefinition shapes leaves this test green. — Failure scenario: a future change lets a literal's predefined hasThenable: false interfere with the serializer's thenable reporting; this test still passes because the async-hook gate throws first. Fix direction: use a plain non-Promise thenable (phases: { then: () => {} }) so the serializer's structural detection — not the birth hook — is what the test pins.

中文说明

由于 fixture 的敌意值是真实 Promise(phases: Promise.resolve(1)),子进程的 async-hooks 关卡会独立于序列化器抛出 META_PROMISE_ERROR,使测试命名的防线——预定义 hasThenable 标志名无法对序列化器的结构化检测隐藏 thenable——未被执行;破坏序列化器对标志预定义形状的检测,本测试仍为绿。— 故障场景:未来改动使字面量预定义的 hasThenable: false 干扰序列化器的 thenable 上报;本测试仍通过,因为 async-hook 关卡先抛错。修复方向:改用非 Promise 的普通 thenable(phases: { then: () => {} }),让测试固定的是序列化器的结构化检测而非出生钩子。

— qwen3.8-max via Qwen Code /review (v0.21.12)

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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R5-13: Because the fixture's hostile value is a real Promise (extra: Promise.resolve(1)), the child's async-hooks gate throws META_PROMISE_ERROR independently of the serializer, leaving the defense the test names — serializer detection surviving meta-realm helper mutation (Object.keys = ..., Promise.prototype.then = ...) — unpinned. Four executed arms: (A) all three serializer detection paths neutralized + original fixture → the test still passes; (B) same mutation + thenable fixture → fails expected [Function] to throw an error; (C) pristine + thenable fixture → passes; (D) gate disabled + pristine serializer + thenable fixture → passes (rejection comes from the serializer alone). Same masking shape as R5-11, a different test and a different unpinned defense. — Failure scenario: a mutation breaking the serializer's captured-intrinsic walk ships green while a thenable-hiding regression rots silently.

Fix: make the hostile value a non-Promise thenable so the hook never fires — extra: { then: () => {} } — detection must then come from the serializer's own walk, and the Object.keys override in the fixture becomes genuinely discriminating.

中文说明

由于 fixture 的恶意值是真实 Promise(extra: Promise.resolve(1)),子进程的 async-hooks 闸门会独立于序列化器抛出 META_PROMISE_ERROR,使测试名义上要固定的防线——序列化器在 meta realm 内置对象被篡改(Object.keys = ...Promise.prototype.then = ...)后仍能检出——没有被固定。四组已执行实验:(A) 中和序列化器全部三条检出路径 + 原 fixture → 测试仍通过;(B) 同样变异 + thenable fixture → 失败 expected [Function] to throw an error;(C) 原始代码 + thenable fixture → 通过;(D) 关闭闸门 + 原始序列化器 + thenable fixture → 通过(拒绝仅来自序列化器)。与 R5-11 同一掩盖形态,但测试与未固定防线不同。— 故障场景:破坏序列化器捕获内置对象遍历的变异可以绿色通过,thenable 隐匿回归将无声腐烂。

修复:把恶意值改为非 Promise 的 thenable,使钩子永不触发——extra: { then: () => {} }——检出必须来自序列化器自己的遍历,fixture 中的 Object.keys 覆盖才真正具有区分力。

— qwen3.8-max via Qwen Code /review (v0.21.12)

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');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-4: This isolation probe checks __qwenWorkflowMetaIsPromise, an identifier that exists nowhere in the round-2 implementation — grep confirms the only __qwenWorkflowMeta* names are this test line and META_SLOT = '__qwenWorkflowMetaValue' (workflow-sandbox.ts:412) — so the detection branch is unreachable and the test passes regardless of what the serializer exposes. Probe-verified: the serializer globalThis IS reachable through this exact escape chain and the real slot IS visible there; the stale identifier is always undefined. A future change re-exposing serializer state on the serializer global (or making the slot writable/configurable) ships green. — Concrete cost: this reads as the security regression test for serializer-scope isolation, so an exposure regression would be certified by a green run of exactly this test. Note for the fix: accessor-shorthand getters cannot run this chain (their .caller access throws); a re-anchored probe must keep the function-expression getter shape.

Suggested change
const serializerGlobal = arguments.callee.caller.constructor('return globalThis')();
if (serializerGlobal.__qwenWorkflowMetaIsPromise) throw new Error('host helper exposed');
if (typeof serializerGlobal.copy !== 'undefined' || typeof serializerGlobal.__qwenWorkflowMetaValue !== 'undefined') throw new Error('host helper exposed');

(or delete the stale probe if the adjacent scope-isolation tests are deemed sufficient.)

中文说明

[Suggestion] R2-4:这个隔离探测检查的是 __qwenWorkflowMetaIsPromise——该标识符在第二轮实现中任何地方都不存在——grep 确认仅有的两个 __qwenWorkflowMeta* 名称是本测试行和 META_SLOT = '__qwenWorkflowMetaValue'(workflow-sandbox.ts:412)——因此检测分支不可达,无论序列化器暴露什么,测试都会通过。已探测验证:序列化器的 globalThis 通过这条逃逸链确实可达,真实 slot 在那里可见;而这个过时的标识符始终是 undefined。未来若有改动重新把序列化器状态暴露到序列化器 global 上(或把 slot 变为可写/可配置),该测试仍会绿灯通过。— 具体代价:它看起来是序列化器作用域隔离的安全回归测试,因此一次暴露回归恰恰会以本测试的绿灯获得认证。修复注意:accessor 简写 getter 无法运行这条链(其 .caller 访问会抛错);重新锚定的探测必须保留函数表达式 getter 形状。

— qwen3.8-max via Qwen Code /review (v0.21.11)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R5-11: The only test meant to pin the parent-side hasThenable-before-tooLarge envelope precedence rides the child async-hooks path: the fixture creates a real Promise (extra: Promise.resolve(1)), so the child throws META_PROMISE_ERROR before any envelope is emitted and the parent re-throws verbatim without evaluating either check. Mutation-verified: swapping the two parent checks leaves the full suite green (193/193), while a probe with { name: 'x'.repeat(70000), description: 'd', extra: { then: () => {} } } flips — under the swap it receives the too-large error instead of the Promise diagnostic. — Failure scenario: after such a reorder, a plain thenable next to oversized content (which creates no Promise, so the hook cannot fire) reports meta literal is too large instead of the Promise diagnostic, steering the model/user to shrink the literal instead of removing the thenable.

Fix: change the fixture's Promise to a plain thenable so the envelope path is exercised — extra: { then: () => {} } — optionally keeping the current fixture as a separate case for the child-gate path.

中文说明

唯一意在固定宿主侧"hasThenable 先于 tooLarge"信封优先级的测试走的是子进程 async-hooks 路径:fixture 创建了真实 Promise(extra: Promise.resolve(1)),子进程在输出任何信封之前就抛 META_PROMISE_ERROR,宿主原样重抛,两个检查都没有被真正执行。变异验证:交换宿主两个检查的顺序后整个套件仍然全绿(193/193);而用 { name: 'x'.repeat(70000), description: 'd', extra: { then: () => {} } } 探测则会翻转——交换后收到的是 too-large 错误而非 Promise 诊断。— 故障场景:这种重排发生后,超大内容旁的普通 thenable(不创建 Promise,钩子不会触发)会报 meta literal is too large 而不是 Promise 诊断,把模型/用户引向"缩小字面量"而非"移除 thenable"。

修复:把 fixture 的 Promise 改为普通 thenable,使信封路径真正被执行——extra: { then: () => {} }——可选地把现有 fixture 保留为子进程钩子路径的独立用例。

— qwen3.8-max via Qwen Code /review (v0.21.12)

const src = `export const meta = {
name: 'x'.repeat(200000),
description: 'd',
extra: Promise.resolve(1),
Comment on lines +687 to +691

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R5-11: The only test meant to pin the parent-side hasThenable-before-tooLarge envelope precedence rides the child async-hooks path: the fixture creates a real Promise (extra: Promise.resolve(1)), so the child throws META_PROMISE_ERROR via the createdPromise gate independently of the host-side if (walked.hasThenable) ... if (walked.tooLarge) ordering — delete or swap that ordering and the test stays green. — Failure scenario: a future refactor swaps the host-side precedence (tooLarge before hasThenable); an oversize meta containing a thenable surfaces "meta literal is too large" instead of the Promise diagnostic, and no test notices. Fix direction: add a precedence case whose thenable is a plain non-Promise thenable (bypasses the async-hook gate) so the parent-side ordering is what decides the error.

中文说明

唯一旨在固定宿主侧 hasThenable 优先于 tooLarge 信封顺序的测试,实际依赖的是子进程 async-hooks 路径:fixture 创建了真实 Promise(extra: Promise.resolve(1)),子进程经 createdPromise 关卡抛出 META_PROMISE_ERROR,与宿主侧 if (walked.hasThenable) ... if (walked.tooLarge) 的顺序无关——删除或交换该顺序测试仍为绿。— 故障场景:未来重构交换宿主侧优先级(tooLarge 先于 hasThenable);包含 thenable 的超大 meta 以 "meta literal is too large" 而非 Promise 诊断浮现,且无测试察觉。修复方向:新增一个使用非 Promise 的普通 thenable 的优先级用例(绕过 async-hooks 关卡),使宿主侧顺序真正决定错误。

— qwen3.8-max via Qwen Code /review (v0.21.12)

}\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
Expand Down
Loading
Loading