Skip to content

Commit 00a2fc9

Browse files
authored
feat(codemode): cross Set, RegExp, and URLSearchParams to the host in a useful form (anomalyco#49065)
1 parent a54f2c1 commit 00a2fc9

5 files changed

Lines changed: 58 additions & 12 deletions

File tree

packages/codemode/interpreter-support.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
2323
arguments follow JSON serialization semantics before their schema applies (see the tools section). Own
2424
`__proto__` keys are dropped wherever a host object crosses to the host, so merging tool inputs or results
2525
cannot replace a prototype; `JSON.stringify` still emits the key, like JS, since a string cannot pollute.
26+
- [x] Values `JSON.stringify` would flatten to `{}` cross the host boundary in a useful form instead: a Set as an
27+
array, a RegExp as `"/source/flags"`, a URLSearchParams as its query string. A Map still crosses as `{}`.
28+
Functions, generators, promises, and extension handles are rejected with a hint. In-program `JSON.stringify`
29+
keeps JS behavior for all of these.
2630
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
2731
- [x] Tool calls through the host-provided `tools` tree only.
2832
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is

packages/codemode/src/data.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,18 @@ export const fromData = (protos: Prototypes, value: unknown, label: string): unk
5959
* non-finite numbers become null, and array holes become null. `undefined` object properties are
6060
* dropped ("json") or become null ("result", for program results where the consumer must never see
6161
* undefined); a bare `undefined` follows the same rule.
62+
*
63+
* At the host boundary (tool arguments and program results) `__proto__` keys are dropped and values
64+
* `JSON.stringify` would flatten to `{}` cross in a useful form instead: a Set as an array, a RegExp
65+
* and URLSearchParams as their strings. `JSON.stringify` itself passes `boundary: false` to keep JS
66+
* behavior.
6267
*/
6368
export const toData = (
6469
value: unknown,
6570
label: string,
6671
undefinedAs: "json" | "result" = "json",
67-
stripProto = true,
68-
): unknown => copy(value, label, undefinedAs, 0, new Set(), undefined, stripProto)
72+
boundary = true,
73+
): unknown => copy(value, label, undefinedAs, 0, new Set(), undefined, boundary)
6974

