diff --git a/packages/code-runtime/code-runtime-worker/src/output-json.ts b/packages/code-runtime/code-runtime-worker/src/output-json.ts index 2110a4ab27..de7ed6f301 100644 --- a/packages/code-runtime/code-runtime-worker/src/output-json.ts +++ b/packages/code-runtime/code-runtime-worker/src/output-json.ts @@ -38,42 +38,60 @@ export function jsonStringBytesUpTo(text: string, maxBytes: number): number | un * @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed. */ export function jsonValueBytesUpTo(value: CodeJsonValue, maxBytes: number): number | undefined { - if (value === null) return maxBytes >= 4 ? 4 : undefined - if (typeof value === 'string') return jsonStringBytesUpTo(value, maxBytes) - if (typeof value === 'number') { - const bytes = Buffer.byteLength(String(value), 'utf8') - return bytes <= maxBytes ? bytes : undefined - } - if (typeof value === 'boolean') { - const bytes = value ? 4 : 5 - return bytes <= maxBytes ? bytes : undefined - } + type Task = + | { kind: 'value'; value: CodeJsonValue } + | { kind: 'array'; value: CodeJsonValue[]; index: number } + | { kind: 'object'; value: Record; keys: string[]; index: number } - let bytes = 2 - if (bytes > maxBytes) return undefined - if (Array.isArray(value)) { - for (let index = 0; index < value.length; index++) { - if (index > 0 && ++bytes > maxBytes) return undefined - const item = value[index] - if (item === undefined) return undefined - const itemBytes = jsonValueBytesUpTo(item, maxBytes - bytes) - if (itemBytes === undefined) return undefined - bytes += itemBytes + let bytes = 0 + const add = (cost: number): boolean => { + bytes += cost + return bytes <= maxBytes + } + const tasks: Task[] = [{ kind: 'value', value }] + for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) { + if (task.kind === 'value') { + const current = task.value + if (current === null) { + if (!add(4)) return undefined + } else if (typeof current === 'string') { + const stringBytes = jsonStringBytesUpTo(current, maxBytes - bytes) + if (stringBytes === undefined) return undefined + bytes += stringBytes + } else if (typeof current === 'number') { + if (!add(Buffer.byteLength(String(current), 'utf8'))) return undefined + } else if (typeof current === 'boolean') { + if (!add(current ? 4 : 5)) return undefined + } else if (Array.isArray(current)) { + if (!add(2)) return undefined + if (current.length > 0) tasks.push({ kind: 'array', value: current, index: 0 }) + } else { + if (!add(2)) return undefined + const keys = Object.keys(current) + if (keys.length > 0) tasks.push({ kind: 'object', value: current, keys, index: 0 }) + } + continue } - return bytes - } - let entries = 0 - for (const [key, item] of Object.entries(value)) { - if (entries > 0 && ++bytes > maxBytes) return undefined + if (task.index > 0 && !add(1)) return undefined + if (task.kind === 'array') { + const item = task.value[task.index] + if (item === undefined) return undefined + if (task.index + 1 < task.value.length) tasks.push({ ...task, index: task.index + 1 }) + tasks.push({ kind: 'value', value: item }) + continue + } + + const key = task.keys[task.index] + /* v8 ignore next -- an object frame is created and advanced only for an existing Object.keys entry. */ + if (key === undefined) return undefined const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes) if (keyBytes === undefined) return undefined - bytes += keyBytes + 1 - if (bytes > maxBytes) return undefined - const itemBytes = jsonValueBytesUpTo(item, maxBytes - bytes) - if (itemBytes === undefined) return undefined - bytes += itemBytes - entries += 1 + if (!add(keyBytes + 1)) return undefined + const item = task.value[key] + if (item === undefined) return undefined + if (task.index + 1 < task.keys.length) tasks.push({ ...task, index: task.index + 1 }) + tasks.push({ kind: 'value', value: item }) } return bytes } diff --git a/packages/code-runtime/code-runtime-worker/src/worker-json.ts b/packages/code-runtime/code-runtime-worker/src/worker-json.ts index d49c26d7d1..fbc0cd7720 100644 --- a/packages/code-runtime/code-runtime-worker/src/worker-json.ts +++ b/packages/code-runtime/code-runtime-worker/src/worker-json.ts @@ -3,20 +3,35 @@ import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime' /* jscpd:ignore-start -- the source worker mirrors session JSON helpers without workspace runtime imports */ +/** Whether a realm-owned intrinsic prototype names and points back to its constructor. */ +function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean { + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor') + const constructor: unknown = descriptor?.value + return typeof constructor === 'function' + && constructor.name === name + && constructor.prototype === prototype +} + +/** Whether a candidate is one realm's intrinsic `Object.prototype`. */ +function isIntrinsicObjectPrototype(value: object): boolean { + return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, 'Object') +} + /** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */ function hasPlainArrayPrototype(value: unknown[]): boolean { const prototype: unknown = Object.getPrototypeOf(value) - if (!Array.isArray(prototype)) return false + if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false const objectPrototype: unknown = Object.getPrototypeOf(prototype) - return objectPrototype !== null - && !Array.isArray(objectPrototype) - && Object.getPrototypeOf(objectPrototype) === null + return typeof objectPrototype === 'object' + && objectPrototype !== null + && isIntrinsicObjectPrototype(objectPrototype) } /** Whether an object is a plain or null-prototype record from any JavaScript realm. */ function hasPlainObjectPrototype(value: object): boolean { const prototype: unknown = Object.getPrototypeOf(value) - return prototype === null || Object.getPrototypeOf(prototype) === null + return prototype === null + || typeof prototype === 'object' && isIntrinsicObjectPrototype(prototype) } /** Return every JSON-visible object key, or reject own data JSON would discard. */ diff --git a/packages/code-runtime/code-runtime-worker/tests/output-json.spec.ts b/packages/code-runtime/code-runtime-worker/tests/output-json.spec.ts index 36f6ebcfad..dde3cba5fc 100644 --- a/packages/code-runtime/code-runtime-worker/tests/output-json.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/output-json.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime' import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from '../src/output-json.ts' describe('truncateJsonStringBytes', () => { @@ -45,6 +46,8 @@ describe('jsonValueBytesUpTo', () => { expect(jsonValueBytesUpTo(value, bytes)).toBe(bytes) expect(jsonValueBytesUpTo(value, bytes - 1)).toBeUndefined() expect(jsonValueBytesUpTo({}, 1)).toBeUndefined() + expect(jsonValueBytesUpTo([], 1)).toBeUndefined() + expect(jsonValueBytesUpTo([], 2)).toBe(2) expect(jsonValueBytesUpTo(null, 3)).toBeUndefined() expect(jsonValueBytesUpTo(10, 1)).toBeUndefined() expect(jsonValueBytesUpTo(false, 4)).toBeUndefined() @@ -55,5 +58,14 @@ describe('jsonValueBytesUpTo', () => { expect(jsonValueBytesUpTo({ long: null }, 2)).toBeUndefined() expect(jsonValueBytesUpTo({ '': null }, 4)).toBeUndefined() expect(jsonValueBytesUpTo({ a: null }, 9)).toBeUndefined() + expect(jsonValueBytesUpTo({ a: undefined } as unknown as CodeJsonValue, 100)).toBeUndefined() + }) + + it('meters deeply nested arrays without recursive stack growth', () => { + let value: CodeJsonValue = null + for (let depth = 0; depth < 5_000; depth++) value = [value] + + expect(jsonValueBytesUpTo(value, 10_004)).toBe(10_004) + expect(jsonValueBytesUpTo(value, 10_003)).toBeUndefined() }) }) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index 4d4bb4c3c1..c38ee7f492 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -430,6 +430,29 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => { expect(result).toEqual({ logs: [], error: { kind: 'exception', message: 'fake failure' } }) }) + it('contains a deeply nested forged completion without overflowing the host meter', async () => { + const { runtime } = await setup() + const result = await runtime.run({ + program: ` + const { parentPort } = await import('node:worker_threads'); + let value = null; + for (let depth = 0; depth < 3_000; depth++) value = [value]; + parentPort.postMessage({ type: 'done', value }); + `, + bindings: [], + }) + expect(result.error).toBeUndefined() + let value = result.value + let depth = 0 + while (Array.isArray(value)) { + expect(value).toHaveLength(1) + value = value[0] + depth += 1 + } + expect(depth).toBe(3_000) + expect(value).toBeNull() + }) + it('turns forged over-limit error text into output-limit at the host', async () => { const { runtime } = await setup({ maxOutputBytes: 64 }) const result = await runtime.run({ diff --git a/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts b/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts index 029b5789ac..3d67a67897 100644 --- a/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts @@ -79,6 +79,8 @@ describe('snapshotCodeJsonValue', () => { Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true }) const hiddenObject = Object.defineProperty({}, 'hidden', { value: true }) const symbolObject = { [Symbol('extra')]: true } + const customPrototype = Object.create(null) as Record + const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record, { value: 1 }) const forgedPrototype: unknown[] = [] Object.setPrototypeOf(forgedPrototype, null) const forgedArray = [1] @@ -94,6 +96,7 @@ describe('snapshotCodeJsonValue', () => { symbolDecorated, hiddenObject, symbolObject, + customPrototypeObject, forgedArray, cyclic, [undefined],