@yolk-sdk/codemode 0.1.0-canary.96 → 0.1.0-canary.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/node.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  type CodemodeTool,
9
9
  type CodemodeWasmModule
10
10
  } from '@earendil-works/pi-codemode'
11
+ import { codeModeCallLabels } from './catalog.ts'
11
12
  import type {
12
13
  CodeModeError,
13
14
  CodeModeExecuteOptions,
@@ -96,6 +97,215 @@ const wrapperPrefix = 'async function __yolkCodeMode() {\n'
96
97
 
97
98
  const wrapperSuffix = '\n}'
98
99
 
100
+ /** Most values one argument may hold; a larger argument is rejected. */
101
+ const maxArgumentValues = 100_000
102
+
103
+ /** Deepest object/array nesting one argument may have; deeper arguments are rejected. */
104
+ const maxArgumentDepth = 64
105
+
106
+ /**
107
+ * Evaluated in the VM before the script runs: `(tools, labels) => guardedTools`, with `labels` the
108
+ * `[name, callLabel]` pairs of the tools in order (`codeModeCallLabels`). Each call walks its first
109
+ * argument once, the way `JSON.stringify` reads it (each own enumerable string key and each array
110
+ * index read once, so getters run once; a callable `toJSON` is called once with the key, `''` at
111
+ * the root, and its result is walked at the same path), and builds a fresh copy (null-prototype
112
+ * objects and arrays) that is what the call sends. It rejects with a `TypeError` naming the call
113
+ * label and path when JSON would change the value instead of carrying it: non-finite numbers,
114
+ * invalid `Date`s, `undefined`/function/symbol array items and holes, function/symbol/bigint
115
+ * values, cycles, and objects that are neither plain (prototype `Object.prototype` or `null`) nor
116
+ * arrays and have no `toJSON`. An argument with more than `maxArgumentValues` values or nested more
117
+ * than `maxArgumentDepth` levels is rejected too, never sent unchecked. `undefined`-valued keys are
118
+ * left out (absent, as JSON drops them); the same object twice (not a cycle) is copied twice. An
119
+ * error thrown by a getter or `toJSON` rejects the call with that error. A rejected call never
120
+ * reaches the host. Every member of pi's `tools` is wrapped; unknown members fall through to pi's
121
+ * proxy (close-match suggestions). Not a security boundary (`globalThis.tools` is unguarded):
122
+ * arguments are still decoded by the host.
123
+ */
124
+ const toolsGuardSource = `(function (tools, labels) {
125
+ // Captured before the script runs, so a script that changes built-ins cannot change the checks.
126
+ const { apply, getPrototypeOf, setPrototypeOf } = Reflect
127
+ const isFinite = Number.isFinite
128
+ const isArray = Array.isArray
129
+ const { keys, create, defineProperty } = Object
130
+ const getTime = Date.prototype.getTime
131
+ const TypeErrorCtor = TypeError
132
+ const SetCtor = Set
133
+ const setAdd = Set.prototype.add
134
+ const setHas = Set.prototype.has
135
+ const setDelete = Set.prototype.delete
136
+ const ObjectProto = Object.prototype
137
+ const { trunc, min } = Math
138
+ const stringify = JSON.stringify
139
+ const plainJson = 'pass plain JSON (objects, arrays, strings, finite numbers, booleans, null)'
140
+ const maxValues = ${maxArgumentValues}
141
+ const maxDepth = ${maxArgumentDepth}
142
+ class Rejected {
143
+ constructor(message) {
144
+ defineProperty(this, 'message', { value: message })
145
+ }
146
+ }
147
+ const keyPath = (path, key) =>
148
+ /^[A-Za-z_$][\\w$]*$/.test(key)
149
+ ? (path === '' ? key : path + '.' + key)
150
+ : path + '[' + stringify(key) + ']'
151
+ const typeName = value => {
152
+ const proto = getPrototypeOf(value)
153
+ const name = proto && typeof proto.constructor === 'function' ? proto.constructor.name : ''
154
+ return name ? (/^[AEIOU]/.test(name) ? 'an ' : 'a ') + name : 'a non-plain object'
155
+ }
156
+ // The JSON copy of a value already read from its holder at key (undefined: absent), or throws
157
+ // Rejected with why JSON would not carry it unchanged.
158
+ const snapshot = (read, key, path, slot, walk) => {
159
+ const at = path === '' ? 'is ' : 'at ' + path + ' is '
160
+ if (++walk.values > maxValues) {
161
+ throw new Rejected('has more than ' + maxValues + ' values; split the work across calls')
162
+ }
163
+ let value = read
164
+ const kind = typeof value
165
+ if ((kind === 'object' && value !== null) || kind === 'function' || kind === 'bigint') {
166
+ // The intrinsic getTime reads a Date's internal time (and throws for anything else).
167
+ let time
168
+ try {
169
+ time = apply(getTime, value, [])
170
+ } catch {
171
+ time = undefined
172
+ }
173
+ if (time !== undefined && !isFinite(time)) {
174
+ throw new Rejected(at + 'an invalid Date; pass a valid Date or an ISO string')
175
+ }
176
+ const toJSON = value.toJSON
177
+ if (typeof toJSON === 'function') value = apply(toJSON, value, [key])
178
+ }
179
+ switch (typeof value) {
180
+ case 'string':
181
+ case 'boolean':
182
+ return value
183
+ case 'number':
184
+ if (isFinite(value)) return value
185
+ throw new Rejected(
186
+ at + value + '; pass a finite number' + (slot === 'key' ? ' or omit the key' : '')
187
+ )
188
+ case 'undefined':
189
+ if (slot === 'item') throw new Rejected(at + 'undefined; arrays cannot hold undefined')
190
+ return undefined
191
+ case 'bigint':
192
+ case 'function':
193
+ case 'symbol':
194
+ throw new Rejected(at + 'a ' + typeof value + '; ' + plainJson)
195
+ }
196
+ if (value === null) return null
197
+ if (apply(setHas, walk.ancestors, [value])) {
198
+ throw new Rejected(at + 'a circular reference; ' + plainJson)
199
+ }
200
+ if (walk.depth >= maxDepth) {
201
+ throw new Rejected('at ' + path + ' is nested more than ' + maxDepth + ' levels deep')
202
+ }
203
+ const array = isArray(value)
204
+ if (!array) {
205
+ const proto = getPrototypeOf(value)
206
+ if (proto !== ObjectProto && proto !== null) {
207
+ throw new Rejected(at + typeName(value) + '; ' + plainJson)
208
+ }
209
+ }
210
+ apply(setAdd, walk.ancestors, [value])
211
+ walk.depth++
212
+ let copy
213
+ if (array) {
214
+ // LengthOfArrayLike, read once, as JSON.stringify does.
215
+ // Unary plus is ToNumber: one coercion, and it throws for a bigint as JSON.stringify does.
216
+ const raw = +value.length
217
+ const length = raw > 0 ? min(trunc(raw), 9007199254740991) : 0
218
+ // No prototype: no inherited toJSON or index setter can change the checked copy when pi
219
+ // serializes it (still an array to JSON.stringify).
220
+ copy = []
221
+ setPrototypeOf(copy, null)
222
+ for (let i = 0; i < length; i++) {
223
+ copy[i] = snapshot(value[i], '' + i, path + '[' + i + ']', 'item', walk)
224
+ }
225
+ } else {
226
+ copy = create(null)
227
+ // Indexed over the fresh key array: no iterator a script could replace.
228
+ const names = keys(value)
229
+ for (let i = 0; i < names.length; i++) {
230
+ const name = names[i]
231
+ const item = snapshot(value[name], name, keyPath(path, name), 'key', walk)
232
+ if (item !== undefined) copy[name] = item
233
+ }
234
+ }
235
+ walk.depth--
236
+ apply(setDelete, walk.ancestors, [value])
237
+ return copy
238
+ }
239
+ const wrap = (label, call) => async (...args) => {
240
+ let json
241
+ try {
242
+ json = snapshot(args[0], '', '', 'root', { values: 0, depth: 0, ancestors: new SetCtor() })
243
+ } catch (error) {
244
+ if (error instanceof Rejected) throw new TypeErrorCtor(label + ': argument ' + error.message)
245
+ throw error
246
+ }
247
+ return call(json)
248
+ }
249
+ // Each tool's function sits at tools[name] unless an earlier tool holds that key, in which case
250
+ // that earlier function is already wrapped.
251
+ const wrappers = new Map()
252
+ for (const [name, label] of labels) {
253
+ const call = name in tools ? tools[name] : undefined
254
+ if (typeof call === 'function' && !wrappers.has(call)) wrappers.set(call, wrap(label, call))
255
+ }
256
+ const guarded = Object.create(null)
257
+ for (const key of Object.keys(tools)) {
258
+ const call = tools[key]
259
+ if (!wrappers.has(call)) wrappers.set(call, wrap('tools[' + JSON.stringify(key) + ']', call))
260
+ guarded[key] = wrappers.get(call)
261
+ }
262
+ return new Proxy(Object.freeze(guarded), {
263
+ get: (target, key) => (key in target ? target[key] : tools[key])
264
+ })
265
+ })`
266
+
267
+ // pi runs `(async (tools, console) => {<code>\n})`. The script runs in an inner function of the
268
+ // same shape whose `tools` is guarded; the prefix shares line 1 with the script (only line-1
269
+ // columns shift) and the guard follows the script, so its line numbers are unchanged.
270
+ const guardPrefix = 'return (async (tools, console) => {'
271
+
272
+ /** JSON as a JavaScript expression (U+2028/U+2029 escaped for older parsers). */
273
+ const jsonSource = (value: unknown) =>
274
+ JSON.stringify(value).replace(/[\u2028\u2029]/g, char =>
275
+ char === '\u2028' ? '\\u2028' : '\\u2029'
276
+ )
277
+
278
+ const guardSuffix = (tools: ReadonlyArray<CodeModeExecutorTool>) => {
279
+ const names = tools.map(tool => tool.name)
280
+ const labels = codeModeCallLabels(names)
281
+
282
+ // `makeCodeModeTool` supplies each label; other callers get pi's binding rule.
283
+ const pairs = tools.map((tool, index) => [
284
+ tool.name,
285
+ tool.callLabel ?? labels[index] ?? `tools[${JSON.stringify(tool.name)}]`
286
+ ])
287
+
288
+ return `\n})(${toolsGuardSource}(tools, ${jsonSource(pairs)}), console)`
289
+ }
290
+
291
+ const lineTerminators = /\r\n?|[\n\u2028\u2029]/g
292
+
293
+ /** Lines of the script as QuickJS counts them (at least; never fewer). */
294
+ const scriptLineCount = (code: string) => (code.match(lineTerminators)?.length ?? 0) + 1
295
+
296
+ const scriptFrame = /codemode\.js:(\d+):\d+/
297
+
298
+ /** Drops stack frames of the wrapper and guard, which sit below the script's last line. */
299
+ const withoutWrapperFrames = (stack: string, scriptLines: number) =>
300
+ stack
301
+ .split('\n')
302
+ .filter(line => {
303
+ const frame = scriptFrame.exec(line)
304
+
305
+ return frame === null || Number(frame[1]) <= scriptLines
306
+ })
307
+ .join('\n')
308
+
99
309
  const ErrorWithCode = Schema.Struct({ code: Schema.String })
100
310
 
101
311
  const errorCode = (error: unknown) =>
@@ -205,7 +415,7 @@ type MutableExecutionResult = {
205
415
  storeWrites?: CodeModeStoreWrites
206
416
  }
207
417
 
208
- const fromPiResult = (result: CodemodeResult): CodeModeExecutionResult => {
418
+ const fromPiResult = (result: CodemodeResult, scriptLines: number): CodeModeExecutionResult => {
209
419
  if (result.ok) {
210
420
  const storeWrites = decodeStoreWrites(result.storeWrites)
211
421
 
@@ -235,7 +445,7 @@ const fromPiResult = (result: CodemodeResult): CodeModeExecutionResult => {
235
445
  message: named ? `${name}: ${message}` : message
236
446
  }
237
447
 
238
- if (stack !== undefined) error.stack = stack
448
+ if (stack !== undefined) error.stack = withoutWrapperFrames(stack, scriptLines)
239
449
 
240
450
  return { ok: false, error, output: result.output }
241
451
  }
@@ -245,7 +455,8 @@ const describeError = (error: unknown) => (error instanceof Error ? error.messag
245
455
  /**
246
456
  * A `CodeModeExecutor` on `@earendil-works/pi-codemode`: every execution gets a fresh QuickJS VM
247
457
  * (WebAssembly) in a fresh worker thread, closed when the execution ends. Applies the timeout,
248
- * heap cap, abort signal, and store, strips TypeScript annotations first, and caps concurrent
458
+ * heap cap, abort signal, and store, strips TypeScript annotations first, rejects tool arguments
459
+ * that JSON would silently change inside the script (see `CodeModeExecutorTool`), and caps concurrent
249
460
  * executions per executor; create one executor per process (module scope) so the cap is
250
461
  * per process.
251
462
  *
@@ -299,11 +510,12 @@ export const makePiCodeModeExecutor = (
299
510
 
300
511
  try {
301
512
  return fromPiResult(
302
- await sandbox.execute(stripped.code, {
513
+ await sandbox.execute(`${guardPrefix}${stripped.code}${guardSuffix(execution.tools)}`, {
303
514
  signal: execution.signal,
304
515
  store: execution.store,
305
516
  timeoutMs
306
- })
517
+ }),
518
+ scriptLineCount(stripped.code)
307
519
  )
308
520
  } finally {
309
521
  await sandbox.close()
package/src/tool.ts CHANGED
@@ -150,7 +150,9 @@ type NestedResolution =
150
150
  | { readonly ok: false; readonly message: string }
151
151
 
152
152
  /** What a nested call resolves to: `structuredContent` for tools with an output schema (when
153
- * present), text content otherwise; error results reject with their text.
153
+ * present), text content otherwise; error results reject with their text. A tool that declares an
154
+ * output schema but returns no `structuredContent` resolves to its text: that is a tool bug, kept
155
+ * readable rather than turned into a failure.
154
156
  */
155
157
  const resolveNestedResult = (tool: CodeModeCatalogTool, result: ToolResult): NestedResolution => {
156
158
  const text = nestedText(result.content)
@@ -317,13 +319,13 @@ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult,
317
319
  (tool: CodeModeCatalogTool): CodeModeExecutorTool['execute'] =>
318
320
  (args, { signal }) => {
319
321
  if (signal.aborted) {
320
- return Promise.reject(new Error(`tools.${tool.identifier} was cancelled.`))
322
+ return Promise.reject(new Error(`${tool.callLabel} was cancelled.`))
321
323
  }
322
324
 
323
325
  if (records.length >= limits.maxNestedCalls) {
324
326
  return Promise.reject(
325
327
  new Error(
326
- `Nested call limit reached: a script may make at most ${limits.maxNestedCalls} tool calls. Batch the work or return partial results.`
328
+ `${tool.callLabel}: Nested call limit reached: a script may make at most ${limits.maxNestedCalls} tool calls. Batch the work or return partial results.`
327
329
  )
328
330
  )
329
331
  }
@@ -375,8 +377,8 @@ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult,
375
377
  reject(
376
378
  new Error(
377
379
  Cause.hasInterruptsOnly(exit.cause)
378
- ? `tools.${tool.identifier} was cancelled.`
379
- : `tools.${tool.identifier} failed unexpectedly.`
380
+ ? `${tool.callLabel} was cancelled.`
381
+ : `${tool.callLabel} failed unexpectedly.`
380
382
  )
381
383
  )
382
384
 
@@ -388,7 +390,8 @@ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult,
388
390
  if (resolution.ok) {
389
391
  resolve(resolution.value)
390
392
  } else {
391
- reject(new Error(resolution.message))
393
+ // The script sees which call failed; the nested-call record keeps the raw text.
394
+ reject(new Error(`${tool.callLabel}: ${resolution.message}`))
392
395
  }
393
396
  })
394
397
  })
@@ -399,6 +402,7 @@ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult,
399
402
  description: tool.description,
400
403
  inputSchema: tool.inputSchema,
401
404
  outputSchema: tool.outputSchema,
405
+ callLabel: tool.callLabel,
402
406
  execute: callTool(tool)
403
407
  }))
404
408