From acc628f3afde26ac47168434a3daa4f42351c936 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:37:11 +0800 Subject: [PATCH 1/2] fix(tools): preserve literal and realm-safe schemas --- ...20-unified-json-value-schema-dsl.i18n.yaml | 4 +-- ...026-07-20-unified-json-value-schema-dsl.md | 2 +- ...-07-20-unified-json-value-schema-dsl.zh.md | 2 +- packages/core/session/src/json.ts | 22 +++++++++++----- packages/core/session/tests/json.spec.ts | 26 ++++++++++++++++++- packages/core/tools/src/schema.ts | 2 +- packages/core/tools/tests/json-schema.spec.ts | 11 ++++++++ packages/core/tools/tests/tools.spec.ts | 15 +++++++++++ 8 files changed, 72 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml index f434c81118..0cd154e190 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-unified-json-value-schema-dsl.md: ab7bb268407ac26230283172ebb291de80412bf2 -2026-07-20-unified-json-value-schema-dsl.zh.md: 479699fc2d58666b86861f0ea1db907ae4957dc5 +2026-07-20-unified-json-value-schema-dsl.md: 94c3f5aa5fcb84abddc58e8fd298188b3284f7ea +2026-07-20-unified-json-value-schema-dsl.zh.md: d8362c2c9987689fbd812b6c16aae815f56062e1 diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md index ab7bb26840..94c3f5aa5f 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md @@ -12,7 +12,7 @@ Tool parameters used a small author DSL while subagent/workflow structured outpu `dsh-tools` owns one JSON-value schema vocabulary with two representations. `ValueSchemaSpec` is the author form for any JSON root; `ParameterSchemaSpec` is its implicit object-property-map form with per-property `required: true`. `JsonSchemaNode` is the raw wire form. Both support string, finite number, integer, boolean, null, array, object, type-correct scalar `enum`/`const`, and exact-one `oneOf`; `{ type: 'json' }` is author-only sugar for an annotation-only unconstrained raw node. -An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. `InferValue` and `InferArgs

` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. +An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. `InferValue` and `InferArgs

` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. Intrinsic plain Object and Array containers remain plain across JavaScript realms; subclasses remain exotic. Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use `assertObjectJsonSchema()` and `ObjectJsonSchema`; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler. diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md index 479699fc2d..d8362c2c99 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md @@ -12,7 +12,7 @@ Status: implemented `dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true`。`JsonSchemaNode` 是原始协议表示。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum`/`const`,以及要求恰好匹配一个分支的 `oneOf`;`{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。 -显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue` 和 `InferArgs

` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。 +显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue` 和 `InferArgs

` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。 对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。 diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index f0d3d42266..e2c681b100 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -12,6 +12,18 @@ */ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } +/** 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) + return Array.isArray(prototype) && Object.getPrototypeOf(Object.getPrototypeOf(prototype)) === null +} + +/** 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 +} + /** * Validate and detach lossless JSON in one read per property, so a stateful * getter cannot change between validation and copying. Accepts ordinary arrays, @@ -46,7 +58,7 @@ export function snapshotJsonValue(value: T): T | undefined { ancestors.add(current) try { if (Array.isArray(current)) { - if (Object.getPrototypeOf(current) !== Array.prototype) return undefined + if (!hasPlainArrayPrototype(current)) return undefined const length = current.length // Every ordinary array owns `length`; dense indexed elements account // for the remaining keys. Anything else would be lost by JSON and by @@ -62,8 +74,7 @@ export function snapshotJsonValue(value: T): T | undefined { return snapshot } - const prototype = Object.getPrototypeOf(current) as unknown - if (prototype !== Object.prototype && prototype !== null) return undefined + if (!hasPlainObjectPrototype(current)) return undefined const snapshot: { [key: string]: JsonValue } = {} for (const key of Object.keys(current)) { const item = visit((current as Record)[key]) @@ -115,7 +126,7 @@ export function isJsonValue(value: unknown, seen: Set = new Set()): bool seen.add(value) try { if (Array.isArray(value)) { - if (Object.getPrototypeOf(value) !== Array.prototype) return false + if (!hasPlainArrayPrototype(value)) return false if (Reflect.ownKeys(value).length !== value.length + 1) return false // Reject sparse arrays: a hole is skipped by `every`/`forEach` but // JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip @@ -127,8 +138,7 @@ export function isJsonValue(value: unknown, seen: Set = new Set()): bool return true } // Plain object only (reject Map/Set/Date/class instances). - const proto = Object.getPrototypeOf(value) as unknown - if (proto !== Object.prototype && proto !== null) return false + if (!hasPlainObjectPrototype(value)) return false return Object.values(value).every(v => isJsonValue(v, seen)) } finally { seen.delete(value) diff --git a/packages/core/session/tests/json.spec.ts b/packages/core/session/tests/json.spec.ts index 35d521f6d1..1d00d70787 100644 --- a/packages/core/session/tests/json.spec.ts +++ b/packages/core/session/tests/json.spec.ts @@ -1,5 +1,6 @@ +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' -import { isJsonValue, snapshotJsonValue } from '@deepseek-ai/dsh-session' +import { isJsonValue, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' describe('snapshotJsonValue', () => { it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => { @@ -36,6 +37,22 @@ describe('snapshotJsonValue', () => { expect(Object.getPrototypeOf(snapshot.list[0])).toBe(Object.prototype) }) + it('accepts intrinsic plain containers from another JavaScript realm', () => { + const foreign = runInNewContext('({ object: { nested: [1] }, array: [2, { ok: true }] })') as { + object: { nested: number[] } + array: JsonValue[] + } + + expect(isJsonValue(foreign.object)).toBe(true) + expect(isJsonValue(foreign.array)).toBe(true) + const objectSnapshot = snapshotJsonValue(foreign.object)! + const arraySnapshot = snapshotJsonValue(foreign.array)! + expect(objectSnapshot).toEqual({ nested: [1] }) + expect(arraySnapshot).toEqual([2, { ok: true }]) + expect(Object.getPrototypeOf(objectSnapshot)).toBe(Object.prototype) + expect(Object.getPrototypeOf(arraySnapshot)).toBe(Array.prototype) + }) + it('reads each object value and array slot once while materializing', () => { class Exotic { readonly accepted = false @@ -77,10 +94,17 @@ describe('snapshotJsonValue', () => { Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true }) const cyclic: Record = {} cyclic.self = cyclic + const foreignExotics = runInNewContext(`(() => { + class Box { constructor() { this.value = 1 } } + class List extends Array {} + return [new Box(), new List(1)] + })()`) as [object, unknown[]] expect(snapshotJsonValue(new ExoticObject())).toBeUndefined() expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined() expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined() + expect(snapshotJsonValue(foreignExotics[0])).toBeUndefined() + expect(snapshotJsonValue(foreignExotics[1])).toBeUndefined() expect(snapshotJsonValue(sparse)).toBeUndefined() expect(snapshotJsonValue(compensatedSparse)).toBeUndefined() expect(snapshotJsonValue(decorated)).toBeUndefined() diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 4a559d47eb..9f52eb6e33 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -378,7 +378,7 @@ export interface DefineToolOptions { * @param options - typed definition and optional presenters. * @returns A registry-ready definition. */ -export function defineTool(options: DefineToolOptions): ToolDefinition { +export function defineTool(options: DefineToolOptions): ToolDefinition { // Object-literal methods do not use `this`; retaining references is safe. // eslint-disable-next-line @typescript-eslint/unbound-method const userExecute = options.execute diff --git a/packages/core/tools/tests/json-schema.spec.ts b/packages/core/tools/tests/json-schema.spec.ts index 96feb9b63d..fb03a75ae2 100644 --- a/packages/core/tools/tests/json-schema.spec.ts +++ b/packages/core/tools/tests/json-schema.spec.ts @@ -1,3 +1,4 @@ +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { assertObjectJsonSchema, @@ -187,6 +188,16 @@ describe('the enforced raw JSON Schema subset', () => { .toEqual(['schema.examples annotation must be lossless JSON data']) }) + it('accepts lossless annotation containers from another JavaScript realm', () => { + const schema = runInNewContext(`({ + type: 'object', + default: { x: 1 }, + examples: [[{ ok: true }]], + })`) as unknown + + expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow() + }) + it('rejects cyclic/exotic schema structure but permits sibling reuse', () => { const cyclic: Record = { type: 'object' } cyclic.properties = { self: cyclic } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 5ddac2cd31..80f5812071 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1420,6 +1420,21 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) describe('defineTool presentation (presentCall / presentResult)', () => { + it('preserves inline enum and const literals in inferred arguments', () => { + defineTool({ + name: 'literal-args', + description: 'literal arguments', + parameters: { + mode: { type: 'string', enum: ['read', 'write'], required: true }, + attempt: { type: 'integer', const: 1 }, + }, + async execute(args) { + expectTypeOf(args).toEqualTypeOf<{ mode: 'read' | 'write'; attempt?: 1 }>() + return [] + }, + }) + }) + it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => { const tool = defineTool({ name: 'demo', From 627eb6e00bd83296eb4288b7333b4926cc6d3053 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:56:38 +0800 Subject: [PATCH 2/2] fix(code-runtime): bound hostile output accounting --- .../code-runtime-worker/src/index.ts | 70 ++++++++----------- .../code-runtime-worker/src/output-json.ts | 68 +++++++++++++++++- .../tests/output-json.spec.ts | 45 +++++++++++- .../code-runtime-worker/tests/runtime.spec.ts | 16 +++++ 4 files changed, 156 insertions(+), 43 deletions(-) diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index 0fa66dbd71..198bec694b 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -15,7 +15,7 @@ import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts' -import { truncateJsonStringBytes } from './output-json.ts' +import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts' /** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */ export interface Config { @@ -141,11 +141,6 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | undefined { } -/** Serialized byte size of one lossless JSON value. */ -function jsonBytes(value: CodeJsonValue): number { - return Buffer.byteLength(JSON.stringify(value), 'utf8') -} - /** One run's combined outer-output ledger; binding values never enter it. */ class OutputLedger { private bytes = 2 // JSON serialization of the empty logs array: [] @@ -155,9 +150,10 @@ class OutputLedger { /** Admit one exact log entry, or report that the hard cap was crossed. */ admit(text: string, sink: string[]): boolean { - const cost = Buffer.byteLength(JSON.stringify(text), 'utf8') + (this.entries > 0 ? 1 : 0) - if (this.bytes + cost > this.maxBytes) return false - this.bytes += cost + const separatorBytes = this.entries > 0 ? 1 : 0 + const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes) + if (stringBytes === undefined) return false + this.bytes += stringBytes + separatorBytes this.entries += 1 sink.push(text) return true @@ -165,46 +161,45 @@ class OutputLedger { /** Finalize a successful absent-or-JSON completion against the combined cap. */ success(logs: string[], value?: CodeJsonValue): CodeRunResult { - if (value !== undefined && this.bytes + jsonBytes(value) > this.maxBytes) return this.limit(logs) + if (value !== undefined && jsonValueBytesUpTo(value, this.maxBytes - this.bytes) === undefined) return this.limit(logs) return { logs, ...value !== undefined ? { value } : {} } } /** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */ failure(logs: string[], error: CodeRunFailure): CodeRunResult { - if (this.bytes + Buffer.byteLength(JSON.stringify(error.message), 'utf8') > this.maxBytes) return this.limit(logs) + if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs) return { logs, error } } /** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */ limit(logs: string[]): CodeRunResult { const fullMessage = `outer output exceeded ${this.maxBytes} bytes` - const messageBytes = Buffer.byteLength(JSON.stringify(fullMessage), 'utf8') - const retained = [...logs] - let retainedBytes = jsonBytes(retained) + // The fixed diagnostic is ASCII, so every character is one byte plus the quotes. + const messageBytes = fullMessage.length + 2 + const retained: string[] = [] + let retainedBytes = 2 const logBudget = this.maxBytes - messageBytes - while (retained.length > 0 && retainedBytes > logBudget) { - const removed = retained.pop() - /* v8 ignore next -- the while guard proves pop cannot return undefined. */ - if (removed === undefined) throw new Error('output ledger lost its final log entry') + for (const text of logs) { const separatorBytes = retained.length > 0 ? 1 : 0 - retainedBytes -= Buffer.byteLength(JSON.stringify(removed), 'utf8') + separatorBytes - const prefix = truncateJsonStringBytes(removed, logBudget - retainedBytes - separatorBytes) - if (prefix.length > 0) { - retained.push(prefix) - retainedBytes += Buffer.byteLength(JSON.stringify(prefix), 'utf8') + separatorBytes - break + const availableBytes = logBudget - retainedBytes - separatorBytes + const stringBytes = jsonStringBytesUpTo(text, availableBytes) + if (stringBytes !== undefined) { + retained.push(text) + retainedBytes += stringBytes + separatorBytes + continue } - } - if (logBudget < 2) { - retained.length = 0 - retainedBytes = 2 + const prefix = truncateJsonStringBytes(text, availableBytes) + if (prefix.length > 0) { + const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes) + /* v8 ignore next -- truncateJsonStringBytes guarantees its returned prefix fits the same budget. */ + if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix') + retained.push(prefix) + retainedBytes += prefixBytes + separatorBytes + } + break } const availableMessageBytes = this.maxBytes - retainedBytes - // This fixed diagnostic is ASCII with no JSON escapes, so two bytes are - // the surrounding quotes and every retained character costs one byte. - const message = messageBytes <= availableMessageBytes - ? fullMessage - : fullMessage.slice(0, availableMessageBytes - 2) + const message = truncateJsonStringBytes(fullMessage, availableMessageBytes) return { logs: retained, error: { kind: 'output-limit', message } } } } @@ -410,12 +405,9 @@ export class WorkerCodeRuntime extends CodeRuntime { reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` }) return } - let args: CodeJsonValue | undefined - try { - args = snapshotJsonValue(message.args) as CodeJsonValue | undefined - } catch { - args = undefined - } + // Structured clone has already removed accessors and proxies, so the + // host can repeat the lossless snapshot without a reflective throw. + const args = snapshotJsonValue(message.args) as CodeJsonValue | undefined if (args === undefined) { reply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' }) return 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 dd3c3529f5..2110a4ab27 100644 --- a/packages/code-runtime/code-runtime-worker/src/output-json.ts +++ b/packages/code-runtime/code-runtime-worker/src/output-json.ts @@ -1,5 +1,7 @@ /** JSON string-prefix accounting for the outer-output ledger. @module @deepseek-ai/dsh-code-runtime-worker/output-json */ +import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime' + /** Control characters with a two-byte short JSON escape instead of `\u00XX`. */ const SHORT_ESCAPE_CODES = new Set([0x08, 0x09, 0x0a, 0x0c, 0x0d]) @@ -13,6 +15,69 @@ function serializedCharacterBytes(character: string): number { return Buffer.byteLength(character, 'utf8') } +/** + * Measure one JSON string without materializing its complete escaped form. + * @param text - the candidate string. + * @param maxBytes - largest serialized size the caller can admit. + * @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed. + */ +export function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined { + if (maxBytes < 2) return undefined + let bytes = 2 + for (const character of text) { + bytes += serializedCharacterBytes(character) + if (bytes > maxBytes) return undefined + } + return bytes +} + +/** + * Measure one lossless JSON value without allocating its serialized form. + * @param value - already validated lossless JSON. + * @param maxBytes - largest serialized size the caller can admit. + * @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 + } + + 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 + } + return bytes + } + + let entries = 0 + for (const [key, item] of Object.entries(value)) { + if (entries > 0 && ++bytes > maxBytes) 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 + } + return bytes +} + /** * Return the longest code-point-aligned prefix whose JSON string encoding, * including its surrounding quotes, fits `maxBytes`. @@ -23,7 +88,6 @@ function serializedCharacterBytes(character: string): number { */ export function truncateJsonStringBytes(text: string, maxBytes: number): string { if (maxBytes < 2) return '' - if (Buffer.byteLength(JSON.stringify(text), 'utf8') <= maxBytes) return text let bytes = 2 let end = 0 for (const character of text) { @@ -32,5 +96,5 @@ export function truncateJsonStringBytes(text: string, maxBytes: number): string bytes += cost end += character.length } - return text.slice(0, end) + return end === text.length ? text : text.slice(0, end) } 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 8340d6e74c..36f6ebcfad 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,10 +1,12 @@ -import { describe, expect, it } from 'vitest' -import { truncateJsonStringBytes } from '../src/output-json.ts' +import { describe, expect, it, vi } from 'vitest' +import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from '../src/output-json.ts' describe('truncateJsonStringBytes', () => { it('returns a fitting string whole and rejects budgets without JSON quotes', () => { expect(truncateJsonStringBytes('fits', 6)).toBe('fits') expect(truncateJsonStringBytes('x', 1)).toBe('') + expect(jsonStringBytesUpTo('fits', 6)).toBe(6) + expect(jsonStringBytesUpTo('fits', 5)).toBeUndefined() }) it('accounts every JSON escape and cuts only between complete code points', () => { @@ -15,4 +17,43 @@ describe('truncateJsonStringBytes', () => { expect(truncateJsonStringBytes(text, budget)).toBe(prefix) expect(Buffer.byteLength(JSON.stringify(truncateJsonStringBytes(text, budget)), 'utf8')).toBe(budget) }) + + it('bounds hostile strings without materializing their complete escaped form', () => { + const stringify = vi.spyOn(JSON, 'stringify').mockImplementation(() => { throw new Error('must not stringify') }) + try { + expect(jsonStringBytesUpTo('"'.repeat(10_000), 32)).toBeUndefined() + expect(truncateJsonStringBytes('"'.repeat(10_000), 32)).toBe('"'.repeat(15)) + } finally { + stringify.mockRestore() + } + }) +}) + +describe('jsonValueBytesUpTo', () => { + it('matches JSON serialization for every lossless value branch and stops at the cap', () => { + const value = { + empty: {}, + nil: null, + yes: true, + no: false, + number: 1.5, + text: '"\n😀', + array: [1, 'x'], + } + const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8') + + expect(jsonValueBytesUpTo(value, bytes)).toBe(bytes) + expect(jsonValueBytesUpTo(value, bytes - 1)).toBeUndefined() + expect(jsonValueBytesUpTo({}, 1)).toBeUndefined() + expect(jsonValueBytesUpTo(null, 3)).toBeUndefined() + expect(jsonValueBytesUpTo(10, 1)).toBeUndefined() + expect(jsonValueBytesUpTo(false, 4)).toBeUndefined() + expect(jsonValueBytesUpTo(new Array(1), 10)).toBeUndefined() + expect(jsonValueBytesUpTo([null], 5)).toBeUndefined() + expect(jsonValueBytesUpTo([0, 0], 3)).toBeUndefined() + expect(jsonValueBytesUpTo({ a: null, b: null }, 10)).toBeUndefined() + expect(jsonValueBytesUpTo({ long: null }, 2)).toBeUndefined() + expect(jsonValueBytesUpTo({ '': null }, 4)).toBeUndefined() + expect(jsonValueBytesUpTo({ a: null }, 9)).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 8bcee8389b..4d4bb4c3c1 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -401,6 +401,22 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => { expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200) }) + it('bounds one oversized forged log while retaining its fitting escaped prefix', async () => { + const { runtime } = await setup({ maxOutputBytes: 96 }) + const result = await runtime.run({ + program: ` + const { parentPort } = await import('node:worker_threads'); + parentPort.postMessage({ type: 'log', text: '"'.repeat(1_000_000) }); + for (;;) {} + `, + bindings: [], + }) + expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' }) + expect(result.logs).toHaveLength(1) + expect(result.logs[0]).toMatch(/^"+$/) + expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify('outer output exceeded 96 bytes'), 'utf8')).toBeLessThanOrEqual(96) + }) + it('drops a malformed forged done carrying both value and error', async () => { const { runtime } = await setup() const result = await runtime.run({