Skip to content

Commit b5a87eb

Browse files
os-zhuangclaude
andauthored
fix(runtime): safe-marshal hook ctx so circular host handles don't crash the sandbox (#2674) (#2676)
`QuickJSScriptRunner.installCtx` serialised the hook context into the sandbox with a bare `JSON.stringify`. A live `setTimeout`/`setInterval` handle reachable from `ctx` links back on itself (`Timeout._idlePrev -> TimersList._idleNext -> …`), so `JSON.stringify` threw `TypeError: Converting circular structure to JSON` and took the whole hook down. Route all three host→VM marshalling paths (`setGlobalJson`, `setObjectJson`, `jsonToHandle`) through a shared `safeJsonStringify` that drops circular back-edges via a path `WeakSet` and coerces `BigInt` to a string. Only JSON-safe leaves cross the boundary — unserialisable host objects are stripped rather than fatal — and the body still runs. Closes #2674. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0adcc1c commit b5a87eb

3 files changed

Lines changed: 77 additions & 3 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
Sandbox: stop `QuickJSScriptRunner` from crashing when a hook context holds a non-serialisable host object.
6+
7+
`installCtx` marshalled `ctx` into the QuickJS sandbox with a bare `JSON.stringify`. If the context (or anything reachable from it) held a live `setTimeout`/`setInterval` handle, `JSON.stringify` threw `TypeError: Converting circular structure to JSON` (`Timeout._idlePrev -> TimersList._idleNext -> …`) and took the whole hook down (#2674). Marshalling now goes through a shared `safeJsonStringify` that drops circular back-edges via a path `WeakSet` and coerces `BigInt` to a string, so only JSON-safe leaves cross the boundary and the body still runs.

packages/runtime/src/sandbox/quickjs-runner.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,41 @@ describe('QuickJSScriptRunner — L2 hook script', () => {
162162
expect(err!.message).toContain("action 'lead_apply_convert' threw:");
163163
expect(err!.innerMessage).toBe('线索信息不完整');
164164
});
165+
166+
it('marshals ctx.input containing a circular Timeout handle without crashing (#2674)', async () => {
167+
// A live setInterval handle links back on itself
168+
// (Timeout._idlePrev -> TimersList._idleNext -> …). Naive JSON.stringify
169+
// over ctx would throw "Converting circular structure to JSON" and take the
170+
// hook down. The runner must strip the back-edge and run the body.
171+
const timer = setInterval(() => {}, 1_000);
172+
try {
173+
const r = await runner.runScript(
174+
{
175+
language: 'js',
176+
source: 'return { ok: true, n: ctx.input.n };',
177+
capabilities: [],
178+
},
179+
ctx({ input: { n: 5, timer } as unknown as Record<string, unknown> }),
180+
hookOpts,
181+
);
182+
expect(r.value).toEqual({ ok: true, n: 5 });
183+
} finally {
184+
clearInterval(timer);
185+
}
186+
});
187+
188+
it('marshals a BigInt in ctx.input by coercing to string rather than throwing', async () => {
189+
const r = await runner.runScript(
190+
{
191+
language: 'js',
192+
source: 'return { big: ctx.input.big };',
193+
capabilities: [],
194+
},
195+
ctx({ input: { big: 42n } as unknown as Record<string, unknown> }),
196+
hookOpts,
197+
);
198+
expect(r.value).toEqual({ big: '42' });
199+
});
165200
});
166201

167202
describe('QuickJSScriptRunner — L2 action script', () => {

packages/runtime/src/sandbox/quickjs-runner.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -569,9 +569,41 @@ function installApiMethod(
569569
fn.dispose();
570570
}
571571

572+
/**
573+
* Serialise a host value for marshalling into the VM, tolerating shapes that
574+
* plain `JSON.stringify` chokes on. Only JSON-safe leaves cross the sandbox
575+
* boundary anyway, so anything unserialisable is dropped rather than fatal:
576+
*
577+
* - **Circular references** — a live `setTimeout`/`setInterval` handle (or any
578+
* node host object) reachable from `ctx` links back on itself
579+
* (`Timeout._idlePrev -> TimersList._idleNext -> …`). Naive `JSON.stringify`
580+
* throws `TypeError: Converting circular structure to JSON` and takes the
581+
* whole hook down (issue #2674). A `WeakSet` of ancestors on the current path
582+
* drops the back-edge instead.
583+
* - **BigInt** — `JSON.stringify` throws outright; we coerce to a string.
584+
*
585+
* The replacer is deliberately conservative: it strips only what would crash
586+
* the serialiser, leaving legitimate data intact.
587+
*/
588+
function safeJsonStringify(v: unknown): string {
589+
const seen = new WeakSet<object>();
590+
const json = JSON.stringify(v ?? null, function (this: unknown, _key, value) {
591+
if (typeof value === 'bigint') return value.toString();
592+
if (typeof value === 'object' && value !== null) {
593+
if (seen.has(value)) return undefined; // drop circular back-edge
594+
seen.add(value);
595+
}
596+
return value;
597+
});
598+
// `JSON.stringify` returns `undefined` when the top-level value is itself
599+
// unserialisable (e.g. a bare function); normalise to a JSON literal so the
600+
// downstream `vm.evalCode` never receives `(undefined)`.
601+
return json ?? 'null';
602+
}
603+
572604
/** Marshal a host JSON-serializable value into a QuickJS handle. */
573605
function jsonToHandle(vm: QuickJSAsyncContext, v: unknown): QuickJSHandle {
574-
const json = JSON.stringify(v ?? null);
606+
const json = safeJsonStringify(v);
575607
const r = vm.evalCode(`(${json})`);
576608
if (r.error) {
577609
const msg = vm.dump(r.error);
@@ -582,7 +614,7 @@ function jsonToHandle(vm: QuickJSAsyncContext, v: unknown): QuickJSHandle {
582614
}
583615

584616
function setGlobalJson(vm: QuickJSAsyncContext, name: string, v: unknown): void {
585-
const json = JSON.stringify(v ?? null);
617+
const json = safeJsonStringify(v);
586618
const result = vm.evalCode(`(${json})`);
587619
if (result.error) {
588620
result.error.dispose();
@@ -593,7 +625,7 @@ function setGlobalJson(vm: QuickJSAsyncContext, name: string, v: unknown): void
593625
}
594626

595627
function setObjectJson(vm: QuickJSAsyncContext, parent: QuickJSHandle, key: string, v: unknown): void {
596-
const json = JSON.stringify(v ?? null);
628+
const json = safeJsonStringify(v);
597629
const result = vm.evalCode(`(${json})`);
598630
if (result.error) {
599631
result.error.dispose();

0 commit comments

Comments
 (0)