7075
// "program" and "data" build program objects; "json" and "result" build ordinary objects for the host.
7176
type Mode = "program" | "data" | "json" | "result"
@@ -77,9 +82,9 @@ const copy = (
7782
depth: number,
7883
seen: Set<object>,
7984
protos?: Prototypes,
80-
stripProto = true,
85+
boundary = true,
8186
): unknown => {
82-
const next = (item: unknown) => copy(item, label, mode, depth + 1, seen, protos, stripProto)
87+
const next = (item: unknown) => copy(item, label, mode, depth + 1, seen, protos, boundary)
8388
if (depth > MAX_VALUE_DEPTH) {
8489
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
8590
}
@@ -132,6 +137,17 @@ const copy = (
132137
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null
133138
if (value instanceof ProgramURL) return value.url.href
134139
if (value instanceof URL) return value.href
140+
if (boundary && protos === undefined) {
141+
if (value instanceof ProgramRegExp) return String(value.regex)
142+
if (value instanceof ProgramURLSearchParams) return value.params.toString()
143+
if (value instanceof ProgramSet) {
144+
if (seen.has(value)) throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`)
145+
seen.add(value)
146+
const copied = Array.from(value.set, (item) => next(item) ?? null)
147+
seen.delete(value)
148+
return copied
149+
}
150+
}
135151
// Remaining wrappers and their host counterparts serialize as empty objects, like JSON.stringify.
136152
if (
137153
isWrapper(value) ||
@@ -161,7 +177,7 @@ const copy = (
161177
defineHost(copied, "message", next(get(value, "message")))
162178
}
163179
for (const [key, item] of entries(value)) {
164-
if (stripProto && key === "__proto__") continue
180+
if (boundary && key === "__proto__") continue
165181
const copiedItem = next(item)
166182
if (copiedItem === undefined && mode === "json") continue
167183
defineHost(copied, key, copiedItem)
@@ -197,7 +213,7 @@ const copy = (
197213
}
198214
const copied: Record<string, unknown> = {}
199215
for (const [key, item] of Object.entries(value)) {
200-
if (stripProto && key === "__proto__") continue
216+
if (boundary && key === "__proto__") continue
201217
const copiedItem = next(item)
202218
if (copiedItem === undefined && mode === "json") continue
203219
defineHost(copied, key, copiedItem)

packages/codemode/src/stdlib/json.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<un
5757
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
5858
.map(String)
5959
: null
60-
// A string cannot pollute, so __proto__ stays: JSON.stringify includes own __proto__ keys, like JS.
60+
// Not a host boundary: __proto__ stays and Set/RegExp/URLSearchParams serialize as {}, like JS.
6161
const text = JSON.stringify(toData(args[0], "JSON.stringify value", "json", false), properties, indent)
6262
if (text !== undefined) checkStringLength(text.length)
6363
return Effect.succeed(text)

packages/codemode/test/stdlib.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -394,8 +394,8 @@ describe("RegExp", () => {
394394
})
395395
})
396396

397-
test("regexes serialize to {} at the boundary, like JSON", async () => {
398-
expect(await value(`return /a/`)).toEqual({})
397+
test("regexes cross the boundary as their literal form; JSON.stringify keeps {} like JS", async () => {
398+
expect(await value(`return [/a/, { r: /b/gi }]`)).toEqual(["/a/", { r: "/b/gi" }])
399399
expect(await value(`return JSON.stringify({ r: /a/g })`)).toBe('{"r":{}}')
400400
})
401401

@@ -521,7 +521,7 @@ describe("URL and URI helpers", () => {
521521
cannotParse: false,
522522
parsed: "https://example.test/users",
523523
invalidIsTypeError: true,
524-
boundary: ["https://example.test/a", {}],
524+
boundary: ["https://example.test/a", "q=one"],
525525
json: '{"url":"https://example.test/a","params":{}}',
526526
})
527527
})
@@ -715,8 +715,9 @@ describe("Set", () => {
715715
).toBe(6)
716716
})
717717

718-
test("sets serialize to {} at the boundary, like JSON", async () => {
719-
expect(await value(`return { s: new Set([1]) }`)).toEqual({ s: {} })
718+
test("sets cross the boundary as arrays; JSON.stringify keeps {} like JS", async () => {
719+
expect(await value(`return { s: new Set([1, "a", { n: 1 }, undefined]) }`)).toEqual({ s: [1, "a", { n: 1 }, null] })
720+
expect(await value(`return JSON.stringify(new Set([1]))`)).toBe("{}")
720721
})
721722
})
722723

packages/codemode/test/tool-paths.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,28 @@ describe("tool argument prototype safety", () => {
312312
expect(await value(runtime, `return [{ __proto__: 1 }]`)).toEqual([{}])
313313
})
314314
})
315+
316+
describe("tool arguments cross in a useful form where JSON.stringify would give {}", () => {
317+
test("Set, RegExp, and URLSearchParams; Map stays {} like JSON", async () => {
318+
let seen: unknown
319+
const runtime = CodeMode.make({
320+
tools: {
321+
inspect: Tool.make({
322+
description: "Inspect",
323+
input: Schema.Struct({ v: Schema.Unknown }),
324+
output: Schema.Unknown,
325+
execute: (input) =>
326+
Effect.sync(() => {
327+
seen = input.v
328+
return null
329+
}),
330+
}),
331+
},
332+
})
333+
await value(
334+
runtime,
335+
`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]]) } })`,
336+
)
337+
expect(seen).toEqual({ s: [1, 2], r: "/x/g", p: "a=1&b=2", m: {} })
338+
})
339+
})

0 commit comments

Comments
 (0)