diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 11e3e3f23cc..a02c9a0dd4c 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -23,6 +23,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 arguments follow JSON serialization semantics before their schema applies (see the tools section). Own `__proto__` keys are dropped wherever a host object crosses to the host, so merging tool inputs or results cannot replace a prototype; `JSON.stringify` still emits the key, like JS, since a string cannot pollute. +- [x] Values `JSON.stringify` would flatten to `{}` cross the host boundary in a useful form instead: a Set as an + array, a RegExp as `"/source/flags"`, a URLSearchParams as its query string. A Map still crosses as `{}`. + Functions, generators, promises, and extension handles are rejected with a hint. In-program `JSON.stringify` + keeps JS behavior for all of these. - [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode. - [x] Tool calls through the host-provided `tools` tree only. - [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is diff --git a/packages/codemode/src/data.ts b/packages/codemode/src/data.ts index 10b809c5671..50d572d5901 100644 --- a/packages/codemode/src/data.ts +++ b/packages/codemode/src/data.ts @@ -59,13 +59,18 @@ export const fromData = (protos: Prototypes, value: unknown, label: string): unk * non-finite numbers become null, and array holes become null. `undefined` object properties are * dropped ("json") or become null ("result", for program results where the consumer must never see * undefined); a bare `undefined` follows the same rule. + * + * At the host boundary (tool arguments and program results) `__proto__` keys are dropped and values + * `JSON.stringify` would flatten to `{}` cross in a useful form instead: a Set as an array, a RegExp + * and URLSearchParams as their strings. `JSON.stringify` itself passes `boundary: false` to keep JS + * behavior. */ export const toData = ( value: unknown, label: string, undefinedAs: "json" | "result" = "json", - stripProto = true, -): unknown => copy(value, label, undefinedAs, 0, new Set(), undefined, stripProto) + boundary = true, +): unknown => copy(value, label, undefinedAs, 0, new Set(), undefined, boundary) // "program" and "data" build program objects; "json" and "result" build ordinary objects for the host. type Mode = "program" | "data" | "json" | "result" @@ -77,9 +82,9 @@ const copy = ( depth: number, seen: Set, protos?: Prototypes, - stripProto = true, + boundary = true, ): unknown => { - const next = (item: unknown) => copy(item, label, mode, depth + 1, seen, protos, stripProto) + const next = (item: unknown) => copy(item, label, mode, depth + 1, seen, protos, boundary) if (depth > MAX_VALUE_DEPTH) { throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) } @@ -132,6 +137,17 @@ const copy = ( if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null if (value instanceof ProgramURL) return value.url.href if (value instanceof URL) return value.href + if (boundary && protos === undefined) { + if (value instanceof ProgramRegExp) return String(value.regex) + if (value instanceof ProgramURLSearchParams) return value.params.toString() + if (value instanceof ProgramSet) { + if (seen.has(value)) throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`) + seen.add(value) + const copied = Array.from(value.set, (item) => next(item) ?? null) + seen.delete(value) + return copied + } + } // Remaining wrappers and their host counterparts serialize as empty objects, like JSON.stringify. if ( isWrapper(value) || @@ -161,7 +177,7 @@ const copy = ( defineHost(copied, "message", next(get(value, "message"))) } for (const [key, item] of entries(value)) { - if (stripProto && key === "__proto__") continue + if (boundary && key === "__proto__") continue const copiedItem = next(item) if (copiedItem === undefined && mode === "json") continue defineHost(copied, key, copiedItem) @@ -197,7 +213,7 @@ const copy = ( } const copied: Record = {} for (const [key, item] of Object.entries(value)) { - if (stripProto && key === "__proto__") continue + if (boundary && key === "__proto__") continue const copiedItem = next(item) if (copiedItem === undefined && mode === "json") continue defineHost(copied, key, copiedItem) diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index 54cbdf465b7..7983bbb349d 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -57,7 +57,7 @@ const stringify = (runner: Runner, args: Array): Effect.Effect typeof item === "string" || typeof item === "number") .map(String) : null - // A string cannot pollute, so __proto__ stays: JSON.stringify includes own __proto__ keys, like JS. + // Not a host boundary: __proto__ stays and Set/RegExp/URLSearchParams serialize as {}, like JS. const text = JSON.stringify(toData(args[0], "JSON.stringify value", "json", false), properties, indent) if (text !== undefined) checkStringLength(text.length) return Effect.succeed(text) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 952baa504cc..a872819c1a7 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -394,8 +394,8 @@ describe("RegExp", () => { }) }) - test("regexes serialize to {} at the boundary, like JSON", async () => { - expect(await value(`return /a/`)).toEqual({}) + test("regexes cross the boundary as their literal form; JSON.stringify keeps {} like JS", async () => { + expect(await value(`return [/a/, { r: /b/gi }]`)).toEqual(["/a/", { r: "/b/gi" }]) expect(await value(`return JSON.stringify({ r: /a/g })`)).toBe('{"r":{}}') }) @@ -521,7 +521,7 @@ describe("URL and URI helpers", () => { cannotParse: false, parsed: "https://example.test/users", invalidIsTypeError: true, - boundary: ["https://example.test/a", {}], + boundary: ["https://example.test/a", "q=one"], json: '{"url":"https://example.test/a","params":{}}', }) }) @@ -715,8 +715,9 @@ describe("Set", () => { ).toBe(6) }) - test("sets serialize to {} at the boundary, like JSON", async () => { - expect(await value(`return { s: new Set([1]) }`)).toEqual({ s: {} }) + test("sets cross the boundary as arrays; JSON.stringify keeps {} like JS", async () => { + expect(await value(`return { s: new Set([1, "a", { n: 1 }, undefined]) }`)).toEqual({ s: [1, "a", { n: 1 }, null] }) + expect(await value(`return JSON.stringify(new Set([1]))`)).toBe("{}") }) }) diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index 2c9e90016f3..db9cab3a589 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -312,3 +312,28 @@ describe("tool argument prototype safety", () => { expect(await value(runtime, `return [{ __proto__: 1 }]`)).toEqual([{}]) }) }) + +describe("tool arguments cross in a useful form where JSON.stringify would give {}", () => { + test("Set, RegExp, and URLSearchParams; Map stays {} like JSON", async () => { + let seen: unknown + const runtime = CodeMode.make({ + tools: { + inspect: Tool.make({ + description: "Inspect", + input: Schema.Struct({ v: Schema.Unknown }), + output: Schema.Unknown, + execute: (input) => + Effect.sync(() => { + seen = input.v + return null + }), + }), + }, + }) + await value( + runtime, + `return await tools.inspect({ v: { s: new Set([1, 2]), r: /x/g, p: new URLSearchParams("a=1&b=2"), m: new Map([["k", 1]]) } })`, + ) + expect(seen).toEqual({ s: [1, 2], r: "/x/g", p: "a=1&b=2", m: {} }) + }) +})