Files
deepseek-harness/packages/workflow/workflow-vm/tests/realm.spec.ts
Tianyi Cui fff2e1f33d workflow: render thrown script values inside the realm's execution window
Codex code-review round 3: the round-2 'contained stack getter' still let a
script escape the vm sync-slice timeout — throw { get stack() { while(true){} } }
put the spin on the HOST catch path, where no timeout applies (verified: a
direct sync-slice spin dies by the timeout; the getter-hidden one hung the
process). Identity-trusting the native getter is also insufficient: V8 stack
formatting reads script-controllable hooks at format time (Error.prepareStackTrace,
a subclass name getter — both empirically confirmed), so ANY host-side
formatting of a realm error can run realm code.

The fix moves rendering into the realm itself: the compiled body (and the meta
literal) is wrapped in a realm-side catch that pre-renders the thrown value to
a string (REALM_THROWN_RENDERER_SOURCE) — a hostile accessor/toString now runs
as ordinary script code, killed by the sync-slice timeout or falling under the
documented post-await spin limitation; host WorkflowErrors pass through for
the CANCELLED mapping. The host catch descriptor-reads the pre-rendered string
(thrownRendering) or falls back to describeThrown, which invokes no getter
whose identity is not the host realm's own native stack getter.

Tests: hostile-table expectations updated for realm-side rendering; new
regressions for the getter-hidden sync spin dying by the vm timeout (engine +
meta paths) and for a hostile thenable rejection that bypasses the realm
wrapper (renders host-side, proxy labelled, traps never run); describeThrown/
thrownRendering unit tables including the realm-error identity-mismatch case.
2026-07-05 20:32:35 +08:00

