feat(codemode): expand standard library support (#35566)

This commit is contained in:
Aiden Cline 2026-07-06 10:36:02 -05:00 committed by GitHub
commit 5b2d90a441
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 833 additions and 1248 deletions

View file

@ -16,7 +16,16 @@ import {
} from "./tool-runtime.js"
import type { Definition } from "./tool.js"
import { ToolError } from "./tool-error.js"
import { isSandboxValue, SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
import {
isSandboxValue,
SandboxDate,
SandboxMap,
SandboxPromise,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
} from "./values.js"
/** A tool call admitted during an execution. */
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
@ -184,7 +193,7 @@ type StatementResult =
| { kind: "continue" }
type MemberReference = {
target: SafeObject | Array<unknown>
target: SafeObject | Array<unknown> | SandboxURL
key: string | number
}
@ -217,7 +226,18 @@ class PromiseMethodReference {
// A built-in global namespace (`Object`, `Math`, `JSON`, `Array`, ...); members resolve to a
// GlobalMethodReference, except known constants (e.g. `Math.PI`) which resolve to a value.
type GlobalNamespaceName = "Object" | "Math" | "JSON" | "Array" | "console" | "Date" | "RegExp" | "Map" | "Set"
type GlobalNamespaceName =
| "Object"
| "Math"
| "JSON"
| "Array"
| "console"
| "Date"
| "RegExp"
| "Map"
| "Set"
| "URL"
| "URLSearchParams"
class GlobalNamespace {
constructor(readonly name: GlobalNamespaceName) {}
@ -234,6 +254,10 @@ class CoercionFunction {
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
}
class UriFunction {
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
}
class ProgramThrow {
constructor(readonly value: unknown) {}
}
@ -355,7 +379,7 @@ const errorConstructors = new Set([
"URIError",
])
const valueConstructors = new Set(["Date", "RegExp", "Map", "Set"])
const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"])
const dateMethods = new Set([
"getTime",
@ -400,10 +424,52 @@ const regexpProperties = new Set([
const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
const urlProperties = new Set([
"href",
"origin",
"protocol",
"username",
"password",
"host",
"hostname",
"port",
"pathname",
"search",
"hash",
])
const urlWritableProperties = new Set([
"href",
"protocol",
"username",
"password",
"host",
"hostname",
"port",
"pathname",
"search",
"hash",
])
const urlMethods = new Set(["toString", "toJSON"])
const urlStatics = new Set(["canParse", "parse"])
const urlSearchParamsMethods = new Set([
"append",
"delete",
"get",
"getAll",
"has",
"set",
"sort",
"forEach",
"keys",
"values",
"entries",
"toString",
])
const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
const supportedSyntaxMessage =
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError(
@ -662,6 +728,7 @@ const isRuntimeReference = (value: unknown): boolean =>
value instanceof PromiseMethodReference ||
value instanceof SandboxPromise ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof ErrorConstructorReference ||
isSandboxValue(value)
@ -677,7 +744,7 @@ const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boo
return contains
}
// Like containsRuntimeReference, but sandbox value types (Date/RegExp/Map/Set) count as data:
// Like containsRuntimeReference, but sandbox standard-library values count as data:
// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive
// coercion) rather than rejecting them as opaque interpreter machinery.
const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
@ -707,6 +774,7 @@ const typeofValue = (value: unknown): string => {
value instanceof ErrorConstructorReference
)
return "function"
if (value instanceof UriFunction) return "function"
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
if (value instanceof GlobalNamespace) {
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
@ -733,6 +801,10 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
return lhs instanceof SandboxMap
case "Set":
return lhs instanceof SandboxSet
case "URL":
return lhs instanceof SandboxURL
case "URLSearchParams":
return lhs instanceof SandboxURLSearchParams
case "Array":
return Array.isArray(lhs)
case "Object":
@ -746,7 +818,7 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
return false
}
throw new InterpreterRuntimeError(
"The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, Array, Object, or Promise.",
"The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, or Promise.",
node,
)
}
@ -885,14 +957,6 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break
case "replace":
case "replaceAll": {
if (args[0] instanceof CodeModeFunction || args[1] instanceof CodeModeFunction) {
throw new InterpreterRuntimeError(
`String.${name} does not support function replacers in CodeMode; use match/matchAll and rebuild the string instead.`,
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
}
if (args[0] instanceof SandboxRegExp) {
const pattern = (args[0] as SandboxRegExp).regex
const replacement = str(1)
@ -1028,6 +1092,8 @@ const coerceToString = (value: unknown): string => {
if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof SandboxMap) return "[object Map]"
if (value instanceof SandboxSet) return "[object Set]"
if (value instanceof SandboxURL) return value.url.href
if (value instanceof SandboxURLSearchParams) return value.params.toString()
if (typeof value === "object") {
return Array.isArray(value)
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
@ -1072,7 +1138,7 @@ const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNo
const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
const requireObject = (): Record<string, unknown> => {
const value = boundedData(args[0], `Object.${name} input`)
// Sandbox values (Date/RegExp/Map/Set) have no own enumerable properties in JS, so the
// Sandbox standard-library values have no own enumerable properties in JS, so the
// Object.* helpers see them as empty objects - never their interpreter internals.
if (isSandboxValue(value)) return {}
if (value === null || typeof value !== "object" || Array.isArray(value)) {
@ -1124,6 +1190,11 @@ const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode):
for (const [key, item] of (args[0] as SandboxMap).map.entries()) guardedSet(out, coerceToString(key), item)
return out
}
if (args[0] instanceof SandboxURLSearchParams) {
const out: Record<string, unknown> = Object.create(null)
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
return out
}
const pairs = boundedData(args[0], "Object.fromEntries input")
if (!Array.isArray(pairs))
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
@ -1241,6 +1312,9 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
if (args[0] instanceof SandboxMap)
return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item])
if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values())
if (args[0] instanceof SandboxURLSearchParams) {
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
}
const source = boundedData(args[0], "Array.from input")
if (typeof source === "string") return Array.from(source)
if (Array.isArray(source)) return [...source]
@ -1385,6 +1459,52 @@ const invokeRegExpMethod = (value: SandboxRegExp, name: string, args: Array<unkn
}
}
const uriArgument = (value: unknown, label: string): string => coerceToString(boundedData(value, label))
const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node: AstNode): string => {
const value = uriArgument(args[0], `${ref.name} input`)
try {
switch (ref.name) {
case "encodeURI":
return encodeURI(value)
case "encodeURIComponent":
return encodeURIComponent(value)
case "decodeURI":
return decodeURI(value)
case "decodeURIComponent":
return decodeURIComponent(value)
}
} catch (error) {
throw new InterpreterRuntimeError(
`${ref.name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`,
node,
).as("URIError")
}
}
const urlArgument = (value: unknown, label: string): string =>
value instanceof SandboxURL ? value.url.href : uriArgument(value, label)
const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node)
if (args.length === 0) {
throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node).as("TypeError")
}
const input = urlArgument(args[0], `URL.${name} input`)
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
try {
const url = new URL(input, base)
return name === "canParse" ? true : new SandboxURL(url)
} catch {
return name === "canParse" ? false : null
}
}
const invokeURLMethod = (value: SandboxURL, name: string, node: AstNode): string => {
if (name === "toString" || name === "toJSON") return value.url.href
throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
}
const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
if (ref.namespace === "console")
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
@ -1393,12 +1513,18 @@ const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, no
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
if (ref.namespace === "Date") {
if (!dateStatics.has(ref.name))
throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node)
return invokeDateStatic(ref.name, args, node)
}
if (ref.namespace === "RegExp" || ref.namespace === "Map" || ref.namespace === "Set") {
if (
ref.namespace === "RegExp" ||
ref.namespace === "Map" ||
ref.namespace === "Set" ||
ref.namespace === "URLSearchParams"
) {
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
}
return invokeJsonMethod(ref.name, args, node)
@ -1411,6 +1537,8 @@ const spreadItems = (spread: unknown): Array<unknown> | undefined => {
if (spread instanceof SandboxMap)
return Array.from(spread.map.entries(), ([key, item]): Array<unknown> => [key, item])
if (spread instanceof SandboxSet) return Array.from(spread.set.values())
if (spread instanceof SandboxURLSearchParams)
return Array.from(spread.params.entries(), ([key, value]): Array<unknown> => [key, value])
return undefined
}
@ -1485,6 +1613,12 @@ class Interpreter<R> {
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") })
globalScope.set("URL", { mutable: false, value: new GlobalNamespace("URL") })
globalScope.set("URLSearchParams", { mutable: false, value: new GlobalNamespace("URLSearchParams") })
globalScope.set("encodeURI", { mutable: false, value: new UriFunction("encodeURI") })
globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") })
globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") })
globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") })
// Error constructors are real values, so `x instanceof Error` works and `Error("msg")`
// (with or without `new`) constructs a branded { name, message } error object.
for (const name of errorConstructors) {
@ -2331,8 +2465,12 @@ class Interpreter<R> {
return self.constructRegExp(args, node)
case "Map":
return self.constructMap(args[0], node)
default:
case "Set":
return self.constructSet(args[0], node)
case "URL":
return self.constructURL(args, node)
default:
return self.constructURLSearchParams(args[0], node)
}
})
}
@ -2421,6 +2559,67 @@ class Interpreter<R> {
return target
}
private constructURL(args: Array<unknown>, node: AstNode): SandboxURL {
if (args.length === 0) {
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
"TypeError",
)
}
const input = urlArgument(args[0], "new URL input")
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
try {
return new SandboxURL(new URL(input, base))
} catch {
throw new InterpreterRuntimeError(
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
node,
).as("TypeError")
}
}
private constructURLSearchParams(init: unknown, node: AstNode): SandboxURLSearchParams {
if (init === undefined) return new SandboxURLSearchParams(new URLSearchParams())
if (init instanceof SandboxURLSearchParams) {
return new SandboxURLSearchParams(new URLSearchParams(init.params))
}
if (typeof init === "string") return new SandboxURLSearchParams(new URLSearchParams(init))
if (init === null || typeof init === "number" || typeof init === "boolean") {
return new SandboxURLSearchParams(new URLSearchParams(coerceToString(init)))
}
if (init instanceof SandboxMap) {
return this.constructURLSearchParams(
Array.from(init.map.entries(), ([key, value]) => [key, value]),
node,
)
}
if (Array.isArray(init)) {
const entries = init.map((pair) => {
if (!Array.isArray(pair) || pair.length !== 2) {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects an array of [name, value] pairs.",
node,
).as("TypeError")
}
return [uriArgument(pair[0], "URLSearchParams name"), uriArgument(pair[1], "URLSearchParams value")] as [
string,
string,
]
})
return new SandboxURLSearchParams(new URLSearchParams(entries))
}
if (isSandboxValue(init)) return new SandboxURLSearchParams(new URLSearchParams())
const data = boundedData(init, "new URLSearchParams input")
if (data === null || typeof data !== "object") {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects a query string, data object, array of pairs, or URLSearchParams.",
node,
).as("TypeError")
}
return new SandboxURLSearchParams(
new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))),
)
}
private evaluateBinaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
const operator = getString(node, "operator")
const self = this
@ -2699,6 +2898,9 @@ class Interpreter<R> {
if (callable instanceof CoercionFunction) {
return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`)
}
if (callable instanceof UriFunction) {
return invokeUriFunction(callable, args, node)
}
// `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS.
if (callable instanceof ErrorConstructorReference) {
return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0]))
@ -2759,6 +2961,8 @@ class Interpreter<R> {
if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]"
if (value instanceof SandboxDate) return coerceToString(value)
if (value instanceof SandboxRegExp) return coerceToString(value)
if (value instanceof SandboxURL) return coerceToString(value)
if (value instanceof SandboxURLSearchParams) return coerceToString(value)
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
if (value instanceof SandboxMap) {
@ -3026,6 +3230,12 @@ class Interpreter<R> {
node: AstNode,
): Effect.Effect<unknown, unknown, R> {
if (typeof ref.receiver === "string") {
if (
(ref.name === "replace" || ref.name === "replaceAll") &&
(args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction)
) {
return this.invokeStringReplacer(ref.receiver, ref.name, args, node)
}
return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
}
if (typeof ref.receiver === "number") {
@ -3046,23 +3256,95 @@ class Interpreter<R> {
if (ref.receiver instanceof SandboxSet) {
return this.invokeSetMethod(ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof SandboxURL) {
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
}
if (ref.receiver instanceof SandboxURLSearchParams) {
return this.invokeURLSearchParamsMethod(ref.receiver, ref.name, args, node)
}
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
}
// Runs a Map/Set callback (forEach) accepting a user function or a builtin coercion,
private invokeStringReplacer(
value: string,
name: "replace" | "replaceAll",
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> {
const apply = this.applyCollectionCallback(args[1], `String.${name}`, node)
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
const collect = (...callbackArgs: Array<unknown>): string => {
const match = callbackArgs[0]
const groups = callbackArgs[callbackArgs.length - 1]
const hasGroups = groups !== null && typeof groups === "object"
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
if (typeof match !== "string" || typeof offset !== "number") {
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
}
if (hasGroups) {
const safeGroups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(groups)) {
if (!isBlockedMember(key)) safeGroups[key] = group
}
callbackArgs[callbackArgs.length - 1] = safeGroups
}
matches.push({ match, offset, args: callbackArgs })
return match
}
const pattern = args[0]
if (pattern instanceof SandboxRegExp) {
if (name === "replaceAll" && !pattern.regex.global) {
throw new InterpreterRuntimeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
node,
)
}
if (name === "replace") value.replace(pattern.regex, collect)
else value.replaceAll(pattern.regex, collect)
} else {
if (typeof pattern !== "string") {
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
}
if (name === "replace") value.replace(pattern, collect)
else value.replaceAll(pattern, collect)
}
return Effect.gen(function* () {
const output: Array<string> = []
let end = 0
for (const match of matches) {
output.push(
value.slice(end, match.offset),
coerceToString(boundedData(yield* apply(match.args), `String.${name} replacer result`)),
)
end = match.offset + match.match.length
}
output.push(value.slice(end))
return boundedData(output.join(""), `String.${name} result`)
})
}
// Runs a collection callback accepting a user function or supported builtin callable,
// mirroring the array-method callback contract.
private applyCollectionCallback(
callback: unknown,
name: string,
node: AstNode,
): (args: Array<unknown>) => Effect.Effect<unknown, unknown, R> {
if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) {
if (
!(callback instanceof CodeModeFunction) &&
!(callback instanceof CoercionFunction) &&
!(callback instanceof UriFunction)
) {
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
}
return (callbackArgs) =>
callback instanceof CoercionFunction
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
: this.invokeFunction(callback, callbackArgs)
: callback instanceof UriFunction
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
: this.invokeFunction(callback, callbackArgs)
}
private invokeMapMethod(
@ -3145,6 +3427,81 @@ class Interpreter<R> {
}
}
private invokeURLSearchParamsMethod(
target: SandboxURLSearchParams,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> {
const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`)
const requireArgs = (count: number): void => {
if (args.length < count) {
throw new InterpreterRuntimeError(
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
node,
).as("TypeError")
}
}
switch (name) {
case "append": {
requireArgs(2)
return Effect.sync(() => {
target.params.append(arg(0), arg(1))
return undefined
})
}
case "delete": {
requireArgs(1)
return Effect.sync(() => {
if (args[1] !== undefined) target.params.delete(arg(0), arg(1))
else target.params.delete(arg(0))
return undefined
})
}
case "get":
requireArgs(1)
return Effect.sync(() => target.params.get(arg(0)))
case "getAll":
requireArgs(1)
return Effect.sync(() => target.params.getAll(arg(0)))
case "has":
requireArgs(1)
return Effect.sync(() =>
args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0)),
)
case "set": {
requireArgs(2)
return Effect.sync(() => {
target.params.set(arg(0), arg(1))
return undefined
})
}
case "sort":
return Effect.sync(() => {
target.params.sort()
return undefined
})
case "keys":
return Effect.sync(() => Array.from(target.params.keys()))
case "values":
return Effect.sync(() => Array.from(target.params.values()))
case "entries":
return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array<unknown> => [key, value]))
case "toString":
return Effect.sync(() => target.params.toString())
case "forEach": {
requireArgs(1)
const apply = this.applyCollectionCallback(args[0], "URLSearchParams.forEach", node)
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
return undefined
})
}
default:
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node)
}
}
private invokeArrayMethod(
target: Array<unknown>,
name: string,
@ -3254,17 +3611,23 @@ class Interpreter<R> {
}
const callback = args[0]
if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) {
if (
!(callback instanceof CodeModeFunction) &&
!(callback instanceof CoercionFunction) &&
!(callback instanceof UriFunction)
) {
throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node)
}
const self = this
// Accept a user arrow function or a builtin coercion callable (Boolean/String/Number), so the
// idioms `filter(Boolean)` / `map(String)` / `map(Number)` work as in JS. Coercions are
// synchronous; only CodeModeFunctions can await tool calls.
// Accept a user function or supported builtin callable, so idioms such as
// `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. Builtins
// are synchronous; only CodeModeFunctions can await tool calls.
const apply = (callbackArgs: Array<unknown>): Effect.Effect<unknown, unknown, R> =>
callback instanceof CoercionFunction
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
: self.invokeFunction(callback, callbackArgs)
: callback instanceof UriFunction
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
: self.invokeFunction(callback, callbackArgs)
return Effect.gen(function* () {
// Iterate a snapshot taken at call time so a callback that mutates the array can't
// self-extend the loop - matching JS, where elements appended during iteration are not visited.
@ -3418,7 +3781,7 @@ class Interpreter<R> {
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
// JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common
// `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values
// (Date/RegExp/Map/Set) have no own enumerable properties in JS, so they are no-ops too.
// have no own enumerable properties in JS, so they are no-ops too.
if (spread === null || spread === undefined || isSandboxValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError(
@ -3655,6 +4018,21 @@ class Interpreter<R> {
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof SandboxURL) {
if (key === "searchParams") {
return new ComputedValue(objectValue.searchParams)
}
if (typeof key === "string" && urlMethods.has(key)) return new IntrinsicReference(objectValue, key)
if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
return new ComputedValue(undefined)
}
if (objectValue instanceof SandboxURLSearchParams) {
if (key === "size") return new ComputedValue(objectValue.params.size)
if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
return new IntrinsicReference(objectValue, key)
}
return new ComputedValue(undefined)
}
// Any property access on a promise is a confused program (`p.then(...)`, `p.value`);
// reading `undefined` here would hide the missing await, so both paths get an explicit,
@ -3732,6 +4110,9 @@ class Interpreter<R> {
}
return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)]
}
if (reference.target instanceof SandboxURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
}
return reference.target[String(reference.key)]
})
}
@ -3769,7 +4150,10 @@ class Interpreter<R> {
}
}
const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
const current = (reference.target as Record<PropertyKey, unknown>)[key]
const current =
reference.target instanceof SandboxURL
? (reference.target.url as unknown as Record<string, unknown>)[key]
: (reference.target as Record<PropertyKey, unknown>)[key]
const { write, next, result } = yield* compute(current)
if (write) self.assignToReference(reference, key, next, node)
return result
@ -3809,6 +4193,20 @@ class Interpreter<R> {
target[index] = next
return
}
if (reference.target instanceof SandboxURL) {
const property = key as string
if (!urlWritableProperties.has(property)) {
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
}
try {
const url = reference.target.url as unknown as Record<string, string>
url[property] = uriArgument(next, `URL.${property} value`)
return
} catch (error) {
if (error instanceof InterpreterRuntimeError || error instanceof ToolRuntimeError) throw error
throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
}
}
const target = reference.target as SafeObject
const objectKey = key as string
this.rejectCircularInsertion(target, next, "Object assignment result", node)

View file

@ -9,7 +9,15 @@ import {
outputTypeScript,
} from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
import {
SandboxDate,
SandboxMap,
SandboxPromise,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
} from "./values.js"
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
@ -152,9 +160,9 @@ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has
* Two modes share the walk:
* - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary -
* final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize
* exactly as JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}.
* exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}.
* - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
* codemode.ts): Date/RegExp/Map/Set instances pass through untouched (treated as leaves,
* codemode.ts): standard-library value instances pass through untouched (treated as leaves,
* contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
* other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
*
@ -208,7 +216,9 @@ const copyBounded = (
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet
value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams
) {
return value
}
@ -228,24 +238,30 @@ const copyBounded = (
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
return wrapped
}
if (value instanceof URL) return new SandboxURL(new URL(value.href))
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
}
// Sandbox value types (and their host counterparts, which a host tool may legitimately
// return) serialize exactly as JSON.stringify would at the data boundary: a Date is its
// toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}.
// return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use
// toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}.
if (value instanceof SandboxDate) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
}
if (value instanceof Date) {
return Number.isFinite(value.getTime()) ? value.toISOString() : null
}
if (value instanceof SandboxURL) return value.url.href
if (value instanceof URL) return value.href
if (
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet ||
value instanceof SandboxURLSearchParams ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set
value instanceof Set ||
value instanceof URLSearchParams
) {
return Object.create(null) as SafeObject
}
@ -589,9 +605,9 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"",
"## Language",
"",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, arbitrary methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
]
const toolSection: Array<string> = [""]

View file

@ -27,8 +27,23 @@ export class SandboxSet {
readonly set = new Set<unknown>()
}
export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet =>
export class SandboxURLSearchParams {
constructor(readonly params: URLSearchParams) {}
}
export class SandboxURL {
readonly searchParams: SandboxURLSearchParams
constructor(readonly url: URL) {
this.searchParams = new SandboxURLSearchParams(url.searchParams)
}
}
export const isSandboxValue = (
value: unknown,
): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet | SandboxURL | SandboxURLSearchParams =>
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet
value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams