From 0d851adc3e9b64e3935ed9f978a6072ad8a92c53 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:58:38 +0800 Subject: [PATCH] fix(session): traverse JSON values iteratively --- ...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/cordis/tool-cordis/src/guard.ts | 29 ++- .../cordis/tool-cordis/tests/mount.spec.ts | 1 + packages/core/session/README.md | 2 +- packages/core/session/src/json.ts | 220 +++++++++--------- packages/core/session/tests/json.spec.ts | 13 ++ 8 files changed, 161 insertions(+), 112 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 0cd154e190..ae5592ef01 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: 94c3f5aa5fcb84abddc58e8fd298188b3284f7ea -2026-07-20-unified-json-value-schema-dsl.zh.md: d8362c2c9987689fbd812b6c16aae815f56062e1 +2026-07-20-unified-json-value-schema-dsl.md: 6a3dc21a7ae76ba8f84f5b3edf8306285a02a683 +2026-07-20-unified-json-value-schema-dsl.zh.md: 77ac599e6713563528d0382dfecff83071488930 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 94c3f5aa5f..6a3dc21a7a 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. Intrinsic plain Object and Array containers remain plain across JavaScript realms; subclasses remain exotic. +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. Validation and snapshot traversal are iterative, so valid nesting is limited by available memory rather than the JavaScript call stack. 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 d8362c2c99..77ac599e67 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 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。 +显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue` 和 `InferArgs

` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。校验和快照遍历均以迭代方式执行,因此合法嵌套的深度上限由可用内存决定,而非 JavaScript 调用栈。 对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。 diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index e8eb19e524..2ebba0fbd8 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -29,9 +29,34 @@ type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown } function isPlainRecord(value: unknown): value is Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false const prototype: unknown = Object.getPrototypeOf(value) - return prototype === null || Object.getPrototypeOf(prototype) === null + return prototype === null + || typeof prototype === 'object' + && Object.getPrototypeOf(prototype) === null + && hasIntrinsicConstructor(prototype, 'Object') } +/* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe intrinsic test */ +/** 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 an array uses one realm's intrinsic Array prototype rather than a subclass. */ +function hasPlainArrayPrototype(value: unknown[]): boolean { + const prototype: unknown = Object.getPrototypeOf(value) + if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false + const objectPrototype: unknown = Object.getPrototypeOf(prototype) + return typeof objectPrototype === 'object' + && objectPrototype !== null + && Object.getPrototypeOf(objectPrototype) === null + && hasIntrinsicConstructor(objectPrototype, 'Object') +} +/* jscpd:ignore-end */ + /** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */ function cloneJson(value: unknown, path: string, seen = new Set()): unknown { if (value === null || typeof value === 'string' || typeof value === 'boolean') return value @@ -44,7 +69,7 @@ function cloneJson(value: unknown, path: string, seen = new Set()): unkn seen.add(value) try { if (Array.isArray(value)) { - if (Reflect.ownKeys(value).length !== value.length + 1) { + if (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) { throw new Error(`harness.defineTool ${path} must be lossless JSON data`) } const output: unknown[] = [] diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index 64301f2172..22df219b1b 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -348,6 +348,7 @@ describe('cordis_mount', () => { ['parameters: { value: { type: \'json\', default: Object.defineProperty({}, \'hidden\', { value: true }) } }', 'parameters.value.default must be lossless JSON data'], ['parameters: { value: { type: \'json\', default: { [Symbol(\'hidden\')]: true } } }', 'parameters.value.default must be lossless JSON data'], ['parameters: { value: { type: \'json\', default: new (class DefaultValue { constructor() { this.ok = true } })() } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: new (class DefaultList extends Array {})() } }', 'parameters.value.default must be lossless JSON data'], ['parameters: { value: { type: \'json\', default: new Date(0) } }', 'parameters.value.default must be lossless JSON data'], ])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => { const ctx = await setup() diff --git a/packages/core/session/README.md b/packages/core/session/README.md index bba2a21e06..e01e8072c3 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -46,7 +46,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. ### Lossless JSON utilities -Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` recursively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization. +Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` iteratively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization without imposing a call-stack depth limit. ### Surface types diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index f7609a1f46..e1452457fd 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -50,79 +50,127 @@ function enumerableStringKeys(value: object): string[] | undefined { return keys as string[] } +type SnapshotDestination = + | { kind: 'root' } + | { kind: 'array'; target: JsonValue[]; index: number } + | { kind: 'object'; target: { [key: string]: JsonValue }; key: string } + +type JsonWalkTask = + | { kind: 'visit'; value: unknown; destination?: SnapshotDestination } + | { kind: 'array-item'; source: unknown[]; index: number; target?: JsonValue[] } + | { kind: 'object-property'; source: Record; key: string; target?: { [key: string]: JsonValue } } + | { kind: 'leave'; source: object } + +/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */ +function walkJsonValue(value: unknown, detach: boolean): JsonValue | true | undefined { + const ancestors = new Set() + let root: JsonValue | undefined + const assign = (destination: SnapshotDestination | undefined, item: JsonValue): void => { + if (destination === undefined) return + if (destination.kind === 'root') { + root = item + } else if (destination.kind === 'array') { + destination.target[destination.index] = item + } else { + Object.defineProperty(destination.target, destination.key, { + value: item, + enumerable: true, + configurable: true, + writable: true, + }) + } + } + + const tasks: JsonWalkTask[] = [{ + kind: 'visit', + value, + ...(detach ? { destination: { kind: 'root' } as const } : {}), + }] + for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) { + if (task.kind === 'leave') { + ancestors.delete(task.source) + continue + } + if (task.kind === 'array-item') { + if (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return undefined + tasks.push({ + kind: 'visit', + value: task.source[task.index], + ...(task.target === undefined ? {} : { destination: { kind: 'array', target: task.target, index: task.index } as const }), + }) + continue + } + if (task.kind === 'object-property') { + tasks.push({ + kind: 'visit', + value: task.source[task.key], + ...(task.target === undefined ? {} : { destination: { kind: 'object', target: task.target, key: task.key } as const }), + }) + continue + } + + const current = task.value + if (current === null) { + assign(task.destination, null) + continue + } + if (typeof current === 'boolean' || typeof current === 'string') { + assign(task.destination, current) + continue + } + if (typeof current === 'number') { + if (!Number.isFinite(current) || Object.is(current, -0)) return undefined + assign(task.destination, current) + continue + } + if (typeof current !== 'object') return undefined + if (ancestors.has(current)) return undefined + + if (Array.isArray(current)) { + if (!hasPlainArrayPrototype(current)) return undefined + const length = current.length + if (Reflect.ownKeys(current).length !== length + 1) return undefined + const target = detach ? [] as JsonValue[] : undefined + if (target !== undefined) assign(task.destination, target) + ancestors.add(current) + tasks.push({ kind: 'leave', source: current }) + for (let index = length - 1; index >= 0; index--) { + tasks.push({ kind: 'array-item', source: current, index, ...(target === undefined ? {} : { target }) }) + } + continue + } + + if (!hasPlainObjectPrototype(current)) return undefined + const keys = enumerableStringKeys(current) + if (keys === undefined) return undefined + const target = detach ? {} as { [key: string]: JsonValue } : undefined + if (target !== undefined) assign(task.destination, target) + ancestors.add(current) + tasks.push({ kind: 'leave', source: current }) + for (let index = keys.length - 1; index >= 0; index--) { + const key = keys[index] + /* v8 ignore next -- the loop is bounded by the captured key count. */ + if (key === undefined) return undefined + tasks.push({ kind: 'object-property', source: current as Record, key, ...(target === undefined ? {} : { target }) }) + } + } + return detach ? root : true +} + /** * Validate and detach lossless JSON in one read per property, so a stateful - * getter cannot change between validation and copying. Accepts ordinary arrays, - * plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic, - * exotic, negative-zero, and non-finite values. Getter throws propagate. + * getter cannot change between validation and copying. Traversal is iterative, + * so valid nesting is bounded by available memory rather than the JavaScript + * call stack. Accepts ordinary arrays, plain or null-prototype objects, and JSON + * scalars; rejects sparse, cyclic, exotic, negative-zero, and non-finite values. + * Getter throws propagate. * * @param value - the candidate value to validate and detach. * @returns the detached snapshot, or `undefined` when the value is not * losslessly JSON-serializable. */ export function snapshotJsonValue(value: T): T | undefined { - const ancestors = new Set() - - const visit = (current: unknown): JsonValue | undefined => { - if (current === null) return null - switch (typeof current) { - case 'boolean': - case 'string': - return current - case 'number': - return Number.isFinite(current) && !Object.is(current, -0) ? current : undefined - case 'bigint': - case 'function': - case 'symbol': - case 'undefined': - return undefined - case 'object': - break - } - - if (ancestors.has(current)) return undefined - ancestors.add(current) - try { - if (Array.isArray(current)) { - 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 - // structured clone, including symbols and non-enumerable properties. - if (Reflect.ownKeys(current).length !== length + 1) return undefined - const snapshot: JsonValue[] = [] - for (let index = 0; index < length; index++) { - if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined - const item = visit(current[index]) - if (item === undefined) return undefined - snapshot.push(item) - } - return snapshot - } - - if (!hasPlainObjectPrototype(current)) return undefined - const keys = enumerableStringKeys(current) - if (keys === undefined) return undefined - const snapshot: { [key: string]: JsonValue } = {} - for (const key of keys) { - const item = visit((current as Record)[key]) - if (item === undefined) return undefined - // Define the key as data so a JSON field literally named "__proto__" - // cannot mutate the snapshot's prototype through ordinary assignment. - Object.defineProperty(snapshot, key, { - value: item, - enumerable: true, - configurable: true, - writable: true, - }) - } - return snapshot - } finally { - ancestors.delete(current) - } - } - - return visit(value) as T | undefined + return walkJsonValue(value, true) as T | undefined } /** @@ -130,46 +178,8 @@ export function snapshotJsonValue(value: T): T | undefined { * detaching it. Only own enumerable string properties participate; `toJSON` * is ignored and getters run, so persistence boundaries use the snapshotter. * @param value - the candidate event data to test. - * @param seen - current recursion path; callers omit it. * @returns whether `value` survives JSON round-trip losslessly. */ -export function isJsonValue(value: unknown, seen: Set = new Set()): boolean { - if (value === null) return true - switch (typeof value) { - case 'boolean': - case 'string': - return true - case 'number': - return Number.isFinite(value) && !Object.is(value, -0) - case 'bigint': - case 'function': - case 'symbol': - case 'undefined': - return false - case 'object': - break // handled below - } - // object - if (seen.has(value)) return false // circular - seen.add(value) - try { - if (Array.isArray(value)) { - 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 - // lossily. Require every index 0..length-1 to be an OWN property. - for (let i = 0; i < value.length; i++) { - if (!Object.prototype.hasOwnProperty.call(value, i)) return false - if (!isJsonValue(value[i], seen)) return false - } - return true - } - // Plain object only (reject Map/Set/Date/class instances). - if (!hasPlainObjectPrototype(value)) return false - const keys = enumerableStringKeys(value) - return keys !== undefined && keys.every(key => isJsonValue((value as Record)[key], seen)) - } finally { - seen.delete(value) - } +export function isJsonValue(value: unknown): boolean { + return walkJsonValue(value, false) === true } diff --git a/packages/core/session/tests/json.spec.ts b/packages/core/session/tests/json.spec.ts index 90b3ec6928..e8680142f3 100644 --- a/packages/core/session/tests/json.spec.ts +++ b/packages/core/session/tests/json.spec.ts @@ -80,6 +80,19 @@ describe('snapshotJsonValue', () => { expect(arrayReads).toBe(1) }) + it('accepts deeply nested valid JSON without using the JavaScript call stack', () => { + let value: JsonValue = 'leaf' + for (let depth = 0; depth < 5_000; depth++) value = [value] + + expect(isJsonValue(value)).toBe(true) + let cursor: JsonValue | undefined = snapshotJsonValue(value) + for (let depth = 0; depth < 5_000; depth++) { + expect(Array.isArray(cursor)).toBe(true) + cursor = Array.isArray(cursor) ? cursor[0] : undefined + } + expect(cursor).toBe('leaf') + }) + it('rejects exotic containers, sparse or decorated arrays, cycles, and invalid children', () => { class ExoticObject { readonly value = 1