179 lines
8.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import * as vm from 'node:vm'
import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering } from '../src/realm.ts'
/** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */
function inRealm(expression: string): unknown {
return vm.runInNewContext(`(${expression})`)
}
/** The MaterializeError message for a value that must be rejected (throws if accepted). */
function rejection(value: unknown): string {
try {
materializeFromRealm(value)
} catch (error: unknown) {
if (error instanceof MaterializeError) return error.message
throw error
}
throw new Error('expected the value to be rejected')
}
describe('materializeFromRealm', () => {
it('copies realm objects/arrays/scalars into host plain data', () => {
const value = inRealm("{ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] }")
const out = materializeFromRealm(value) as Record<string, unknown>
expect(out).toEqual({ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] })
// The copy is HOST data: prototypes are the host intrinsics.
expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
expect(Array.isArray(out.list)).toBe(true)
// And it round-trips through JSON byte-identically (the whole point).
expect(JSON.parse(JSON.stringify(out))).toEqual(out)
})
it('accepts undefined ONLY at the root (a valueless script return)', () => {
expect(materializeFromRealm(undefined)).toBeUndefined()
expect(rejection(inRealm('{ a: undefined }'))).toContain('value.a')
})
it('never invokes accessors: a counting getter is rejected, not read', () => {
const counter = inRealm(`
(() => {
globalThis.reads = 0
return { get x() { globalThis.reads += 1; return 1 } }
})()
`)
expect(rejection(counter)).toContain('accessor properties cannot cross')
// The getter body never ran — descriptor inspection only.
expect((counter as { x?: unknown }).x).toBe(1) // sanity: reading DOES run it…
expect(rejection(counter)).toContain('accessor') // …but materialization still never did
})
it('a "__proto__" key becomes an OWN data property of the copy, never a prototype mutation', () => {
const value: unknown = vm.runInNewContext('JSON.parse(\'{"__proto__": {"polluted": 1}, "ok": 2}\')')
const out = materializeFromRealm(value) as Record<string, unknown>
expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(true)
expect(out.ok).toBe(2)
// The host Object.prototype was NOT touched.
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
})
it('rejects functions, symbols (keys and values), and bigints with path-qualified messages', () => {
expect(rejection(inRealm('{ fn: () => 1 }'))).toContain('value.fn')
expect(rejection(inRealm("{ [Symbol('k')]: 1 }"))).toContain('symbol-keyed')
expect(rejection(inRealm("{ s: Symbol('v') }"))).toContain('value.s')
expect(rejection(inRealm('{ big: 1n }'))).toContain('value.big')
expect(rejection(inRealm("[Symbol('x')]"))).toContain('value[0]')
const taggedArray = inRealm("(() => { const a = [1]; a[Symbol('t')] = 1; return a })()")
expect(rejection(taggedArray)).toContain('symbol-keyed')
})
it('rejects non-finite numbers and undefined values inside containers', () => {
expect(rejection(inRealm('{ n: NaN }'))).toContain('non-finite')
expect(rejection(inRealm('[Infinity]'))).toContain('non-finite')
})
it('rejects exotic prototypes (Date, Map, class instances) but accepts null-prototype data', () => {
expect(rejection(inRealm('{ d: new Date(0) }'))).toContain('exotic prototype')
expect(rejection(inRealm('new Map()'))).toContain('exotic prototype')
expect(rejection(inRealm('(() => { class C { constructor() { this.x = 1 } } return new C() })()')))
.toContain('exotic prototype')
expect(materializeFromRealm(inRealm('Object.assign(Object.create(null), { a: 1 })'))).toEqual({ a: 1 })
})
it('rejects proxies (root, nested, revoked, host-realm) WITHOUT running any trap', () => {
const trapped = inRealm(`new Proxy({ a: 1 }, {
ownKeys() { throw new Error('trap ran') },
getOwnPropertyDescriptor() { throw new Error('trap ran') },
getPrototypeOf() { throw new Error('trap ran') },
})`)
// A trap firing would surface 'trap ran' (a non-MaterializeError) instead.
expect(rejection(trapped)).toContain('proxies cannot cross')
expect(rejection(inRealm('{ nested: new Proxy([], {}) }'))).toContain('value.nested')
const revoked = inRealm('(() => { const r = Proxy.revocable({}, {}); r.revoke(); return r.proxy })()')
expect(rejection(revoked)).toContain('proxies cannot cross')
expect(rejection(new Proxy({}, {}))).toContain('proxies cannot cross')
})
it('rejects an object whose PROTOTYPE is a proxy without dereferencing through it', () => {
const value = inRealm(`Object.create(new Proxy({}, {
getPrototypeOf() { throw new Error('trap ran') },
}))`)
expect(rejection(value)).toContain('exotic prototype')
})
it('rejects cycles and accepts the same object reused as a sibling (a DAG)', () => {
expect(rejection(inRealm('(() => { const o = {}; o.self = o; return o })()'))).toContain('circular')
const dag = inRealm('(() => { const leaf = { v: 1 }; return { a: leaf, b: leaf } })()')
expect(materializeFromRealm(dag)).toEqual({ a: { v: 1 }, b: { v: 1 } })
})
it('rejects sparse arrays, accessor elements, and non-index array properties', () => {
expect(rejection(inRealm('[1, , 3]'))).toContain('sparse')
expect(rejection(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 1 }); return a })()')))
.toContain('accessor')
expect(rejection(inRealm('(() => { const a = [1]; a.total = 3; return a })()')))
.toContain('non-index')
})
it('skips non-enumerable own properties (matching JSON.stringify exactly)', () => {
const value = inRealm(`(() => {
const o = { visible: 1 }
Object.defineProperty(o, 'hidden', { value: () => 1, enumerable: false })
return o
})()`)
expect(materializeFromRealm(value)).toEqual({ visible: 1 })
})
it('works on plain host values too (the boundary is realm-agnostic)', () => {
expect(materializeFromRealm({ a: [1, 'x'] })).toEqual({ a: [1, 'x'] })
expect(materializeFromRealm('str')).toBe('str')
expect(materializeFromRealm(3)).toBe(3)
expect(materializeFromRealm(false)).toBe(false)
expect(materializeFromRealm(null)).toBeNull()
})
})
describe('describeThrown (host-side thrown-value rendering)', () => {
it('renders a HOST Error via its identity-verified native stack getter', () => {
const error = new Error('host failure')
const rendered = describeThrown(error)
expect(rendered).toContain('host failure')
expect(rendered).toContain('at ') // a real stack, not just the message
})
it('never invokes a REALM error stack getter (identity mismatch) — message renders instead', () => {
const realmError: unknown = vm.runInNewContext('(() => { try { throw new Error("realm failure") } catch (e) { return e } })()')
expect(describeThrown(realmError)).toBe('realm failure')
})
it('reads a data-property stack directly and falls through a setter-only accessor', () => {
expect(describeThrown({ stack: 'data stack' })).toBe('data stack')
const setterOnly = { message: 'via message' }
Object.defineProperty(setterOnly, 'stack', { set() { /* swallow */ } })
expect(describeThrown(setterOnly)).toBe('via message')
})
it('labels proxies and functions without touching them; primitives stringify', () => {
expect(describeThrown(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBe('[thrown proxy]')
expect(describeThrown(() => 1)).toBe('[thrown function]')
expect(describeThrown('plain')).toBe('plain')
expect(describeThrown(42)).toBe('42')
expect(describeThrown(undefined)).toBe('undefined')
expect(describeThrown(null)).toBe('null')
expect(describeThrown({ code: 42 })).toBe('[object Object]')
})
})
describe('thrownRendering (the realm-catch wrapper reader)', () => {
it('extracts the pre-rendered string from a wrapper and nothing else', () => {
expect(thrownRendering({ __wfThrown: 'rendered text' })).toBe('rendered text')
expect(thrownRendering({ __wfThrown: 42 })).toBeUndefined()
expect(thrownRendering({ other: 'x' })).toBeUndefined()
expect(thrownRendering(new Error('plain'))).toBeUndefined()
expect(thrownRendering('string')).toBeUndefined()
expect(thrownRendering(null)).toBeUndefined()
expect(thrownRendering(new Proxy({ __wfThrown: 'forged' }, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBeUndefined()
})
})