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
parent 04e8801f5b
commit 5b2d90a441
7 changed files with 833 additions and 1248 deletions
+4 -3
View File
@@ -243,12 +243,13 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports:
- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. - Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`.
- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. - Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.
- `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). - `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones).
- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported. - Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout.
- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). - `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
- URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention.
- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. - First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic.
- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error. - `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
Inside a program, Date/RegExp/Map/Set values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available. It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available.
File diff suppressed because it is too large Load Diff
+426 -28
View File
@@ -16,7 +16,16 @@ import {
} from "./tool-runtime.js" } from "./tool-runtime.js"
import type { Definition } from "./tool.js" import type { Definition } from "./tool.js"
import { ToolError } from "./tool-error.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. */ /** A tool call admitted during an execution. */
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js" export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
@@ -184,7 +193,7 @@ type StatementResult =
| { kind: "continue" } | { kind: "continue" }
type MemberReference = { type MemberReference = {
target: SafeObject | Array<unknown> target: SafeObject | Array<unknown> | SandboxURL
key: string | number key: string | number
} }
@@ -217,7 +226,18 @@ class PromiseMethodReference {
// A built-in global namespace (`Object`, `Math`, `JSON`, `Array`, ...); members resolve to a // 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. // 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 { class GlobalNamespace {
constructor(readonly name: GlobalNamespaceName) {} constructor(readonly name: GlobalNamespaceName) {}
@@ -234,6 +254,10 @@ class CoercionFunction {
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {} constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
} }
class UriFunction {
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
}
class ProgramThrow { class ProgramThrow {
constructor(readonly value: unknown) {} constructor(readonly value: unknown) {}
} }
@@ -355,7 +379,7 @@ const errorConstructors = new Set([
"URIError", "URIError",
]) ])
const valueConstructors = new Set(["Date", "RegExp", "Map", "Set"]) const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"])
const dateMethods = new Set([ const dateMethods = new Set([
"getTime", "getTime",
@@ -400,10 +424,52 @@ const regexpProperties = new Set([
const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) 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 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 OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
const supportedSyntaxMessage = 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 => const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError( new InterpreterRuntimeError(
@@ -662,6 +728,7 @@ const isRuntimeReference = (value: unknown): boolean =>
value instanceof PromiseMethodReference || value instanceof PromiseMethodReference ||
value instanceof SandboxPromise || value instanceof SandboxPromise ||
value instanceof CoercionFunction || value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof ErrorConstructorReference || value instanceof ErrorConstructorReference ||
isSandboxValue(value) isSandboxValue(value)
@@ -677,7 +744,7 @@ const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boo
return contains 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 // operators and switch treat them as ordinary object operands (identity equality, ToPrimitive
// coercion) rather than rejecting them as opaque interpreter machinery. // coercion) rather than rejecting them as opaque interpreter machinery.
const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => { const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
@@ -707,6 +774,7 @@ const typeofValue = (value: unknown): string => {
value instanceof ErrorConstructorReference value instanceof ErrorConstructorReference
) )
return "function" return "function"
if (value instanceof UriFunction) return "function"
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
if (value instanceof GlobalNamespace) { if (value instanceof GlobalNamespace) {
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function" 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 return lhs instanceof SandboxMap
case "Set": case "Set":
return lhs instanceof SandboxSet return lhs instanceof SandboxSet
case "URL":
return lhs instanceof SandboxURL
case "URLSearchParams":
return lhs instanceof SandboxURLSearchParams
case "Array": case "Array":
return Array.isArray(lhs) return Array.isArray(lhs)
case "Object": case "Object":
@@ -746,7 +818,7 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
return false return false
} }
throw new InterpreterRuntimeError( 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, node,
) )
} }
@@ -885,14 +957,6 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break break
case "replace": case "replace":
case "replaceAll": { 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) { if (args[0] instanceof SandboxRegExp) {
const pattern = (args[0] as SandboxRegExp).regex const pattern = (args[0] as SandboxRegExp).regex
const replacement = str(1) 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 SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof SandboxMap) return "[object Map]" if (value instanceof SandboxMap) return "[object Map]"
if (value instanceof SandboxSet) return "[object Set]" 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") { if (typeof value === "object") {
return Array.isArray(value) return Array.isArray(value)
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") ? 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 invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
const requireObject = (): Record<string, unknown> => { const requireObject = (): Record<string, unknown> => {
const value = boundedData(args[0], `Object.${name} input`) 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. // Object.* helpers see them as empty objects - never their interpreter internals.
if (isSandboxValue(value)) return {} if (isSandboxValue(value)) return {}
if (value === null || typeof value !== "object" || Array.isArray(value)) { 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) for (const [key, item] of (args[0] as SandboxMap).map.entries()) guardedSet(out, coerceToString(key), item)
return out 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") const pairs = boundedData(args[0], "Object.fromEntries input")
if (!Array.isArray(pairs)) if (!Array.isArray(pairs))
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) 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) if (args[0] instanceof SandboxMap)
return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) 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 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") const source = boundedData(args[0], "Array.from input")
if (typeof source === "string") return Array.from(source) if (typeof source === "string") return Array.from(source)
if (Array.isArray(source)) return [...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 => { const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
if (ref.namespace === "console") if (ref.namespace === "console")
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) 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 === "Array") return invokeArrayStatic(ref.name, args, node)
if (ref.namespace === "Number") return invokeNumberStatic(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 === "String") return invokeStringStatic(ref.name, args, node)
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
if (ref.namespace === "Date") { if (ref.namespace === "Date") {
if (!dateStatics.has(ref.name)) if (!dateStatics.has(ref.name))
throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node) throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node)
return invokeDateStatic(ref.name, args, 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) throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
} }
return invokeJsonMethod(ref.name, args, node) return invokeJsonMethod(ref.name, args, node)
@@ -1411,6 +1537,8 @@ const spreadItems = (spread: unknown): Array<unknown> | undefined => {
if (spread instanceof SandboxMap) if (spread instanceof SandboxMap)
return Array.from(spread.map.entries(), ([key, item]): Array<unknown> => [key, item]) 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 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 return undefined
} }
@@ -1485,6 +1613,12 @@ class Interpreter<R> {
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") }) globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") }) globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") }) 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")` // Error constructors are real values, so `x instanceof Error` works and `Error("msg")`
// (with or without `new`) constructs a branded { name, message } error object. // (with or without `new`) constructs a branded { name, message } error object.
for (const name of errorConstructors) { for (const name of errorConstructors) {
@@ -2331,8 +2465,12 @@ class Interpreter<R> {
return self.constructRegExp(args, node) return self.constructRegExp(args, node)
case "Map": case "Map":
return self.constructMap(args[0], node) return self.constructMap(args[0], node)
default: case "Set":
return self.constructSet(args[0], node) 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 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> { private evaluateBinaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
const operator = getString(node, "operator") const operator = getString(node, "operator")
const self = this const self = this
@@ -2699,6 +2898,9 @@ class Interpreter<R> {
if (callable instanceof CoercionFunction) { if (callable instanceof CoercionFunction) {
return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`) 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. // `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS.
if (callable instanceof ErrorConstructorReference) { if (callable instanceof ErrorConstructorReference) {
return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0])) 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 SandboxPromise) return "[Promise (await it to get its value)]"
if (value instanceof SandboxDate) return coerceToString(value) if (value instanceof SandboxDate) return coerceToString(value)
if (value instanceof SandboxRegExp) 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 (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]" if (seen.has(value)) return "[Circular]"
if (value instanceof SandboxMap) { if (value instanceof SandboxMap) {
@@ -3026,6 +3230,12 @@ class Interpreter<R> {
node: AstNode, node: AstNode,
): Effect.Effect<unknown, unknown, R> { ): Effect.Effect<unknown, unknown, R> {
if (typeof ref.receiver === "string") { 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)) return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
} }
if (typeof ref.receiver === "number") { if (typeof ref.receiver === "number") {
@@ -3046,23 +3256,95 @@ class Interpreter<R> {
if (ref.receiver instanceof SandboxSet) { if (ref.receiver instanceof SandboxSet) {
return this.invokeSetMethod(ref.receiver, ref.name, args, node) 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) 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. // mirroring the array-method callback contract.
private applyCollectionCallback( private applyCollectionCallback(
callback: unknown, callback: unknown,
name: string, name: string,
node: AstNode, node: AstNode,
): (args: Array<unknown>) => Effect.Effect<unknown, unknown, R> { ): (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) throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
} }
return (callbackArgs) => return (callbackArgs) =>
callback instanceof CoercionFunction callback instanceof CoercionFunction
? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) ? 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( 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( private invokeArrayMethod(
target: Array<unknown>, target: Array<unknown>,
name: string, name: string,
@@ -3254,17 +3611,23 @@ class Interpreter<R> {
} }
const callback = args[0] 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) throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node)
} }
const self = this const self = this
// Accept a user arrow function or a builtin coercion callable (Boolean/String/Number), so the // Accept a user function or supported builtin callable, so idioms such as
// idioms `filter(Boolean)` / `map(String)` / `map(Number)` work as in JS. Coercions are // `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. Builtins
// synchronous; only CodeModeFunctions can await tool calls. // are synchronous; only CodeModeFunctions can await tool calls.
const apply = (callbackArgs: Array<unknown>): Effect.Effect<unknown, unknown, R> => const apply = (callbackArgs: Array<unknown>): Effect.Effect<unknown, unknown, R> =>
callback instanceof CoercionFunction callback instanceof CoercionFunction
? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) ? 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* () { return Effect.gen(function* () {
// Iterate a snapshot taken at call time so a callback that mutates the array can't // 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. // 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")) const spread = yield* self.evaluateExpression(getNode(property, "argument"))
// JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common // JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common
// `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values // `{ ...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 (spread === null || spread === undefined || isSandboxValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
@@ -3655,6 +4018,21 @@ class Interpreter<R> {
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key) if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined) 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`); // 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, // 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)] 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)] 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 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) const { write, next, result } = yield* compute(current)
if (write) self.assignToReference(reference, key, next, node) if (write) self.assignToReference(reference, key, next, node)
return result return result
@@ -3809,6 +4193,20 @@ class Interpreter<R> {
target[index] = next target[index] = next
return 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 target = reference.target as SafeObject
const objectKey = key as string const objectKey = key as string
this.rejectCircularInsertion(target, next, "Object assignment result", node) this.rejectCircularInsertion(target, next, "Object assignment result", node)
+26 -10
View File
@@ -9,7 +9,15 @@ import {
outputTypeScript, outputTypeScript,
} from "./tool-schema.js" } from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.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)) 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: * Two modes share the walk:
* - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary - * - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary -
* final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize * 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 * - **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 * contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
* other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...). * other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
* *
@@ -208,7 +216,9 @@ const copyBounded = (
value instanceof SandboxDate || value instanceof SandboxDate ||
value instanceof SandboxRegExp || value instanceof SandboxRegExp ||
value instanceof SandboxMap || value instanceof SandboxMap ||
value instanceof SandboxSet value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams
) { ) {
return value return value
} }
@@ -228,24 +238,30 @@ const copyBounded = (
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true)) for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
return wrapped 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 // 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 // return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use
// toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}. // toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}.
if (value instanceof SandboxDate) { if (value instanceof SandboxDate) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
} }
if (value instanceof Date) { if (value instanceof Date) {
return Number.isFinite(value.getTime()) ? value.toISOString() : null return Number.isFinite(value.getTime()) ? value.toISOString() : null
} }
if (value instanceof SandboxURL) return value.url.href
if (value instanceof URL) return value.href
if ( if (
value instanceof SandboxRegExp || value instanceof SandboxRegExp ||
value instanceof SandboxMap || value instanceof SandboxMap ||
value instanceof SandboxSet || value instanceof SandboxSet ||
value instanceof SandboxURLSearchParams ||
value instanceof RegExp || value instanceof RegExp ||
value instanceof Map || value instanceof Map ||
value instanceof Set value instanceof Set ||
value instanceof URLSearchParams
) { ) {
return Object.create(null) as SafeObject return Object.create(null) as SafeObject
} }
@@ -589,9 +605,9 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"", "",
"## Language", "## Language",
"", "",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls.", "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, arbitrary methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", "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 serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
] ]
const toolSection: Array<string> = [""] const toolSection: Array<string> = [""]
+17 -2
View File
@@ -27,8 +27,23 @@ export class SandboxSet {
readonly set = new Set<unknown>() 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 SandboxDate ||
value instanceof SandboxRegExp || value instanceof SandboxRegExp ||
value instanceof SandboxMap || value instanceof SandboxMap ||
value instanceof SandboxSet value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams
+3 -1
View File
@@ -655,9 +655,11 @@ describe("CodeMode public contract", () => {
for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) { for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) {
expect(instructions).toContain(missing) expect(instructions).toContain(missing)
} }
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
expect(instructions).not.toContain("host globals")
expect(instructions).toContain("Use Code Mode tools for external operations") expect(instructions).toContain("Use Code Mode tools for external operations")
expect(instructions).toContain( expect(instructions).toContain(
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
) )
}) })
+222 -2
View File
@@ -1,12 +1,12 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect } from "effect" import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js" import { CodeMode, Tool } from "../src/index.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS; // Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live // intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they // values, while at the host boundary (final result, tool arguments, JSON.stringify) they
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null), // serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
// RegExp/Map/Set -> {}. // URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => { const value = async (code: string) => {
const result = await run(code) const result = await run(code)
@@ -149,6 +149,67 @@ describe("RegExp", () => {
expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]") expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]")
}) })
test("function replacers receive captures, offsets, input, and named groups", async () => {
expect(
await value(`
const seen = []
const output = "a1b22".replace(/(\\d)(\\d)?/g, (match, first, second, offset, input) => {
seen.push([match, first, second === undefined, offset, input])
return Number(match) * 2
})
return { output, seen }
`),
).toEqual({
output: "a2b44",
seen: [
["1", "1", true, 1, "a1b22"],
["22", "2", false, 3, "a1b22"],
],
})
expect(
await value(`
return "red-blue".replace(
/(?<left>[a-z]+)-(?<right>[a-z]+)/,
(match, left, right, offset, input, groups) => groups.right + ":" + groups.left,
)
`),
).toBe("blue:red")
})
test("function replacers support string searches, zero-length matches, and result coercion", async () => {
expect(await value(`return "banana".replace("na", (match, offset, input) => "[" + offset + "]")`)).toBe("ba[2]na")
expect(await value(`return "ab".replaceAll("", (match, offset) => offset)`)).toBe("0a1b2")
expect(await value(`return "😀".replaceAll(/(?:)/gu, (match, offset) => "[" + offset + "]")`)).toBe("[0]😀[2]")
expect(
await value(`return "123".replace(/\\d/g, (match) => match === "1" ? 7 : match === "2" ? null : { n: 3 })`),
).toBe("7null[object Object]")
})
test("function replacers can await effectful tool calls", async () => {
const decorate = Tool.make({
description: "Decorate a string",
input: Schema.String,
output: Schema.String,
run: (input) => Effect.succeed(`[${input}]`),
})
const result = await Effect.runPromise(
CodeMode.execute({
tools: { host: { decorate } },
code: `return "a1b22".replace(/\\d+/g, async (match) => await tools.host.decorate(match))`,
}),
)
expect(result.ok && result.value).toBe("a[1]b[22]")
const missingAwait = await Effect.runPromise(
CodeMode.execute({
tools: { host: { decorate } },
code: `return "a1".replace(/\\d/, (match) => tools.host.decorate(match))`,
}),
)
expect(!missingAwait.ok && missingAwait.error.kind).toBe("InvalidDataValue")
expect(!missingAwait.ok && missingAwait.error.message).toContain("un-awaited Promise")
})
test("replaceAll without the g flag is a catchable error", async () => { test("replaceAll without the g flag is a catchable error", async () => {
expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught") expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught")
}) })
@@ -208,6 +269,165 @@ describe("RegExp", () => {
}) })
}) })
describe("URL and URI helpers", () => {
test("encodes and decodes complete URIs and URI components", async () => {
expect(
await value(`
return [
encodeURI("https://example.test/a b?q=a/b"),
encodeURIComponent("a b/c?"),
decodeURI("https://example.test/a%20b?q=a/b"),
decodeURIComponent("a%20b%2Fc%3F"),
["a b", "c/d"].map(encodeURIComponent),
]
`),
).toEqual([
"https://example.test/a%20b?q=a/b",
"a%20b%2Fc%3F",
"https://example.test/a b?q=a/b",
"a b/c?",
["a%20b", "c%2Fd"],
])
expect(
await value(`try { decodeURIComponent("%zz"); return false } catch (error) { return error instanceof URIError }`),
).toBe(true)
})
test("resolves and mutates URLs with linked search parameters", async () => {
expect(
await value(`
const url = new URL("../users?id=old#top", "https://user:pass@example.com:8443/api/v1/")
url.pathname = "/items/a b"
url.searchParams.set("id", "a b")
url.searchParams.append("tag", "x/y")
url.hash = "part 1"
return {
href: url.href,
origin: url.origin,
host: url.host,
pathname: url.pathname,
search: url.search,
id: url.searchParams.get("id"),
string: String(url),
json: url.toJSON(),
instances: [
url instanceof URL,
url.searchParams instanceof URLSearchParams,
url.searchParams === url.searchParams,
],
}
`),
).toEqual({
href: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
origin: "https://example.com:8443",
host: "example.com:8443",
pathname: "/items/a%20b",
search: "?id=a+b&tag=x%2Fy",
id: "a b",
string: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
json: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
instances: [true, true, true],
})
})
test("URLSearchParams supports records, pairs, mutation, callbacks, and materialization", async () => {
expect(
await value(`
const params = new URLSearchParams([["tag", "b"], ["tag", "a"], ["q", "a b"]])
const seen = []
params.forEach((value, key) => seen.push(key + "=" + value))
params.delete("tag", "b")
params.append("tag", "c")
params.sort()
return {
text: params.toString(),
size: params.size,
tags: params.getAll("tag"),
has: params.has("tag", "c"),
entries: Array.from(params),
object: Object.fromEntries(params),
record: new URLSearchParams({ page: 2, filter: "open" }).toString(),
seen,
}
`),
).toEqual({
text: "q=a+b&tag=a&tag=c",
size: 3,
tags: ["a", "c"],
has: true,
entries: [
["q", "a b"],
["tag", "a"],
["tag", "c"],
],
object: { q: "a b", tag: "c" },
record: "page=2&filter=open",
seen: ["tag=b", "tag=a", "q=a b"],
})
})
test("URL parsing failures are catchable and values use native JSON forms", async () => {
expect(
await value(`
const parsed = URL.parse("/users", "https://example.test/api/")
let invalidIsTypeError = false
try { new URL("not relative without a base") } catch (error) { invalidIsTypeError = error instanceof TypeError }
return {
canParse: URL.canParse("/users", "https://example.test/api/"),
cannotParse: URL.canParse("not relative without a base"),
parsed: parsed.href,
invalidIsTypeError,
boundary: [new URL("https://example.test/a"), new URLSearchParams("q=one")],
json: JSON.stringify({ url: new URL("https://example.test/a"), params: new URLSearchParams("q=one") }),
}
`),
).toEqual({
canParse: true,
cannotParse: false,
parsed: "https://example.test/users",
invalidIsTypeError: true,
boundary: ["https://example.test/a", {}],
json: '{"url":"https://example.test/a","params":{}}',
})
})
test("distinguishes omitted URL arguments from explicit undefined", async () => {
expect(
await value(`
function throwsTypeError(run) {
try { run(); return false } catch (error) { return error instanceof TypeError }
}
const params = new URLSearchParams()
const required = [
() => params.append(),
() => params.delete(),
() => params.get(),
() => params.getAll(),
() => params.has(),
() => params.set(),
() => params.forEach(),
].map(throwsTypeError)
params.append(undefined, undefined)
return {
construct: throwsTypeError(() => new URL()),
canParse: throwsTypeError(() => URL.canParse()),
parse: throwsTypeError(() => URL.parse()),
explicitUndefined: new URL(undefined, "https://example.test/base/").href,
params: params.toString(),
required,
}
`),
).toEqual({
construct: true,
canParse: true,
parse: true,
explicitUndefined: "https://example.test/base/undefined",
params: "undefined=undefined",
required: [true, true, true, true, true, true, true],
})
})
})
describe("Map", () => { describe("Map", () => {
test("get/set/has/size with chaining", async () => { test("get/set/has/size with chaining", async () => {
expect( expect(