diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71d31ef..04b5372 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,13 +14,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: git submodule update --init --recursive - uses: actions/setup-node@v4 with: node-version: lts/* cache: npm - - run: cd packages/capnweb && npm ci && npm run build - run: npm ci - run: npm run build - run: cd website && npm ci @@ -46,13 +44,8 @@ jobs: with: node-version: ${{ matrix.node }} cache: npm - - name: Install Deno - uses: denoland/setup-deno@v2 - with: - deno-version: latest - - run: cd packages/capnweb && npm ci && npm run build - run: npm ci - run: npm run build - run: npm run test - - run: cd examples && npm ci &&npm run ci + - run: cd examples && npm ci && npm run check diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0f43b50..01e24e8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,14 +10,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: git submodule update --init --recursive - uses: actions/setup-node@v4 with: node-version: lts/* cache: npm registry-url: https://registry.npmjs.org - - run: cd packages/capnweb && npm ci && npm run build - run: npm ci - run: npm run build - run: ./bin/bump-and-publish.sh diff --git a/README.md b/README.md index 802c28e..98f20f8 100644 --- a/README.md +++ b/README.md @@ -73,15 +73,14 @@ class User extends db.Table('users').as('user') { ```typescript import { google } from '@ai-sdk/google' import { generateText, stepCountIs } from 'ai' -import { CodeMode, createDenoSandbox } from 'exoagent' +import { codemode } from 'exoagent' // Create a capability scoped to user_id=1 const userCap = User.on(u => u.id['='](1)).from() -// Wrap with CodeMode for sandboxed execution -const codeMode = new CodeMode(createDenoSandbox()) -const codeTool = await codeMode.wrap({ - currentUser: () => userCap, +// Wrap with codemode for sandboxed execution +const codeTool = await codemode({ + currentUser: userCap, }, schemaString) // schemaString = the class definitions above as a string const result = await generateText({ @@ -115,17 +114,15 @@ npx tsx saas-bot.ts Note the examples require: 1. NodeJS (runtime) -2. [Deno](https://docs.deno.com/runtime/getting_started/installation/) (sandbox) -3. An LLM API key set via one of the env vars: +2. An LLM API key set via one of the env vars: - `OPENAI_API_KEY` - `ANTHROPIC_API_KEY` - `GOOGLE_GENERATIVE_AI_API_KEY` ## Architecture ExoAgent sits between your LLM and your infrastructure as a regular tool. -1. **Protocol**: Uses [Cap'n Web](https://github.com/cloudflare/capnweb) (RPC) as the transport. -2. **Runtime**: Runs in a JS code sandbox (user-configured; Deno supported out of the box, more to come). -3. **Query Builder**: Uses a custom capability SQL builder that compiles to safe SQL. +1. **Evaluator**: A custom sandboxed JavaScript evaluator (`exoeval`) that only allows safe operations. +2. **Query Builder**: A capability-based SQL builder that compiles to safe SQL with scoped access. ## ⚠️ Project Status: Experimental (v0.0.x) ExoAgent is an exploration of capability-based security for LLMs. While the architecture (OCaps + Sandboxing) is theoretically robust, this specific implementation is new and may contain bugs. diff --git a/eslint.config.js b/eslint.config.js index 38b2c59..f416067 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,6 +23,7 @@ export default antfu( 'style/max-statements-per-line': 'off', 'ts/no-this-alias': 'off', 'antfu/no-top-level-await': 'off', + 'test/prefer-lowercase-title': 'off', }, }, ) diff --git a/examples/package.json b/examples/package.json index f582a4d..889a0aa 100644 --- a/examples/package.json +++ b/examples/package.json @@ -7,8 +7,8 @@ "saas-bot": "tsx saas-bot.ts", "test": "vitest --run", "typecheck": "tsc --noEmit", - "ci": "npm run typecheck && npm run test", - "npm-install-test": "TMP=$(mktemp -d --suffix=-exoagent-examples) && cp -r . $TMP && npm --prefix $TMP i exoagent@$(npm show .. version) && npm --prefix $TMP run ci" + "check": "npm run typecheck && npm run test", + "npm-install-test": "TMP=$(mktemp -d --suffix=-exoagent-examples) && cp -r . $TMP && npm --prefix $TMP i exoagent@$(npm show .. version) && npm --prefix $TMP run check" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.23", diff --git a/examples/saas-bot.test.ts b/examples/saas-bot.test.ts index 11d3d3a..0fb89cf 100644 --- a/examples/saas-bot.test.ts +++ b/examples/saas-bot.test.ts @@ -7,7 +7,7 @@ describe('saas-bot example e2e', () => { const model = createMockModel([ { code: `async ({ organization }) => { - return await organization() + return await organization .join(({ org }) => org.projects()) .select(({ project }) => ({ name: project.name, status: project.status })) .execute() @@ -34,7 +34,7 @@ describe('saas-bot example e2e', () => { const model = createMockModel([ { code: `async ({ organization }) => { - return await organization() + return await organization .join(({ org }) => org.projects()) .join(({ project }) => project.tasks()) .select(({ project, task }) => ({ @@ -69,7 +69,7 @@ describe('saas-bot example e2e', () => { const model = createMockModel([ { code: `async ({ organization }) => { - return await organization() + return await organization .join(({ org }) => org.projects()) .join(({ project }) => project.tasks()) .join(({ task }) => task.comments()) @@ -102,7 +102,7 @@ describe('saas-bot example e2e', () => { const model = createMockModel([ { code: `async ({ organization }) => { - return await organization() + return await organization .join(({ org }) => org.members()) .select(({ member }) => ({ name: member.name, role: member.role })) .execute() @@ -129,7 +129,7 @@ describe('saas-bot example e2e', () => { const model = createMockModel([ { code: `async ({ organization }) => { - return await organization() + return await organization .join(({ org }) => org.projects()) .join(({ project }) => project.tasks()) .select(({ task }) => ({ title: task.title, status: task.status, priority: task.priority })) diff --git a/examples/saas-bot.ts b/examples/saas-bot.ts index 195b8d0..1c72202 100755 --- a/examples/saas-bot.ts +++ b/examples/saas-bot.ts @@ -13,7 +13,7 @@ import type { LanguageModel } from 'ai' import process from 'node:process' import { generateText, stepCountIs } from 'ai' import BetterSqlite3 from 'better-sqlite3' -import { CodeMode, createDenoSandbox, tool } from 'exoagent' +import { codemode, tool } from 'exoagent' import { Database } from 'exoagent/sql' import { SqliteDialect } from 'kysely' import { getModel, runRepl } from './utils' @@ -200,10 +200,9 @@ async function chat(userPrompt: string, model: LanguageModel, orgId: number = 1) // Create a capability scoped to the specified organization const orgCap = Organization.on(o => o.id['='](orgId)).from() - // Wrap with CodeMode for sandboxed execution - const codeMode = new CodeMode(createDenoSandbox()) - const codeTool = await codeMode.wrap({ - organization: () => orgCap, + // Wrap with codemode for sandboxed execution + const codeTool = await codemode({ + organization: orgCap, }, `class Comment extends db.Table('comments').as('comment') { id = this.column('id') taskId = this.column('task_id') @@ -281,15 +280,15 @@ class Organization extends db.Table('organizations').as('org') { You have access to the current organization's data including projects, tasks, and team members. Use the execute tool to query the database. The API provides: -- organization(): Returns a query builder for the current org +- organization: Returns a query builder for the current org - org.members(): Returns the org's team members - org.projects(): Returns the org's projects - project.tasks(): Returns a project's tasks - task.comments(): Returns a task's comments Examples: -- (api) => api.organization().join(({ org }) => org.members()).select(({ member }) => member).execute() -- (api) => api.organization().join(({ org }) => org.projects()).join(({ project }) => project.tasks()).select(({ project, task }) => ({ projectName: project.name, taskName: task.title })).execute() +- (api) => api.organization.join(({ org }) => org.members()).select(({ member }) => member).execute() +- (api) => api.organization.join(({ org }) => org.projects()).join(({ project }) => project.tasks()).select(({ project, task }) => ({ projectName: project.name, taskName: task.title })).execute() Select must return a row object (not a flat column). diff --git a/examples/simple.test.ts b/examples/simple.test.ts index d1cf550..a8fbe16 100644 --- a/examples/simple.test.ts +++ b/examples/simple.test.ts @@ -7,7 +7,7 @@ describe('simple example e2e', () => { const model = createMockModel([ { code: `async ({ currentUser }) => { - return await currentUser() + return await currentUser .join(({ user }) => user.todos()) .select(({ todo }) => ({ title: todo.title, completed: todo.completed })) .execute() @@ -34,7 +34,7 @@ describe('simple example e2e', () => { const model = createMockModel([ { code: `async ({ currentUser }) => { - return await currentUser() + return await currentUser .join(({ user }) => user.todos()) .select(({ todo }) => ({ title: todo.title, completed: todo.completed })) .where(({ todo }) => todo.completed['='](0)) @@ -61,7 +61,7 @@ describe('simple example e2e', () => { const model = createMockModel([ { code: `async ({ currentUser }) => { - return await currentUser() + return await currentUser .join(({ user }) => user.todos()) .select(({ user, todo }) => ({ userName: user.name, title: todo.title })) .execute() diff --git a/examples/simple.ts b/examples/simple.ts index 65eadc5..e8fe9fe 100755 --- a/examples/simple.ts +++ b/examples/simple.ts @@ -13,7 +13,7 @@ import type { LanguageModel } from 'ai' import process from 'node:process' import { generateText, stepCountIs } from 'ai' import BetterSqlite3 from 'better-sqlite3' -import { CodeMode, createDenoSandbox, tool } from 'exoagent' +import { codemode, tool } from 'exoagent' import { Database } from 'exoagent/sql' import { SqliteDialect } from 'kysely' import { getModel, runRepl } from './utils' @@ -88,11 +88,8 @@ async function chat(userPrompt: string, model: LanguageModel, userId: number = 1 // Create a capability scoped to the specified user const userCap = User.on(u => u.id['='](userId)).from() - // Wrap with CodeMode for sandboxed execution - const codeMode = new CodeMode(createDenoSandbox()) - const codeTool = await codeMode.wrap({ - currentUser: () => userCap, - }, `class Todo extends db.Table('todos').as('todo') { + // Wrap with codemode for sandboxed execution + const codeTool = await codemode({ currentUser: userCap }, `class Todo extends db.Table('todos').as('todo') { id = this.column('id') userId = this.column('user_id') title = this.column('title') @@ -118,14 +115,14 @@ class User extends db.Table('users').as('user') { system: `You are a helpful assistant that helps users manage their todos. You have access to the current user's information and their todos. Use the execute tool to query the database. The API provides: -- currentUser(): Returns a query builder for the current user's data +- currentUser: Returns a query builder for the current user's data - user.todos(): Returns a query builder for the user's todos. Example: -- (api) => api.currentUser().join(({ user }) => user.todos()).select(({ todo }) => ({title: todo.title, completed: todo.completed})).execute() -- (api) => api.currentUser().join(({ user }) => user.todos()).where(({ todo }) => todo.completed['='](0)).select(({ todo }) => ({title: todo.title, completed: todo.completed})).execute() +- (api) => api.currentUser.join(({ user }) => user.todos()).select(({ todo }) => ({title: todo.title, completed: todo.completed})).execute() +- (api) => api.currentUser.join(({ user }) => user.todos()).where(({ todo }) => todo.completed['='](0)).select(({ todo }) => ({title: todo.title, completed: todo.completed})).execute() -To do a SELECT *, use this shorthand: (api) => api.currentUser().join(({ user }) => user.todos()).select(({ user }) => user) +To do a SELECT *, use this shorthand: (api) => api.currentUser.join(({ user }) => user.todos()).select(({ user }) => user) Select must return a row object (not a flat column). Always use .execute() at the end of your query chains to get results.`, diff --git a/package-lock.json b/package-lock.json index 66c54db..2472798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,10 @@ "license": "MIT", "dependencies": { "camelcase": "^9.0.0", - "capnweb": "file:dist/capnweb", "json-schema": "^0.4.0", "json-schema-to-typescript": "^15.0.0", "kysely": "^0.28.9", + "secure-json-parse": "^4.1.0", "tiny-invariant": "^1.3.3", "zod": "^4.2.1", "zod-to-json-schema": "^3.25.0" @@ -25,6 +25,7 @@ "@electric-sql/pglite": "^0.3.15", "@standard-schema/spec": "^1.1.0", "@types/node": "25.0.1", + "acorn": "^8.16.0", "bumpp": "10.3.2", "eslint": "9.39.2", "kysely-pglite-dialect": "^1.2.0", @@ -46,7 +47,9 @@ "ai": "^6.0.0" } }, - "dist/capnweb": {}, + "dist/capnweb": { + "extraneous": true + }, "node_modules/@ai-sdk/gateway": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.2.tgz", @@ -2868,9 +2871,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -3292,10 +3295,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/capnweb": { - "resolved": "dist/capnweb", - "link": true - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -7233,6 +7232,22 @@ "node": "^14.0.0 || >=16.0.0" } }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", diff --git a/package.json b/package.json index cc42e07..0c3a9a2 100644 --- a/package.json +++ b/package.json @@ -40,32 +40,27 @@ ], "scripts": { "clean": "rm -rf dist", - "build": "npm run clean && npm run build:runtime && npm run build:main && npm run build:capnweb && npm run build:test-deps", + "build": "npm run clean && npm run build:main", "build:main": "vite build", - "build:runtime": "esbuild src/code-mode-runtime.ts --bundle --format=esm --target=es2022 --outfile=dist/code-mode-runtime.mjs --packages=external", - "build:capnweb": "npm pack ./packages/capnweb --pack-destination dist && mkdir -p dist/capnweb && tar -xzf dist/capnweb-*.tgz --strip-components=1 -C dist/capnweb && rm dist/capnweb-*.tgz", - "build:test-deps": "tsdown --config tsdown.test-deps.config.ts", "dev": "tsdown --watch", "lint": "eslint", "prepublishOnly": "npm run build", "release": "bumpp", "start": "tsx src/index.ts", - "pretest": "npm run build:runtime", "test": "vitest --run", "typecheck": "tsc", - "ci:capnweb": "cd packages/capnweb && npm ci && npm run build", - "ci:examples": "npm --prefix ./examples run ci", - "ci": "npm run ci:capnweb && npm ci && npm run lint && npm run typecheck && npm run build && npm run test && npm run ci:examples" + "check:examples": "npm --prefix ./examples run check", + "check": "npm ci && npm run lint && npm run typecheck && npm run build && npm run test && npm run check:examples" }, "peerDependencies": { "ai": "^6.0.0" }, "dependencies": { "camelcase": "^9.0.0", - "capnweb": "file:dist/capnweb", "json-schema": "^0.4.0", "json-schema-to-typescript": "^15.0.0", "kysely": "^0.28.9", + "secure-json-parse": "^4.1.0", "tiny-invariant": "^1.3.3", "zod": "^4.2.1", "zod-to-json-schema": "^3.25.0" @@ -77,6 +72,7 @@ "@electric-sql/pglite": "^0.3.15", "@standard-schema/spec": "^1.1.0", "@types/node": "25.0.1", + "acorn": "^8.16.0", "bumpp": "10.3.2", "eslint": "9.39.2", "kysely-pglite-dialect": "^1.2.0", diff --git a/packages/capnweb b/packages/capnweb deleted file mode 160000 index e7025ae..0000000 --- a/packages/capnweb +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e7025ae4560603f70065a81e9830daf6690f7a0a diff --git a/src/capnweb-test-helpers.ts b/src/capnweb-test-helpers.ts deleted file mode 100644 index f705a7f..0000000 --- a/src/capnweb-test-helpers.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { RpcSessionOptions, RpcStub, RpcTarget, RpcTransport } from 'capnweb' -import { RpcSession } from 'capnweb' -import { expect } from 'vitest' - -class TestTransport implements RpcTransport { - constructor(public name: string, private partner?: TestTransport) { - if (partner) { - partner.partner = this - } - } - - private queue: string[] = [] - private waiter?: () => void - private aborter?: (err: any) => void - public log = false - - async send(message: string): Promise { - // HACK: If the string "$remove$" appears in the message, remove it. This is used in some - // tests to hack the RPC protocol. - message = message.replaceAll('$remove$', '') - - if (this.log) - // eslint-disable-next-line no-console - console.log(`${this.name}: ${message}`) - this.partner!.queue.push(message) - if (this.partner!.waiter) { - this.partner!.waiter() - this.partner!.waiter = undefined - this.partner!.aborter = undefined - } - } - - async receive(): Promise { - if (this.queue.length === 0) { - await new Promise((resolve, reject) => { - this.waiter = resolve - this.aborter = reject - }) - } - - return this.queue.shift()! - } - - forceReceiveError(error: any) { - this.aborter!(error) - } -} - -// Spin the microtask queue a bit to give messages time to be delivered and handled. -async function pumpMicrotasks() { - for (let i = 0; i < 16; i++) { - await Promise.resolve() - } -} - -export class TestHarness { - clientTransport: TestTransport - serverTransport: TestTransport - client: RpcSession - server: RpcSession - - stub: RpcStub - - constructor(target: T, serverOptions: RpcSessionOptions = { - onSendError: (error) => { - // console.log('onSendError:', inspect(error, { depth: null })) - return error - }, - }) { - this.clientTransport = new TestTransport('client') - this.serverTransport = new TestTransport('server', this.clientTransport) - - this.client = new RpcSession(this.clientTransport, undefined) - - // TODO: If I remove `` here, I get a TypeScript error about the instantiation being - // excessively deep and possibly infinite. Why? `` is supposed to be the default. - this.server = new RpcSession(this.serverTransport, target, serverOptions) - - this.stub = this.client.getRemoteMain() - } - - // Enable logging of all messages sent. Useful for debugging. - enableLogging() { - this.clientTransport.log = true - this.serverTransport.log = true - } - - checkAllDisposed() { - expect(this.client.getStats(), 'client').toStrictEqual({ imports: 1, exports: 1 }) - expect(this.server.getStats(), 'server').toStrictEqual({ imports: 1, exports: 1 }) - } - - async [Symbol.asyncDispose]() { - try { - // HACK: Spin the microtask loop for a bit to make sure dispose messages have been sent - // and received. - await pumpMicrotasks() - - // Check at the end of every test that everything was disposed. - this.checkAllDisposed() - } - catch (err) { - // Don't throw from disposer as it may suppress the real error that caused the disposal in - // the first place. - - // I couldn't find a better way to make vitest log a failure without throwing... - let message: string - if (err instanceof Error) { - message = err.stack || err.message - } - else { - message = `${err}` - } - expect.soft(true, message).toBe(false) - } - } -} diff --git a/src/code-mode-deno.test.ts b/src/code-mode-deno.test.ts deleted file mode 100644 index f168e23..0000000 --- a/src/code-mode-deno.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Tool } from 'ai' -import { jsonSchema } from 'ai' -import { describe, expect, it } from 'vitest' -import { createDenoSandbox } from './code-mode-deno.js' -import { CodeMode } from './code-mode.js' - -const codeMode = new CodeMode(createDenoSandbox()) - -describe('codeMode with Deno', () => { - it('executes user code that calls tools', async () => { - const tools: Tool[] = [ - { - description: 'Adds two numbers', - inputSchema: jsonSchema({ type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] }), - execute: async ({ a, b }: { a: number, b: number }) => ({ result: a + b }), - }, - { - description: 'Greets a person', - inputSchema: jsonSchema({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }), - execute: async ({ name }: { name: string }) => ({ message: `Hello, ${name}!` }), - }, - ] - - const wrappedTool = await codeMode.wrap(tools) - const result = await (wrappedTool.execute as (input: { code: string }) => Promise)({ - code: `async (api) => { - const addResult = await api.tool_0({ a: 5, b: 3 }) - const greetResult = await api.tool_1({ name: 'World' }) - return { sum: addResult.result, greeting: greetResult.message } - }`, - }) - - expect(result).toEqual({ sum: 8, greeting: 'Hello, World!' }) - }, 10000) - - it('handles tool errors', async () => { - const tools: Tool[] = [{ - description: 'Throws an error', - inputSchema: jsonSchema({ type: 'object', properties: {} }), - execute: async () => { throw new Error('Test error') }, - }] - - const wrappedTool = await codeMode.wrap(tools) - const result = await (wrappedTool.execute as (input: { code: string }) => Promise)({ - code: `async (api) => { - try { - await api.tool_0({}) - return { success: false } - } catch (error) { - return { success: true, error: error.message } - } - }`, - }) - - expect(result).toEqual({ success: true, error: 'Test error' }) - }, 10000) - - it('validates tool arguments and rejects invalid input', async () => { - const tools: Tool[] = [ - { - description: 'Adds two numbers', - inputSchema: jsonSchema({ type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] }), - execute: async ({ a, b }: { a: number, b: number }) => ({ result: a + b }), - }, - ] - - const wrappedTool = await codeMode.wrap(tools) - const result = await (wrappedTool.execute as (input: { code: string }) => Promise)({ - code: `async (api) => { - try { - // Passing string for number field 'a' - await api.tool_0({ a: 'not a number', b: 3 }) - return { success: false, error: 'Should have failed validation' } - } catch (error) { - const errorMsg = error?.message || String(error) - return { success: true, error: errorMsg } - } - }`, - }) - - expect(result).toMatchObject({ success: true }) - expect((result as { error: string }).error).toBe('Invalid arguments for tool tool_0: not a number - string value found, but a number is required') - }, 10000) -}) diff --git a/src/code-mode-deno.ts b/src/code-mode-deno.ts deleted file mode 100644 index 549fa6e..0000000 --- a/src/code-mode-deno.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Deno-specific sandbox implementation for CodeMode -// This provides a SafeEvalContext that uses Deno instead of Node.js - -import type { SafeEvalContext, SafeEvalResult } from './code-mode.js' -import { spawn } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Readable, Writable } from 'node:stream' - -/** - * Creates a SafeEvalContext that uses Deno as the sandbox runtime. - * - * @param options - Configuration options for Deno sandbox - * @param options.args - Deno custom arguments (default: []) - * @param options.denoPath - Path to deno executable (default: 'deno') - * @returns A SafeEvalContext configured for Deno - */ -export function createDenoSandbox(options: { - args?: string[] - denoPath?: string -} = {}): SafeEvalContext { - const { args = [], denoPath = 'deno' } = options - - return { - kind: 'direct', - safeEval: async (code: string): Promise => { - const tempDir = await mkdtemp(join(tmpdir(), 'exoagent-deno-')) - const tempFile = join(tempDir, 'code.ts') // Deno can run TypeScript directly - - await writeFile(tempFile, code, 'utf-8') - - // Deno command: deno run < ... args > code.ts - // Note: Deno's stdin/stdout are already Web Streams, but we need to convert - // from Node.js child_process streams to Web Streams for the parent process - const child = spawn(denoPath, ['run', ...args, tempFile], { - stdio: ['pipe', 'pipe', 'inherit'], - }) - - return { - input: Readable.toWeb(child.stdout) as ReadableStream, - output: Writable.toWeb(child.stdin) as WritableStream, - wait: () => new Promise((resolve, reject) => { - child.on('exit', async (code) => { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) - code === 0 ? resolve() : reject(new Error(`Deno process exited with code ${code}`)) - }) - child.on('error', async (err) => { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) - reject(err) - }) - }), - } - }, - sandboxContext: `Promise.resolve({ - input: Deno.stdin.readable, - output: Deno.stdout.writable, - onSuccess: () => Deno.exit(0), - onFailure: () => Deno.exit(1) - })`, - } -} diff --git a/src/code-mode-runtime.ts b/src/code-mode-runtime.ts deleted file mode 100644 index 0c1e5ec..0000000 --- a/src/code-mode-runtime.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Runtime template for sandbox execution -// This gets bundled with capnweb and StreamTransport, then injected into safeEval - -import type { ToolApi } from './tool-wrapper' -import { RpcSession, setGlobalRpcSessionOptions } from 'capnweb' -import { StreamTransport } from './stream-transport' - -setGlobalRpcSessionOptions(() => ({ recordReplayMode: 'all' })) - -declare const __SANDBOX_CONTEXT_PROMISE__: Promise<{ - input: ReadableStream - output: WritableStream - onSuccess: () => void - onFailure: () => void -}> | undefined - -async function run(context: { - input: ReadableStream - output: WritableStream -}) { - const transport = new StreamTransport(context.input, context.output) - try { - const session = new RpcSession(transport) - const api = session.getRemoteMain() - - // The actual runtime code to execute: - const code = await api.__code__() - - // eslint-disable-next-line no-new-func - const fn = new Function('api', `return (${code})(api)`) - // Actually execute the code: - const result = await fn(api) - // Return the result to the remote side: - await api.__return__(result) - } - finally { - // Abort transport to stop RPC read loop and allow process to exit naturally - transport.abort('done') - } -} - -async function main() { - if (!__SANDBOX_CONTEXT_PROMISE__) { - throw new TypeError('__SANDBOX_CONTEXT_PROMISE__ was not replaced prior to execution.') - } - const ctx = await __SANDBOX_CONTEXT_PROMISE__ - const { input, output, onSuccess, onFailure } = ctx - - return run({ input, output }) - .then(() => { - onSuccess() - }) - .catch((err) => { - console.error('runtime error', err) - onFailure() - }) -} - -main() diff --git a/src/code-mode.test.ts b/src/code-mode.test.ts index 7f4401f..7b6affd 100644 --- a/src/code-mode.test.ts +++ b/src/code-mode.test.ts @@ -1,63 +1,26 @@ import type { Tool } from 'ai' -import { spawn } from 'node:child_process' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Readable, Writable } from 'node:stream' -import { jsonSchema } from 'ai' +import { readFile } from 'node:fs/promises' import { describe, expect, it } from 'vitest' -import { CodeMode } from './code-mode.js' +import { z } from 'zod' +import { codemode } from './code-mode.js' import { TestToolset } from './rpc-toolset-test-helpers' -const codeMode = new CodeMode({ - kind: 'direct', - safeEval: async (code: string) => { - const tempDir = await mkdtemp(join(tmpdir(), 'exoagent-test-')) - const tempFile = join(tempDir, 'code.mjs') - await writeFile(tempFile, code, 'utf-8') - const child = spawn('node', [tempFile], { stdio: ['pipe', 'pipe', 'inherit'] }) - return { - input: Readable.toWeb(child.stdout) as ReadableStream, - output: Writable.toWeb(child.stdin) as WritableStream, - wait: () => new Promise((resolve, reject) => { - child.on('exit', async (code) => { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) - code === 0 ? resolve() : reject(new Error(`Process exited with code ${code}`)) - }) - child.on('error', async (err) => { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) - reject(err) - }) - }), - } - }, - sandboxContext: `(async () => { - const { Readable, Writable } = await import('node:stream') - return { - input: Readable.toWeb(process.stdin), - output: Writable.toWeb(process.stdout), - onSuccess: () => process.exit(0), - onFailure: () => process.exit(1) - } - })()`, -}) - describe('codeMode', () => { it('executes user code that calls tools', async () => { const tools: Tool[] = [ { description: 'Adds two numbers', - inputSchema: jsonSchema({ type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] }), + inputSchema: z.object({ a: z.number(), b: z.number() }), execute: async ({ a, b }: { a: number, b: number }) => ({ result: a + b }), }, { description: 'Greets a person', - inputSchema: jsonSchema({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }), + inputSchema: z.object({ name: z.string() }), execute: async ({ name }: { name: string }) => ({ message: `Hello, ${name}!` }), }, ] - const wrappedTool = await codeMode.wrap(tools) + const wrappedTool = await codemode(tools) const result = await (wrappedTool.execute as (input: { code: string }) => Promise)({ code: `async (api) => { const addResult = await api.tool_0({ a: 5, b: 3 }) @@ -69,63 +32,34 @@ describe('codeMode', () => { expect(result).toEqual({ sum: 8, greeting: 'Hello, World!' }) }, 10000) - it('handles tool errors', async () => { - const tools: Tool[] = [{ - description: 'Throws an error', - inputSchema: jsonSchema({ type: 'object', properties: {} }), - execute: async () => { throw new Error('Test error') }, - }] - - const wrappedTool = await codeMode.wrap(tools) - const result = await (wrappedTool.execute as (input: { code: string }) => Promise)({ - code: `async (api) => { - try { - await api.tool_0({}) - return { success: false } - } catch (error) { - return { success: true, error: error.message } - } - }`, - }) - - expect(result).toEqual({ success: true, error: 'Test error' }) - }, 10000) - it('validates tool arguments and rejects invalid input', async () => { const tools: Tool[] = [ { description: 'Adds two numbers', - inputSchema: jsonSchema({ type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] }), + inputSchema: z.object({ a: z.number(), b: z.number() }), execute: async ({ a, b }: { a: number, b: number }) => ({ result: a + b }), }, ] - const wrappedTool = await codeMode.wrap(tools) - const result = await (wrappedTool.execute as (input: { code: string }) => Promise)({ + const wrappedTool = await codemode(tools) + const result = (wrappedTool.execute as (input: { code: string }) => Promise)({ code: `async (api) => { - try { - // Passing string for number field 'a' - await api.tool_0({ a: 'not a number', b: 3 }) - return { success: false, error: 'Should have failed validation' } - } catch (error) { - const errorMsg = error?.message || String(error) - return { success: true, error: errorMsg } - } + await api.tool_0({ a: 'not a number', b: 3 }) + return { success: false, error: 'Should have failed validation' } }`, }) - expect(result).toMatchObject({ success: true }) - expect((result as { error: string }).error).toBe('Invalid arguments for tool tool_0: not a number - string value found, but a number is required') + await expect(result).rejects.toThrow(/Invalid value/) }, 10000) it('executes user code that calls RpcToolset tools', async () => { // Assume npm run build:test-deps has been run - const dtsContent = await readFile('dist/rpc-toolset-test-helpers.d.mts', 'utf-8') + const dtsContent = await readFile('dist/rpc-toolset-test-helpers.d.ts', 'utf-8') - const wrappedTool = await codeMode.wrap({ testToolset: () => new TestToolset() }, dtsContent) + const wrappedTool = await codemode({ testToolset: new TestToolset() }, dtsContent) const result = await (wrappedTool.execute as (input: { code: string }) => Promise)({ code: `async (api) => { - const toolset = await api.testToolset() + const toolset = api.testToolset const addResult = await toolset.add({ a: 10, b: 5 }) return { result: addResult } }`, @@ -136,15 +70,14 @@ describe('codeMode', () => { it('executes user code that chains RpcToolset tools', async () => { // Assume npm run build:test-deps has been run - const dtsContent = await readFile('dist/rpc-toolset-test-helpers.d.mts', 'utf-8') + const dtsContent = await readFile('dist/rpc-toolset-test-helpers.d.ts', 'utf-8') - const wrappedTool = await codeMode.wrap({ testToolset: () => new TestToolset() }, dtsContent) + const wrappedTool = await codemode({ testToolset: new TestToolset() }, dtsContent) const result = await (wrappedTool.execute as (input: { code: string }) => Promise)({ code: `async (api) => { - // Note we *don't* need \`await\`s here because Cap'n Web implement promise-pipelining - const toolset1 = api.testToolset() - const toolset2 = toolset1.toolset2() - return { result: toolset2.subtract({ a: 20, b: 8 }) } + const toolset1 = api.testToolset + const toolset2 = await toolset1.toolset2() + return { result: await toolset2.subtract({ a: 20, b: 8 }) } }`, }) @@ -153,24 +86,18 @@ describe('codeMode', () => { it('validates RpcToolset tool arguments and rejects invalid input', async () => { // Assume npm run build:test-deps has been run - const dtsContent = await readFile('dist/rpc-toolset-test-helpers.d.mts', 'utf-8') + const dtsContent = await readFile('dist/rpc-toolset-test-helpers.d.ts', 'utf-8') - const wrappedTool = await codeMode.wrap({ testToolset: () => new TestToolset() }, dtsContent) - const result = await (wrappedTool.execute as (input: { code: string }) => Promise)({ + const wrappedTool = await codemode({ testToolset: new TestToolset() }, dtsContent) + const result = (wrappedTool.execute as (input: { code: string }) => Promise)({ code: `async (api) => { - try { - const toolset = api.testToolset() + const toolset = api.testToolset // Passing string for number field 'a' await toolset.add({ a: 'not a number', b: 3 }) return { success: false, error: 'Should have failed validation' } - } catch (error) { - const errorMsg = error?.message || String(error) - return { success: true, error: errorMsg } - } }`, }) - expect(result).toMatchObject({ success: true }) - expect((result as { error: string }).error).toContain('Invalid value') + await expect(result).rejects.toThrow(/Invalid value: Invalid input: expected number, received string for argument 0/) }, 10000) }) diff --git a/src/code-mode.ts b/src/code-mode.ts index f9b3079..a0f0850 100644 --- a/src/code-mode.ts +++ b/src/code-mode.ts @@ -1,34 +1,10 @@ import type { Tool, ToolExecutionOptions } from 'ai' -import type { RpcTarget } from 'capnweb' -import type { RpcToolset } from './rpc-toolset' import type { WrappableTools } from './tool-wrapper' -import { RpcSession } from 'capnweb' import { z } from 'zod' -// eslint-disable-next-line antfu/no-import-dist -import runtimeCode from '../dist/code-mode-runtime.mjs?raw' -import { StreamTransport } from './stream-transport' -import { generateToolApi, generateToolTypes } from './tool-wrapper' - -export type SafeEvalResult = { - wait: () => Promise - input: ReadableStream - output: WritableStream -} - -export type SafeEvalContext = { - kind: 'direct' - safeEval: (code: string) => Promise - // See code-mode-runtime.ts for the expected format of the sandbox context - sandboxContext: string -} | { - // We pass the code to the remote side to evaluate (over Cap'n Web), - // along with the API object: - kind: 'rpc' - safeEval: (code: string, api: RpcTarget) => Promise -} +import { exoEval } from './exoeval' +import { generateToolTypes, wrapTools } from './tool-wrapper' type FlatTools = { [key: string]: Tool } | Tool[] -type RpcTools = { [key: string]: () => RpcToolset } type ExecutableTool = { description: string @@ -36,33 +12,27 @@ type ExecutableTool = { execute: (input: { code: string }, opts: ToolExecutionOptions) => Promise } -export class CodeMode { - constructor(private context: SafeEvalContext) {} - - wrap(tools: FlatTools): Promise> - wrap(tools: RpcTools | FlatTools, dts: string): Promise> - async wrap(tools: WrappableTools, dts?: string): Promise> { - // 1. Consume raw tools - - const typeDefinitions: string[] = [] - for await (const chunk of generateToolTypes(tools, 'Tools')) { +export function codemode(tools: FlatTools): Promise> +export function codemode(tools: object, dts: string): Promise> +export async function codemode(tools: object, dts?: string): Promise> { + const typeDefinitions: string[] = [] + if (dts) { + typeDefinitions.push(dts) + } + else { + for await (const chunk of generateToolTypes(tools as WrappableTools, 'Tools')) { typeDefinitions.push(chunk) } - const definitions = typeDefinitions.join('') + } - // 2. Generate new tool that uses the tools (as classes) - return { - description: `Execute code using the following API. You MUST call this tool to run any code - never output code directly in your response. + const definitions = typeDefinitions.join('') + + return { + description: `Execute code using the following API. You MUST call this tool to run any code - never output code directly in your response. \`\`\`typescript ${definitions} - type Primitive = string | number | boolean | null | undefined | bigint | Date | Uint8Array | Error; - type Returnable = Primitive | { [key: string]: Returnable } | Returnable[]; - \`\`\` - - ${dts ? `// .d.ts for the \`RpcToolset\`s:\n${dts}` : ''} - Provide a valid **javascript** (NOT TypeScript) function taking a single argument of type \`Tools\` and returning a value of type \`Promise\`. @@ -74,28 +44,16 @@ export class CodeMode { } \`\`\` `, - inputSchema: z.object({ - code: z.string(), - }), - execute: async ({ code }: { code: string }, opts: ToolExecutionOptions): Promise => { - const ToolApi = generateToolApi(tools, opts) - const api = new ToolApi(code) - - if (this.context.kind === 'rpc') { - return await this.context.safeEval(code, api) - } - - // 1. Inject sandbox context into bundled runtime - const injectedCode = `globalThis.__SANDBOX_CONTEXT_PROMISE__ = ${this.context.sandboxContext};\n${runtimeCode}` - const { input, output, wait } = await this.context.safeEval(injectedCode) - - // 2. Hook up the input and output streams: - const transport = new StreamTransport(input, output) - const _session = new RpcSession(transport, api) - // Remote side should have access to api via RPC and execute the code - await wait() - return api.__return_value__ as unknown as R - }, - } + inputSchema: z.object({ + code: z.string(), + }), + execute: async ({ code }: { code: string }, opts: ToolExecutionOptions): Promise => { + const fn = exoEval(code) + if (typeof fn !== 'function') { + throw new TypeError('Code did not return a function') + } + const wrapped = dts ? tools : wrapTools(tools as WrappableTools, opts) + return await fn(wrapped) + }, } } diff --git a/src/exoeval/builtins.ts b/src/exoeval/builtins.ts new file mode 100644 index 0000000..9f6e2f5 --- /dev/null +++ b/src/exoeval/builtins.ts @@ -0,0 +1,366 @@ +import type { ExpressionContext } from './expr' +import sjson from 'secure-json-parse' +import z from 'zod' +import { isPlainObject } from './expr' +import { expr, fn, tool } from './tool' +import { disallowedProperties } from './utils' + +@tool() +export class ExoArray { + constructor(...args: Parameters) { + // eslint-disable-next-line unicorn/no-new-array + return new Array(...args) as unknown as ExoArray + } + + @tool() + get length(): number { + if (!Array.isArray(this)) { + throw new TypeError('unexpected: `this` not bound to an array') + } + return this.length + } + + @tool(fn.returns(z.any())) + get map() { return Array.prototype.map } + + @tool(fn.returns(z.any())) + get filter() { return Array.prototype.filter } + + @tool(fn.returns(z.any()), z.any().optional()) + get reduce() { return Array.prototype.reduce } + + @tool(fn.returns(z.any()), z.any().optional()) + get reduceRight() { return Array.prototype.reduceRight } + + @tool(fn.returns(z.any())) + get find() { return Array.prototype.find } + + @tool(fn.returns(z.any())) + get findIndex() { return Array.prototype.findIndex } + + @tool(fn.returns(z.any())) + get findLast() { return Array.prototype.findLast } + + @tool(fn.returns(z.any())) + get findLastIndex() { return Array.prototype.findLastIndex } + + @tool(fn.returns(z.any())) + get some() { return Array.prototype.some } + + @tool(fn.returns(z.any())) + get every() { return Array.prototype.every } + + @tool(fn.returns(z.any())) + get flatMap() { return Array.prototype.flatMap } + + @tool(fn.returns(z.any()).optional()) + get toSorted() { return Array.prototype.toSorted } + + @tool(z.number()) + get at() { return Array.prototype.at } + + @tool(z.number().optional(), z.number().optional()) + get slice() { return Array.prototype.slice } + + @tool(z.string().optional()) + get join() { return Array.prototype.join } + + @tool(z.array(z.any())) + get concat() { return Array.prototype.concat } + + @tool(z.any(), z.number().optional()) + get indexOf() { return Array.prototype.indexOf } + + @tool(z.any(), z.number().optional()) + get lastIndexOf() { return Array.prototype.lastIndexOf } + + @tool(z.any()) + get includes() { return Array.prototype.includes } + + @tool(z.number().optional()) + get flat() { return Array.prototype.flat } + + @tool() + get entries() { return Array.prototype.entries } + + @tool() + get keys() { return Array.prototype.keys } + + @tool() + get values() { return Array.prototype.values } + + @tool() + get toReversed() { return Array.prototype.toReversed } + + @tool(z.number(), z.number().optional(), z.any().optional()) + get toSpliced() { return Array.prototype.toSpliced } + + @tool(z.number(), z.any()) + get with() { return Array.prototype.with } + + // Statics + @tool(z.array(z.any())) + static from(arrayLike: ArrayLike) { return Array.from(arrayLike) } + + @tool(z.any()) + static isArray(value: unknown) { return Array.isArray(value) } +} + +@tool(z.union([z.string(), z.number(), z.instanceof(Date), z.boolean(), z.null(), z.undefined(), z.bigint()])) +export class ExoString { + constructor(...args: Parameters) { + // eslint-disable-next-line unicorn/new-for-builtins, no-new-wrappers + return new String(...args) as unknown as ExoString + } + + @tool() + get length(): number { + if (!(typeof this === 'string')) { + throw new TypeError('unexpected: `this` not bound to a string') + } + return (this as string).length + } + + @tool(z.number()) + get at() { return String.prototype.at } + + @tool(z.number()) + get charAt() { return String.prototype.charAt } + + @tool(z.number()) + get charCodeAt() { return String.prototype.charCodeAt } + + @tool(z.number()) + get codePointAt() { return String.prototype.codePointAt } + + @tool(z.string()) + get concat() { return String.prototype.concat } + + @tool(z.string(), z.number().optional()) + get endsWith() { return String.prototype.endsWith } + + @tool(z.string(), z.number().optional()) + get includes() { return String.prototype.includes } + + @tool(z.string(), z.number().optional()) + get indexOf() { return String.prototype.indexOf } + + @tool(z.string(), z.number().optional()) + get lastIndexOf() { return String.prototype.lastIndexOf } + + @tool(z.number(), z.string().optional()) + get padEnd() { return String.prototype.padEnd } + + @tool(z.number(), z.string().optional()) + get padStart() { return String.prototype.padStart } + + @tool(z.number()) + get repeat() { return String.prototype.repeat } + + @tool(z.string(), z.string()) + get replace() { return String.prototype.replace } + + @tool(z.string(), z.string()) + get replaceAll() { return String.prototype.replaceAll } + + @tool(z.number().optional(), z.number().optional()) + get slice() { return String.prototype.slice } + + @tool(z.string(), z.number().optional()) + get split() { return String.prototype.split } + + @tool(z.string(), z.number().optional()) + get startsWith() { return String.prototype.startsWith } + + @tool(z.number().optional(), z.number().optional()) + get substring() { return String.prototype.substring } + + @tool() + get toLowerCase() { return String.prototype.toLowerCase } + + @tool() + get toUpperCase() { return String.prototype.toUpperCase } + + @tool() + get trim() { return String.prototype.trim } + + @tool() + get trimEnd() { return String.prototype.trimEnd } + + @tool() + get trimStart() { return String.prototype.trimStart } +} + +@tool(z.union([z.string(), z.number(), z.instanceof(Date)]).optional()) +export class ExoDate { + constructor() + constructor(value: string | number | Date) + constructor(year: number, month: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number) + constructor(...args: Parameters) { + return new Date(...args) + } + + // Date.prototype methods require native Date as this; bind raw so asTool receives correct receiver + @tool() + get getTime() { return Date.prototype.getTime } + + @tool() + get getFullYear() { return Date.prototype.getFullYear } + + @tool() + get getMonth() { return Date.prototype.getMonth } + + @tool() + get getDate() { return Date.prototype.getDate } + + @tool() + get getHours() { return Date.prototype.getHours } + + @tool() + get getMinutes() { return Date.prototype.getMinutes } + + @tool() + get getSeconds() { return Date.prototype.getSeconds } + + @tool() + get getMilliseconds() { return Date.prototype.getMilliseconds } + + @tool() + get toISOString() { return Date.prototype.toISOString } + + @tool(z.string().optional(), z.record(z.string(), z.union([z.string(), z.boolean(), z.undefined()])).optional()) + get toLocaleDateString() { return Date.prototype.toLocaleDateString } + + @tool(z.string().optional(), z.record(z.string(), z.union([z.string(), z.boolean(), z.undefined()])).optional()) + get toLocaleTimeString() { return Date.prototype.toLocaleTimeString } + + @tool(z.string().optional(), z.record(z.string(), z.union([z.string(), z.boolean(), z.undefined()])).optional()) + get toLocaleString() { return Date.prototype.toLocaleString } + + @tool() + get valueOf() { return Date.prototype.valueOf } + + // Statics + @tool() + static now() { return Date.now() } + + @tool(z.string()) + static parse(dateString: string) { return Date.parse(dateString) } +} + +export class ExoObject { + @expr() + static keys(ctx: ExpressionContext, obj: unknown) { + const seq = ctx.sequence(obj) + if (!isPlainObject(seq)) + throw new TypeError('Object.keys requires an object') + return ctx.distribute(Object.keys(seq).map(k => ctx.of(k))) + } + + @expr() + static values(ctx: ExpressionContext, obj: unknown) { + const seq = ctx.sequence(obj) + if (!isPlainObject(seq)) + throw new TypeError('Object.values requires an object') + return ctx.distribute(Object.values(seq)) + } + + @expr() + static entries(ctx: ExpressionContext, obj: unknown) { + const seq = ctx.sequence(obj) + if (!isPlainObject(seq)) + throw new TypeError('Object.entries requires an object') + return ctx.distribute( + Object.entries(seq).map(([k, v]) => ctx.distribute([ctx.of(k), v])), + ) + } + + @tool(z.array(z.tuple([z.intersection(z.string(), z.custom(k => !disallowedProperties.has(k as string), { + error: k => `${k.input} is not an allowed property name`, + })), z.any()]))) + static fromEntries(entries: [string, unknown][]) { + return Object.fromEntries(entries) + } +} + +@tool(z.any()) +export class ExoBoolean { + constructor(...args: Parameters) { + // eslint-disable-next-line unicorn/new-for-builtins, no-new-wrappers + return new Boolean(...args) + } + + @tool() + get valueOf() { return Boolean.prototype.valueOf } +} + +export class ExoJSON { + @tool(z.string()) + static parse(text: string) { return sjson.parse(text) } + + @tool(z.any(), z.union([z.null(), z.undefined()]), z.number().optional()) + static stringify(value: unknown, replacer?: null, space?: number) { + return JSON.stringify(value, replacer, space) + } +} + +export class ExoMath { + @tool(z.number(), z.number()) + static min(a: number, b: number) { return Math.min(a, b) } + + @tool(z.number(), z.number()) + static max(a: number, b: number) { return Math.max(a, b) } + + @tool(z.number()) + static round(x: number) { return Math.round(x) } + + @tool(z.number()) + static floor(x: number) { return Math.floor(x) } + + @tool(z.number()) + static ceil(x: number) { return Math.ceil(x) } + + @tool(z.number()) + static abs(x: number) { return Math.abs(x) } + + @tool(z.number()) + static sqrt(x: number) { return Math.sqrt(x) } + + @tool(z.number(), z.number()) + static pow(base: number, exp: number) { return base ** exp } + + @tool(z.number()) + static log(x: number) { return Math.log(x) } + + @tool() + static random() { return Math.random() } + + @tool(z.number()) + static sign(x: number) { return Math.sign(x) } + + @tool(z.number()) + static trunc(x: number) { return Math.trunc(x) } +} + +@tool(z.union([z.string(), z.number(), z.bigint()])) +export class ExoNumber { + constructor(...args: Parameters) { + // eslint-disable-next-line unicorn/new-for-builtins, no-new-wrappers + return new Number(...args) + } + + @tool(z.string(), z.number().optional()) + static parseInt(s: string, radix?: number) { return Number.parseInt(s, radix) } + + @tool(z.string()) + static parseFloat(s: string) { return Number.parseFloat(s) } + + @tool(z.any()) + static isNaN(value: unknown) { return Number.isNaN(value) } + + @tool(z.any()) + static isFinite(value: unknown) { return Number.isFinite(value) } + + @tool(z.any()) + static isInteger(value: unknown) { return Number.isInteger(value) } +} diff --git a/src/exoeval/evaluator.test.ts b/src/exoeval/evaluator.test.ts new file mode 100644 index 0000000..e2a50d1 --- /dev/null +++ b/src/exoeval/evaluator.test.ts @@ -0,0 +1,834 @@ +import { describe, expect, it } from 'vitest' +import { exoEval } from './index' + +describe('exoEval', () => { + it('literal (number)', () => { + expect(exoEval('42')).toBe(42) + }) + + it('literal (string)', () => { + expect(exoEval('"hello"')).toBe('hello') + }) + + it('literal (boolean)', () => { + expect(exoEval('true')).toBe(true) + }) + + it('identifier (via const)', () => { + expect(exoEval('const x = 1; x')).toBe(1) + }) + + it('memberExpression', () => { + expect(exoEval('const o = { a: 2 }; o.a')).toBe(2) + }) + + it('callExpression', () => { + expect(exoEval('const f = () => 3; f()')).toBe(3) + }) + + it('arrayExpression', () => { + expect(exoEval('[1, 2, 3]')).toEqual([1, 2, 3]) + }) + + it('objectExpression', () => { + expect(exoEval('({ x: 4 })')).toEqual({ x: 4 }) + }) + + it('objectExpression (computed key)', () => { + expect(exoEval('const k = "a"; ({ [k]: 1 })')).toEqual({ a: 1 }) + expect(exoEval('const key = "b"; ({ [key]: 2 }).b')).toBe(2) + }) + + it('unaryExpression (!)', () => { + expect(exoEval('!false')).toBe(true) + }) + + it('unaryExpression (typeof)', () => { + expect(exoEval('typeof "x"')).toBe('string') + }) + + it('arrowFunctionExpression', () => { + const fn = exoEval('() => 5') as () => number + expect(fn()).toBe(5) + }) + + it('blockStatement', () => { + expect(exoEval('{ 6 }')).toBe(6) + }) + + it('blockStatement (scope isolation)', () => { + expect(exoEval('const x = 1; { const x = 2; x }')).toBe(2) + expect(exoEval('const x = 1; { const x = 2; }; x')).toBe(1) + }) + + it('emptyStatement', () => { + expect(exoEval(';')).toBeUndefined() + }) + + it('variableDeclaration', () => { + expect(exoEval('const a = 7; a')).toBe(7) + }) + + it('expressionStatement', () => { + expect(exoEval('8')).toBe(8) + }) + + it('returnStatement (inside arrow)', () => { + const fn = exoEval('() => { return 9 }') as () => number + expect(fn()).toBe(9) + }) + + it('returnStatement (no argument)', () => { + const fn = exoEval('() => { return; }') as () => undefined + expect(fn()).toBeUndefined() + }) + + it('returnStatement (early return: subsequent statements not executed)', () => { + const fn = exoEval('() => { return 42; (() => { throw new Error("must not run"); })(); }') as () => number + expect(fn()).toBe(42) + }) + + it('ifStatement (then)', () => { + expect(exoEval('if (true) 10')).toBe(10) + }) + + it('ifStatement (else)', () => { + expect(exoEval('if (false) 0; else 11')).toBe(11) + }) + + it('ifStatement (no else, condition false)', () => { + expect(exoEval('if (false) 1')).toBeUndefined() + }) + + it('ifStatement short-circuits: then branch only when condition true', () => { + expect(exoEval('if (true) 1; else (() => { throw new Error("else must not run"); })()')).toBe(1) + }) + it('ifStatement short-circuits: else branch only when condition false', () => { + expect(exoEval('if (false) (() => { throw new Error("then must not run"); })(); else 2')).toBe(2) + }) + + describe('binding (const)', () => { + it('identifier', () => { + expect(exoEval('const x = 1; x')).toBe(1) + }) + it('array pattern', () => { + expect(exoEval('const [a, b] = [10, 20]; [a, b]')).toEqual([10, 20]) + }) + it('array pattern with hole', () => { + expect(exoEval('const [, , c] = [1, 2, 3]; c')).toBe(3) + }) + it('array pattern with rest', () => { + expect(exoEval('const [a, ...r] = [1, 2, 3]; r')).toEqual([2, 3]) + }) + it('object pattern (shorthand)', () => { + expect(exoEval('const { a, b } = { a: 5, b: 6 }; [a, b]')).toEqual([5, 6]) + }) + it('object pattern (rename)', () => { + expect(exoEval('const { a: x } = { a: 7 }; x')).toBe(7) + }) + it('object pattern with rest', () => { + expect(exoEval('const { a, ...r } = { a: 1, b: 2 }; r')).toEqual({ b: 2 }) + }) + it('object pattern (computed key)', () => { + expect(exoEval('const k = "x"; const { [k]: v } = { x: 42 }; v')).toBe(42) + expect(exoEval('const key = "foo"; const { [key]: val } = { foo: 10 }; val')).toBe(10) + }) + it('assignment pattern (default)', () => { + expect(exoEval('const [a = 0] = []; a')).toBe(0) + }) + it('nested: object then array', () => { + expect(exoEval('const { a: [x, y] } = { a: [1, 2] }; [x, y]')).toEqual([1, 2]) + }) + it('nested: array then object', () => { + expect(exoEval('const [{ a }, b] = [{ a: 4 }, 5]; [a, b]')).toEqual([4, 5]) + }) + }) + + describe('binding (fn args)', () => { + it('identifier', () => { + expect(exoEval('const f = (x) => x; f(42)')).toBe(42) + }) + it('array pattern', () => { + expect(exoEval('const f = ([a, b]) => [a, b]; f([1, 2])')).toEqual([1, 2]) + }) + it('object pattern', () => { + expect(exoEval('const f = ({ a, b }) => [a, b]; f({ a: 10, b: 3 })')).toEqual([10, 3]) + }) + it('object pattern (computed key)', () => { + expect(exoEval('const k = "a"; const f = ({ [k]: v }) => v; f({ a: 5 })')).toBe(5) + }) + it('assignment pattern (default)', () => { + expect(exoEval('const f = (x = 10) => x; f()')).toBe(10) + }) + it('rest only', () => { + expect(exoEval('const f = (...rest) => rest; f(1, 2, 3)')).toEqual([1, 2, 3]) + }) + it('rest with leading', () => { + expect(exoEval('const f = (a, b, ...rest) => rest; f(1, 2, 3, 4)')).toEqual([3, 4]) + }) + it('nested: object then array', () => { + expect(exoEval('const f = ({ a: [x, y] }) => [x, y]; f({ a: [10, 20] })')).toEqual([10, 20]) + }) + }) + + describe('optional chaining (member ?.)', () => { + it('returns property when object exists', () => { + expect(exoEval('const o = { a: 1 }; o?.a')).toBe(1) + }) + it('returns undefined when object is null', () => { + expect(exoEval('const o = null; o?.a')).toBeUndefined() + }) + it('returns undefined when object is undefined', () => { + expect(exoEval('const o = undefined; o?.a')).toBeUndefined() + }) + it('short-circuits: does not access property on null', () => { + expect(exoEval('const o = null; o?.x')).toBeUndefined() + }) + it('nested optional chain', () => { + expect(exoEval('const o = { a: { b: 2 } }; o?.a?.b')).toBe(2) + expect(exoEval('const o = { a: null }; o?.a?.b')).toBeUndefined() + }) + }) + + describe('optional call (?.)', () => { + it('calls when callee is function', () => { + expect(exoEval('const f = () => 3; f?.()')).toBe(3) + }) + it('returns undefined when callee is null', () => { + expect(exoEval('const f = null; f?.()')).toBeUndefined() + }) + it('returns undefined when callee is undefined', () => { + expect(exoEval('const f = undefined; f?.()')).toBeUndefined() + }) + it('short-circuits: does not evaluate arguments when callee is null', () => { + const fn = exoEval('const f = null; const side = () => { throw new Error("eval"); }; f?.(side())') as () => unknown + expect(fn).toBeUndefined() + }) + it('method optional call', () => { + expect(exoEval('const o = { m: () => 4 }; o.m?.()')).toBe(4) + expect(exoEval('const o = { m: null }; o.m?.()')).toBeUndefined() + }) + }) + + describe('conditional (ternary)', () => { + it('consequent when test is true', () => { + expect(exoEval('true ? 1 : 2')).toBe(1) + }) + it('alternate when test is false', () => { + expect(exoEval('false ? 1 : 2')).toBe(2) + }) + it('falsy test uses alternate', () => { + expect(exoEval('0 ? 1 : 2')).toBe(2) + expect(exoEval('"" ? 1 : 2')).toBe(2) + expect(exoEval('null ? 1 : 2')).toBe(2) + }) + it('truthy non-boolean uses consequent', () => { + expect(exoEval('1 ? 10 : 20')).toBe(10) + expect(exoEval('"x" ? 10 : 20')).toBe(10) + }) + it('nested ternary', () => { + expect(exoEval('true ? false ? 1 : 2 : 3')).toBe(2) + expect(exoEval('false ? 1 : true ? 4 : 5')).toBe(4) + }) + it('short-circuits: consequent only when test truthy', () => { + expect(exoEval('true ? 1 : (() => { throw new Error("alternate must not run"); })()')).toBe(1) + }) + it('short-circuits: alternate only when test falsy', () => { + expect(exoEval('false ? (() => { throw new Error("consequent must not run"); })() : 2')).toBe(2) + }) + }) + + describe('binary operators', () => { + describe('equality', () => { + it('===', () => { + expect(exoEval('1 === 1')).toBe(true) + expect(exoEval('1 === 2')).toBe(false) + expect(exoEval('"a" === "a"')).toBe(true) + expect(exoEval('null === null')).toBe(true) + }) + it('!==', () => { + expect(exoEval('1 !== 2')).toBe(true) + expect(exoEval('1 !== 1')).toBe(false) + }) + it('==', () => { + expect(exoEval('1 == 1')).toBe(true) + expect(exoEval('1 == "1"')).toBe(true) + }) + it('!=', () => { + expect(exoEval('1 != "1"')).toBe(false) + expect(exoEval('1 != 2')).toBe(true) + }) + }) + describe('comparison (numbers)', () => { + it('< > <= >=', () => { + expect(exoEval('2 < 3')).toBe(true) + expect(exoEval('2 > 3')).toBe(false) + expect(exoEval('3 <= 3')).toBe(true) + expect(exoEval('4 >= 3')).toBe(true) + }) + }) + describe('arithmetic', () => { + it('+ - * / %', () => { + expect(exoEval('2 + 3')).toBe(5) + expect(exoEval('5 - 2')).toBe(3) + expect(exoEval('2 * 3')).toBe(6) + expect(exoEval('7 / 2')).toBe(3.5) + expect(exoEval('7 % 2')).toBe(1) + }) + it('**', () => { + expect(exoEval('2 ** 8')).toBe(256) + }) + }) + describe('bitwise', () => { + it('<< >> >>>', () => { + expect(exoEval('1 << 2')).toBe(4) + expect(exoEval('8 >> 2')).toBe(2) + expect(exoEval('1 >>> 0')).toBe(1) + }) + it('& | ^', () => { + expect(exoEval('5 & 3')).toBe(1) + expect(exoEval('5 | 3')).toBe(7) + expect(exoEval('5 ^ 3')).toBe(6) + }) + }) + }) + + describe('logical expression', () => { + it('&&', () => { + expect(exoEval('true && true')).toBe(true) + expect(exoEval('true && false')).toBe(false) + expect(exoEval('false && true')).toBe(false) + expect(exoEval('1 && 2')).toBe(2) + expect(exoEval('0 && 2')).toBe(0) + }) + it('||', () => { + expect(exoEval('false || true')).toBe(true) + expect(exoEval('false || false')).toBe(false) + expect(exoEval('1 || 2')).toBe(1) + expect(exoEval('0 || 2')).toBe(2) + }) + it('??', () => { + expect(exoEval('null ?? 1')).toBe(1) + expect(exoEval('undefined ?? 2')).toBe(2) + expect(exoEval('0 ?? 1')).toBe(0) + expect(exoEval('false ?? 1')).toBe(false) + }) + it('short-circuits: && does not evaluate RHS when LHS is falsy', () => { + expect(exoEval('false && (() => { throw new Error("RHS must not run"); })()')).toBe(false) + expect(exoEval('0 && (() => { throw new Error("RHS must not run"); })()')).toBe(0) + }) + it('short-circuits: || does not evaluate RHS when LHS is truthy', () => { + expect(exoEval('true || (() => { throw new Error("RHS must not run"); })()')).toBe(true) + expect(exoEval('1 || (() => { throw new Error("RHS must not run"); })()')).toBe(1) + }) + it('short-circuits: ?? does not evaluate RHS when LHS is not null/undefined', () => { + expect(exoEval('0 ?? (() => { throw new Error("RHS must not run"); })()')).toBe(0) + expect(exoEval('false ?? (() => { throw new Error("RHS must not run"); })()')).toBe(false) + }) + }) + + describe('template literal', () => { + it('only static parts', () => { + expect(exoEval('`hello`')).toBe('hello') + }) + it('expression as only element', () => { + // eslint-disable-next-line no-template-curly-in-string + expect(exoEval('const x = 1; `${x}`')).toBe('1') + }) + it('expression first (no leading quasi)', () => { + // eslint-disable-next-line no-template-curly-in-string + expect(exoEval('const x = 2; `${x} world`')).toBe('2 world') + }) + it('expression in middle', () => { + // eslint-disable-next-line no-template-curly-in-string + expect(exoEval('const x = 3; `a ${x} b`')).toBe('a 3 b') + }) + it('multiple expressions', () => { + // eslint-disable-next-line no-template-curly-in-string + expect(exoEval('const a = 1; const b = 2; `${a}+${b}`')).toBe('1+2') + }) + it('expression last', () => { + // eslint-disable-next-line no-template-curly-in-string + expect(exoEval('const x = 4; `end ${x}`')).toBe('end 4') + }) + it('number and boolean coerced to string', () => { + // eslint-disable-next-line no-template-curly-in-string + expect(exoEval('`${42} ${true}`')).toBe('42 true') + }) + it('empty template', () => { + expect(exoEval('``')).toBe('') + }) + it('single expression only (no quasis either side)', () => { + // eslint-disable-next-line no-template-curly-in-string + expect(exoEval('const x = "X"; `${x}`')).toBe('X') + }) + }) + + describe('edge cases', () => { + it('optional chain on undefined then property access throws without ?.', () => { + expect(() => exoEval('const o = undefined; o.x')).toThrow() + }) + it('ternary with expressions in both branches (only one evaluated)', () => { + expect(exoEval('true ? 1 + 1 : (() => { throw new Error("alternate must not run"); })()')).toBe(2) + expect(exoEval('false ? (() => { throw new Error("consequent must not run"); })() : 2 + 2')).toBe(4) + }) + it('binary + with only numbers', () => { + expect(exoEval('10 + 20')).toBe(30) + }) + it('binary + with strings concatenates', () => { + expect(exoEval('"hello" + " " + "world"')).toBe('hello world') + expect(exoEval('"x" + 1')).toBe('x1') + expect(exoEval('1 + "x"')).toBe('1x') + }) + it('logical with nested binary', () => { + expect(exoEval('(1 < 2) && (3 > 2)')).toBe(true) + expect(exoEval('(1 === 1) || (2 === 3)')).toBe(true) + }) + it('template with ternary', () => { + // eslint-disable-next-line no-template-curly-in-string + expect(exoEval('const ok = true; `result: ${ok ? "yes" : "no"}`')).toBe('result: yes') + }) + it('optional chain with computed property', () => { + expect(exoEval('const o = { a: 1 }; const k = "a"; o?.[k]')).toBe(1) + expect(exoEval('const o = null; const k = "a"; o?.[k]')).toBeUndefined() + }) + }) + + describe('array builtins', () => { + it('arr.length', () => { + expect(exoEval('[1, 2, 3].length')).toBe(3) + }) + it('arr[0] still works', () => { + expect(exoEval('[10, 20, 30][0]')).toBe(10) + }) + it('arr.map', () => { + expect(exoEval('[1, 2, 3].map((x) => x * 2)')).toEqual([2, 4, 6]) + }) + it('arr.filter', () => { + expect(exoEval('[1, 2, 3, 4].filter((x) => x > 2)')).toEqual([3, 4]) + }) + it('arr.reduce', () => { + expect(exoEval('[1, 2, 3].reduce((acc, x) => acc + x, 0)')).toBe(6) + }) + it('arr.find', () => { + expect(exoEval('[1, 2, 3].find((x) => x > 1)')).toBe(2) + }) + it('arr.some', () => { + expect(exoEval('[1, 2, 3].some((x) => x > 2)')).toBe(true) + }) + it('arr.every', () => { + expect(exoEval('[1, 2, 3].every((x) => x > 0)')).toBe(true) + }) + it('arr.includes', () => { + expect(exoEval('[1, 2, 3].includes(2)')).toBe(true) + expect(exoEval('[1, 2, 3].includes(5)')).toBe(false) + }) + it('arr.indexOf', () => { + expect(exoEval('[1, 2, 3].indexOf(2)')).toBe(1) + }) + it('arr.join', () => { + expect(exoEval('[1, 2, 3].join("-")')).toBe('1-2-3') + }) + it('arr.slice', () => { + expect(exoEval('[1, 2, 3, 4].slice(1, 3)')).toEqual([2, 3]) + }) + it('arr.at', () => { + expect(exoEval('[10, 20, 30].at(-1)')).toBe(30) + }) + it('arr.flat', () => { + expect(exoEval('[[1, 2], [3, 4]].flat()')).toEqual([1, 2, 3, 4]) + }) + it('chained: filter then map', () => { + expect(exoEval('[1, 2, 3, 4].filter((x) => x > 1).map((x) => x * 2)')).toEqual([4, 6, 8]) + }) + }) + + describe('string builtins', () => { + it('str.length', () => { + expect(exoEval('"hello".length')).toBe(5) + }) + it('str.toUpperCase', () => { + expect(exoEval('"hello".toUpperCase()')).toBe('HELLO') + }) + it('str.toLowerCase', () => { + expect(exoEval('"Hello".toLowerCase()')).toBe('hello') + }) + it('str.slice', () => { + expect(exoEval('"hello".slice(1, 3)')).toBe('el') + }) + it('str.split', () => { + expect(exoEval('"a,b,c".split(",")')).toEqual(['a', 'b', 'c']) + }) + it('str.includes', () => { + expect(exoEval('"hello world".includes("world")')).toBe(true) + }) + it('str.startsWith', () => { + expect(exoEval('"hello".startsWith("hel")')).toBe(true) + }) + it('str.endsWith', () => { + expect(exoEval('"hello".endsWith("llo")')).toBe(true) + }) + it('str.trim', () => { + expect(exoEval('" hello ".trim()')).toBe('hello') + }) + it('str.indexOf', () => { + expect(exoEval('"hello".indexOf("ll")')).toBe(2) + }) + it('str.replace', () => { + expect(exoEval('"hello world".replace("world", "there")')).toBe('hello there') + }) + it('str.repeat', () => { + expect(exoEval('"ab".repeat(3)')).toBe('ababab') + }) + it('str.charAt', () => { + expect(exoEval('"hello".charAt(1)')).toBe('e') + }) + it('str.at', () => { + expect(exoEval('"hello".at(-1)')).toBe('o') + }) + it('str.padStart', () => { + expect(exoEval('"5".padStart(3, "0")')).toBe('005') + }) + it('str.substring', () => { + expect(exoEval('"hello".substring(1, 4)')).toBe('ell') + }) + }) + + describe('object builtins', () => { + it('object.keys', () => { + expect(exoEval('Object.keys({ a: 1, b: 2 })')).toEqual(['a', 'b']) + }) + it('object.values', () => { + expect(exoEval('Object.values({ a: 1, b: 2 })')).toEqual([1, 2]) + }) + it('object.entries', () => { + expect(exoEval('Object.entries({ a: 1 })')).toEqual([['a', 1]]) + }) + }) + + describe('date builtins', () => { + it('new Date with string', () => { + expect(exoEval('const d = new Date("2024-01-15T12:00:00Z"); d.getFullYear()')).toBe(2024) + }) + it('date methods', () => { + expect(exoEval('const d = new Date("2024-06-15T10:30:45Z"); d.getMonth()')).toBe(5) + expect(exoEval('const d = new Date("2024-06-15T10:30:45Z"); d.getDate()')).toBe(15) + }) + it('date.now returns a number', () => { + const result = exoEval('Date.now()') as number + expect(typeof result).toBe('number') + }) + it('toISOString', () => { + expect(exoEval('const d = new Date("2024-01-01T00:00:00Z"); d.toISOString()')).toBe('2024-01-01T00:00:00.000Z') + }) + it('toLocaleDateString with locale and options', () => { + expect(exoEval('const d = new Date("2024-01-15T12:00:00Z"); d.toLocaleDateString("en-US", { month: "long" })')).toContain('January') + }) + }) + + describe('JSON builtins', () => { + it('JSON.parse', () => { + expect(exoEval('JSON.parse(\'{"a":1,"b":2}\')')).toEqual({ a: 1, b: 2 }) + }) + it('JSON.parse array', () => { + expect(exoEval('JSON.parse("[1,2,3]")')).toEqual([1, 2, 3]) + }) + it('JSON.stringify', () => { + expect(exoEval('JSON.stringify({ a: 1 })')).toBe('{"a":1}') + }) + it('JSON.stringify with indent', () => { + expect(exoEval('JSON.stringify({ a: 1 }, null, 2)')).toBe('{\n "a": 1\n}') + }) + it('JSON.parse then access', () => { + expect(exoEval('const obj = JSON.parse(\'{"x":42}\'); obj.x')).toBe(42) + }) + it('roundtrip', () => { + expect(exoEval('JSON.parse(JSON.stringify([1, "two", true]))')).toEqual([1, 'two', true]) + }) + }) + + describe('Math builtins', () => { + it('Math.min', () => { + expect(exoEval('Math.min(3, 7)')).toBe(3) + }) + it('Math.max', () => { + expect(exoEval('Math.max(3, 7)')).toBe(7) + }) + it('Math.round', () => { + expect(exoEval('Math.round(4.6)')).toBe(5) + expect(exoEval('Math.round(4.4)')).toBe(4) + }) + it('Math.floor', () => { + expect(exoEval('Math.floor(4.9)')).toBe(4) + }) + it('Math.ceil', () => { + expect(exoEval('Math.ceil(4.1)')).toBe(5) + }) + it('Math.abs', () => { + expect(exoEval('Math.abs(-5)')).toBe(5) + }) + it('Math.sqrt', () => { + expect(exoEval('Math.sqrt(9)')).toBe(3) + }) + it('Math.pow', () => { + expect(exoEval('Math.pow(2, 10)')).toBe(1024) + }) + it('Math.log', () => { + expect(exoEval('Math.log(1)')).toBe(0) + }) + it('Math.random returns number in [0,1)', () => { + const result = exoEval('Math.random()') as number + expect(typeof result).toBe('number') + expect(result).toBeGreaterThanOrEqual(0) + expect(result).toBeLessThan(1) + }) + it('Math.sign', () => { + expect(exoEval('Math.sign(-10)')).toBe(-1) + expect(exoEval('Math.sign(0)')).toBe(0) + expect(exoEval('Math.sign(10)')).toBe(1) + }) + it('Math.trunc', () => { + expect(exoEval('Math.trunc(4.9)')).toBe(4) + expect(exoEval('Math.trunc(-4.9)')).toBe(-4) + }) + }) + + describe('Number builtins', () => { + it('Number.parseInt', () => { + expect(exoEval('Number.parseInt("42")')).toBe(42) + }) + it('Number.parseInt with radix', () => { + expect(exoEval('Number.parseInt("ff", 16)')).toBe(255) + }) + it('Number.parseFloat', () => { + expect(exoEval('Number.parseFloat("3.14")')).toBeCloseTo(3.14) + }) + it('Number.isNaN', () => { + expect(exoEval('Number.isNaN(0 / 0)')).toBe(true) + expect(exoEval('Number.isNaN(42)')).toBe(false) + }) + it('Number.isFinite', () => { + expect(exoEval('Number.isFinite(42)')).toBe(true) + expect(exoEval('Number.isFinite(1 / 0)')).toBe(false) + }) + it('Number.isInteger', () => { + expect(exoEval('Number.isInteger(42)')).toBe(true) + expect(exoEval('Number.isInteger(42.5)')).toBe(false) + }) + }) + + describe('Object.fromEntries safety', () => { + it('blocks __proto__ key', () => { + expect(() => exoEval('Object.fromEntries([["__proto__", {}]])')).toThrow(/__proto__/) + }) + it('blocks constructor key', () => { + expect(() => exoEval('Object.fromEntries([["constructor", {}]])')).toThrow(/constructor/) + }) + it('valid fromEntries works', () => { + expect(exoEval('Object.fromEntries([["a", 1], ["b", 2]])')).toEqual({ a: 1, b: 2 }) + }) + it('roundtrip entries/fromEntries', () => { + expect(exoEval('Object.fromEntries(Object.entries({ x: 10, y: 20 }))')).toEqual({ x: 10, y: 20 }) + }) + }) + + describe('Object.prototype fields are not accessible', () => { + const blockedFields = ['constructor', '__proto__'] + + for (const field of Object.getOwnPropertyNames(Object.prototype)) { + if (blockedFields.includes(field)) { + it(`${field} throws on access`, () => { + expect(() => exoEval(`({}).${field}`)).toThrow(new RegExp(`${field}.*not allowed`)) + expect(() => exoEval(`({})["${field}"]`)).toThrow(new RegExp(`${field}.*not allowed`)) + }) + it(`${field} throws on assignment`, () => { + expect(() => exoEval(`({ ${field}: 1 })`)).toThrow(new RegExp(`${field}.*not allowed`)) + expect(() => exoEval(`({ ["${field}"]: 1 })`)).toThrow(new RegExp(`${field}.*not allowed`)) + }) + } + else { + it(`${field} returns undefined on access`, () => { + expect(exoEval(`({}).${field}`)).toBeUndefined() + expect(exoEval(`({})["${field}"]`)).toBeUndefined() + }) + it(`${field} is allowed in object literal`, () => { + expect(exoEval(`({ ${field}: 1 }).${field}`)).toBe(1) + expect(exoEval(`({ ["${field}"]: 1 })["${field}"]`)).toBe(1) + }) + } + } + }) + + describe('Function.prototype fields are not accessible', () => { + const blockedFields = ['constructor', '__proto__'] + + for (const field of Object.getOwnPropertyNames(Function.prototype)) { + if (blockedFields.includes(field)) { + it(`${field} throws on access`, () => { + expect(() => exoEval(`(() => {}).${field}`)).toThrow(new RegExp(`${field}.*not allowed`)) + expect(() => exoEval(`(() => {})["${field}"]`)).toThrow(new RegExp(`${field}.*not allowed`)) + }) + } + else { + it(`${field} returns undefined on access`, () => { + expect(exoEval(`(() => {}).${field}`)).toBeUndefined() + expect(exoEval(`(() => {})["${field}"]`)).toBeUndefined() + }) + } + } + }) + + describe('Prototype pollution via spread', () => { + it('blocks __proto__ key from JSON.parse in spread', () => { + // secure-json-parse throws its own error for __proto__ + expect(() => exoEval('({ ...JSON.parse(\'{"__proto__": {"pwned": true}}\') })')).toThrow(/(__proto__|forbidden prototype)/) + }) + it('blocks constructor key from JSON.parse in spread', () => { + expect(() => exoEval('({ ...JSON.parse(\'{"constructor": "evil"}\') })')).toThrow(/constructor.*not allowed/) + }) + }) + + describe('Prototype pollution via crafted object', () => { + it('blocks __proto__ key in spread', () => { + // Create object with __proto__ as an own enumerable property using Object.defineProperty + const malicious = Object.defineProperty({}, '__proto__', { + value: { pwned: true }, + enumerable: true, + }) + // Use code-mode to test passing in external objects + const fn = exoEval('(obj) => ({ ...obj })') as (obj: object) => object + expect(() => fn(malicious)).toThrow(/__proto__.*not allowed/) + }) + it('blocks constructor key in spread', () => { + const malicious = Object.defineProperty({}, 'constructor', { + value: 'evil', + enumerable: true, + }) + const fn = exoEval('(obj) => ({ ...obj })') as (obj: object) => object + expect(() => fn(malicious)).toThrow(/constructor.*not allowed/) + }) + }) + + describe('Class prototype access', () => { + it('Array.prototype returns undefined', () => { + expect(exoEval('Array.prototype')).toBeUndefined() + }) + it('String.prototype returns undefined', () => { + expect(exoEval('String.prototype')).toBeUndefined() + }) + it('Date.prototype returns undefined', () => { + expect(exoEval('Date.prototype')).toBeUndefined() + }) + it('Object.prototype returns undefined', () => { + expect(exoEval('Object.prototype')).toBeUndefined() + }) + }) + + describe('Dangerous Object methods are not exposed', () => { + const dangerousMethods = [ + 'getPrototypeOf', + 'setPrototypeOf', + 'defineProperty', + 'getOwnPropertyDescriptor', + 'create', + 'assign', + ] + for (const method of dangerousMethods) { + it(`Object.${method} returns undefined`, () => { + expect(exoEval(`Object.${method}`)).toBeUndefined() + }) + } + }) + + describe('Globals are not accessible', () => { + const globals = [ + 'globalThis', + 'window', + 'global', + 'self', + 'process', + 'require', + 'module', + 'exports', + '__dirname', + '__filename', + 'eval', + 'Function', + 'Proxy', + 'Reflect', + 'Symbol', + 'Error', + 'Promise', + 'setTimeout', + 'setInterval', + 'Buffer', + 'RegExp', + ] + for (const name of globals) { + it(`${name} is not defined`, () => { + expect(() => exoEval(name)).toThrow(/variable is not defined/) + }) + } + }) + + describe('Dangerous statements are blocked', () => { + const blocked = [ + ['for loop', 'for (let i = 0; i < 10; i++) {}'], + ['while loop', 'while (true) {}'], + ['do-while', 'do {} while (true)'], + ['try-catch', 'try { } catch (e) { }'], + ['throw', 'throw new Error("test")'], + ['class declaration', 'class Foo {}'], + ['function declaration', 'function foo() {}'], + ['with statement', 'with ({}) {}'], + ['debugger', 'debugger'], + ['let declaration', 'let x = 1'], + ['var declaration', 'var x = 1'], + ] + for (const [name, code] of blocked) { + it(`${name} is blocked`, () => { + expect(() => exoEval(code)).toThrow() + }) + } + }) + + describe('Dangerous expressions are blocked', () => { + const blocked = [ + ['delete operator', 'const o = {a:1}; delete o.a'], + ['void operator', 'void 0'], + ['this expression', 'this'], + ['assignment expression', 'const o = {a:1}; o.a = 2'], + ['update expression', 'const o = {a:1}; o.a++'], + ['sequence expression', '(1, 2, 3)'], + ['tagged template', 'const t = (s) => s; t`test`'], + ['getter in object literal', '({ get x() { return 1 } })'], + ['setter in object literal', '({ set x(v) { } })'], + ] + for (const [name, code] of blocked) { + it(`${name} is blocked`, () => { + expect(() => exoEval(code)).toThrow() + }) + } + }) + + describe('Thenable objects cannot be created', () => { + it('then property in object literal throws', () => { + expect(() => exoEval('({ then: (r) => r(1) })')).toThrow(/then.*not allowed/) + }) + it('then property access throws', () => { + expect(() => exoEval('const o = {}; o.then')).toThrow(/then.*not allowed/) + }) + }) + + describe('Regex literals are inert', () => { + it('regex literal parses', () => { + expect(exoEval('/test/')).toBeInstanceOf(RegExp) + }) + it('regex.test() cannot be called', () => { + expect(() => exoEval('/test/.test("test")')).toThrow(/callee is not a toolable function/) + }) + it('regex.exec() cannot be called', () => { + expect(() => exoEval('/test/.exec("test")')).toThrow(/callee is not a toolable function/) + }) + }) +}) diff --git a/src/exoeval/evaluator.ts b/src/exoeval/evaluator.ts new file mode 100644 index 0000000..fa4b10a --- /dev/null +++ b/src/exoeval/evaluator.ts @@ -0,0 +1,472 @@ +import type * as acorn from 'acorn' +import type { Control, ExpressionContext } from './expr' +import { getControl, isPlainObject, makeControl } from './expr' +import { Scope } from './scope' +import { getTool, isExprFunction, isToolableConstructor, isToolableFunction } from './tool' +import { disallowedProperties, Invariant } from './utils' + +const internal = Symbol('internal') +type InternalFn any> = F & { [internal]: acorn.Node } +const internalFn = any>(fn: F, node: acorn.Node): InternalFn => { + (fn as any)[internal] = node + return fn as InternalFn +} +const isInternalFn = (fn: unknown): fn is InternalFn => { + return typeof fn === 'function' && (fn as any)[internal] !== undefined +} + +export type EvalResult = Generator> | Expr, Ret, unknown> +type InternalControl + = { + control: 'await' + value: Expr + } + | { + control: 'return' + value: Expr + } + +type BuiltinPrototypes = { + Array?: object + String?: object + Number?: object + Boolean?: object + Date?: object +} + +export class Evaluator { + public readonly inv: Invariant + constructor( + private readonly ast: acorn.Node, + readonly code: string, + public readonly ctx: ExpressionContext, + public readonly scope: Scope, + public readonly builtinPrototypes: BuiltinPrototypes, + ) { + this.inv = new Invariant(code) + } + + with({ newScope }: { newScope?: boolean }): Evaluator { + return new Evaluator(this.ast, this.code, this.ctx, newScope ? new Scope(this.scope) : this.scope, this.builtinPrototypes) + } + + * Identifier(node: acorn.Identifier): EvalResult { + if (node.name === 'undefined') { + return this.ctx.of(undefined) + } + return this.scope.get(node, this) + } + + * Literal(node: acorn.Literal): EvalResult { + return this.ctx.of(node.value) + } + + * ChainExpression(node: acorn.ChainExpression): EvalResult { + return yield* this.Expression(node.expression) + } + + * MemberExpression(node: acorn.MemberExpression): EvalResult { + this.inv.parse(node.object.type !== 'Super', '`super` is not allowed', node) + this.inv.parse(node.property.type !== 'PrivateIdentifier', 'private identifiers are not allowed', node) + const object = yield* this.Expression(node.object) + + const property = node.property.type === 'Identifier' && !node.computed ? node.property.name : yield* this.$(node.property) + this.inv.eval(typeof property === 'string' || typeof property === 'number', 'property is not a string or number', node, property) + + return yield* this.getExoProperty(object, property, node.property, node.optional) + } + + * evalArray(elements: (acorn.Expression | acorn.SpreadElement | null)[]): EvalResult { + const array: Expr[] = [] + for (const element of elements) { + if (element == null) { + continue + } + if (element.type === 'SpreadElement') { + const spread = yield* this.Expression(element.argument) + const spreadArray = this.ctx.sequence(spread) + this.inv.eval(Array.isArray(spreadArray), 'spread is not an array', element.argument, spreadArray) + array.push(...spreadArray) + } + else { + array.push(yield* this.Expression(element)) + } + } + return array + } + + * CallExpression(node: acorn.CallExpression): EvalResult { + this.inv.parse(node.callee.type !== 'Super', '`super` is not allowed', node) + + const callee = yield* this.Expression(node.callee) + const calleeRaw = yield callee + if (calleeRaw == null && node.optional) { + // Short-circuit: don't eval the arguments if the callee is null or undefined + return this.ctx.of(undefined) + } + + const args = yield* this.evalArray(node.arguments) + if (isInternalFn(calleeRaw)) { + return Reflect.apply(calleeRaw, undefined, args) + } + if (isExprFunction(calleeRaw)) { + return Reflect.apply(calleeRaw as (...args: Expr[]) => Expr, undefined, [this.ctx, ...args]) + } + this.inv.eval(isToolableFunction(calleeRaw), 'callee is not a toolable function', node, calleeRaw) + return this.ctx.call(callee, args) + } + + * ArrayExpression(node: acorn.ArrayExpression): EvalResult { + const array = yield* this.evalArray(node.elements) + return this.ctx.distribute(array) + } + + assertAllowedProperty(key: string | number, node: acorn.Node) { + this.inv.eval(!disallowedProperties.has(String(key)), `${key} is not allowed`, node, key) + } + + defineProperty(obj: object, key: string | number, value: unknown, node: acorn.Node) { + this.assertAllowedProperty(key, node) + Object.defineProperty(obj, key, { value, writable: false, enumerable: true }) + } + + * getExoProperty(obj: Expr, key: string | number, node: acorn.Node, optional: boolean): EvalResult { + this.assertAllowedProperty(key, node) + const sequenced = this.ctx.sequence(obj) + + if (Array.isArray(sequenced) && typeof key === 'number') { + return sequenced[key] + } + if (sequenced == null) { + this.inv.eval(optional, 'object is null or undefined', node, obj) + return this.ctx.of(undefined) + } + const desc = Object.getOwnPropertyDescriptor(sequenced, key) + if (isPlainObject(sequenced) && desc != null) { + return desc?.value ?? desc?.get?.call(obj) ?? this.ctx.of(undefined) + } + + const objRaw = yield obj + const objForLookup = this.getBuiltinPrototype(objRaw) ?? objRaw + const tool = getTool(objRaw, objForLookup, key) + return this.ctx.of(tool) + } + + getBuiltinPrototype(value: unknown): unknown { + if (typeof value === 'string') + return this.builtinPrototypes.String + if (typeof value === 'number') + return this.builtinPrototypes.Number + if (typeof value === 'boolean') + return this.builtinPrototypes.Boolean + if (Array.isArray(value)) + return this.builtinPrototypes.Array + if (value instanceof Date) + return this.builtinPrototypes.Date + return null + } + + * ObjectExpression(node: acorn.ObjectExpression): EvalResult { + const obj: { [key: string | number]: Expr } = {} + for (const property of node.properties) { + if (property.type === 'Property') { + const key = property.key.type === 'Identifier' && !property.computed ? property.key.name : yield* this.$(property.key) + this.inv.eval(typeof key === 'string' || typeof key === 'number', 'key is not a string or number', property.key, key) + const value = yield* this.Expression(property.value) + this.defineProperty(obj, key, value, property.key) + } + else { + const spread = this.ctx.sequence(yield* this.Expression(property.argument)) + this.inv.eval(isPlainObject(spread), 'can only spread plain objects', property.argument, spread) + for (const [key, value] of Object.entries(spread)) { + this.defineProperty(obj, key, value, property.argument) + } + } + } + return this.ctx.distribute(obj) + } + + * AwaitExpression(node: acorn.AwaitExpression): EvalResult { + const result = yield makeControl({ control: 'await', value: yield* this.Expression(node.argument) } as const) + this.inv.eval(this.ctx.isExpr(result), 'internal error: result is not an expression', node, result) + return result + } + + * evalStatements(statements: (acorn.Statement | acorn.ModuleDeclaration)[]): EvalResult { + let result = this.ctx.of(undefined) + for (const statement of statements) { + this.inv.parse( + statement.type !== 'ImportDeclaration' && statement.type !== 'ExportAllDeclaration' && statement.type !== 'ExportNamedDeclaration' && statement.type !== 'ExportDefaultDeclaration', + 'statement is not a statement', + statement, + ) + + result = yield* this.Statement(statement) + } + return result + } + + * evalFunctionCall(node: acorn.Function, args: Expr[]): EvalResult { + const child = this.with({ newScope: true }) + for (const [i, param] of node.params.entries()) { + let arg: Expr + if (param.type === 'RestElement') { + this.inv.parse(i === node.params.length - 1, 'rest element must be last', param) + arg = child.ctx.distribute(args.slice(i)) + } + else { + arg = args[i] + } + yield* child.scope.bind(param, arg, child) + } + if (node.body.type === 'BlockStatement') { + return yield* child.evalStatements(node.body.body) + } + else { + const result = yield* child.Expression(node.body) + return yield* child.makeReturn(result, node.body) + } + } + + * ArrowFunctionExpression(node: acorn.ArrowFunctionExpression): EvalResult { + let fn: (...args: Expr[]) => Expr | Promise + if (node.async) { + fn = async (...args: Expr[]) => { + const iter = this.ctx.doGen(this.evalFunctionCall(node, args)) + let step = iter.next() + while (!step.done) { + const raw = getControl(step.value) + if (raw.control === 'return') { + return raw.value + } + raw.control satisfies 'await' + step = iter.next(await this.ctx.sequencePromiseLike(raw.value)) + } + return this.ctx.of(undefined) + } + } + else { + fn = (...args: Expr[]) => { + const iter = this.ctx.doGen(this.evalFunctionCall(node, args)) + const step = iter.next() + if (step.done) { + return this.ctx.of(undefined) + } + const raw = getControl(step.value) + this.inv.eval(raw.control === 'return', 'unexpected control statement', node, step.value) + return raw.value + } + } + return this.ctx.of(internalFn(fn, node)) + } + + * UnaryExpression(node: acorn.UnaryExpression): EvalResult { + const operand = yield* this.$(node.argument) + switch (node.operator) { + case '!': + return this.ctx.of(!operand) + case '-': + this.inv.eval(typeof operand === 'number', 'operand is not a number', node, operand) + return this.ctx.of(-operand) + case 'typeof': + return this.ctx.of(typeof operand) + default: + this.inv.parse(false, `unsupported unary operator: ${node.operator}`, node) + } + } + + * ConditionalExpression(node: acorn.ConditionalExpression): EvalResult { + // Short-circuit: don't eval the condition if the test is null or undefined + const condition = yield* this.$(node.test) + if (condition) { + return yield* this.Expression(node.consequent) + } + else { + return yield* this.Expression(node.alternate) + } + } + + * BinaryExpression(node: acorn.BinaryExpression): EvalResult { + this.inv.parse(node.left.type !== 'PrivateIdentifier', 'private identifiers are not allowed', node) + const left = yield* this.$(node.left) + const right = yield* this.$(node.right) + switch (node.operator) { + case '===': + return this.ctx.of(left === right) + case '!==': + return this.ctx.of(left !== right) + case '==': + // eslint-disable-next-line eqeqeq + return this.ctx.of(left == right) + case '!=': + // eslint-disable-next-line eqeqeq + return this.ctx.of(left != right) + } + + this.inv.eval((typeof left === 'number' || typeof left === 'string') && (typeof right === 'number' || typeof right === 'string'), 'left and right are not numbers or strings', node, { left, right }) + + switch (node.operator) { + case '<': + return this.ctx.of(left < right) + case '>': + return this.ctx.of(left > right) + case '<=': + return this.ctx.of(left <= right) + case '+': + return this.ctx.of((left as any) + (right as any)) + } + + this.inv.eval((typeof left === 'number') && (typeof right === 'number'), 'left and right are not numbers', node, { left, right }) + switch (node.operator) { + case '>=': + return this.ctx.of(left >= right) + case '<<': + return this.ctx.of(left << right) + case '>>': + return this.ctx.of(left >> right) + case '>>>': + return this.ctx.of(left >>> right) + case '&': + return this.ctx.of(left & right) + case '|': + return this.ctx.of(left | right) + case '^': + return this.ctx.of(left ^ right) + case '%': + return this.ctx.of(left % right) + case '/': + return this.ctx.of(left / right) + case '*': + return this.ctx.of(left * right) + case '**': + return this.ctx.of(left ** right) + case '-': + return this.ctx.of(left - right) + default: + this.inv.parse(false, `unsupported binary operator: ${node.operator}`, node) + } + } + + * LogicalExpression(node: acorn.LogicalExpression): EvalResult { + const left = yield* this.$(node.left) + // Short-circuit: don't eval the right if the left is null or undefined + switch (node.operator) { + case '&&': + return this.ctx.of(left && (yield* this.$(node.right))) + case '||': + return this.ctx.of(left || (yield* this.$(node.right))) + case '??': + return this.ctx.of(left ?? (yield* this.$(node.right))) + default: + node.operator satisfies never + this.inv.parse(false, `unsupported logical operator: ${node.operator}`, node) + } + } + + * TemplateLiteral(node: acorn.TemplateLiteral): EvalResult { + const result: (string | number | boolean)[] = [] + for (const [i, quasi] of node.quasis.entries()) { + this.inv.eval(quasi.value.cooked != null, 'invalid template literal', quasi, quasi.value.raw) + result.push(quasi.value.cooked) + const expr = node.expressions[i] + if (expr) { + const val = yield* this.$(expr) + this.inv.eval(typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean', 'template expressions must evaluate to a string, number, or boolean', expr, val) + result.push(val) + } + } + return this.ctx.of(result.join('')) + } + + * NewExpression(node: acorn.NewExpression): EvalResult { + const calee = yield* this.$(node.callee as acorn.Expression) + + this.inv.eval( + isToolableConstructor(calee), + 'constructor is not a toolable class', + node, + calee, + ) + + const args = yield* this.evalArray(node.arguments) + const rawArgs: unknown[] = [] + for (const arg of args) { + rawArgs.push(yield arg) + } + + const instance = Reflect.construct(calee, rawArgs) + return this.ctx.of(instance) + } + + * Expression(node: acorn.Expression): EvalResult { + const supportedExpressions = ['ArrayExpression', 'ArrowFunctionExpression', 'AwaitExpression', 'BinaryExpression', 'CallExpression', 'ConditionalExpression', 'Function', 'Identifier', 'Literal', 'LogicalExpression', 'MemberExpression', 'NewExpression', 'ObjectExpression', 'TemplateLiteral', 'UnaryExpression', 'ChainExpression'] as const + this.inv.parse(supportedExpressions.includes(node.type as (typeof supportedExpressions)[number]), `unsupported expression type: ${node.type}`, node) + return yield* (this as any)[node.type](node) as unknown as EvalResult + } + + * $(node: acorn.Expression): EvalResult { + return yield yield* this.Expression(node) + } + + * makeReturn(value: Expr, node: acorn.Node): EvalResult { + yield makeControl({ control: 'return', value } as const) + this.inv.eval(false, 'internal error: code executed a return statement', node, value) + } + + * Statement(node: acorn.Statement): EvalResult { + if (node.type === 'BlockStatement') { + return yield* this.with({ newScope: true }).evalStatements(node.body) + } + if (node.type === 'EmptyStatement') { + return this.ctx.of(undefined) + } + if (node.type === 'VariableDeclaration') { + this.inv.parse(node.kind === 'const', 'variable declaration must be `const`', node) + for (const declaration of node.declarations) { + this.inv.parse(declaration.init != null, 'variable declaration must have an initializer', declaration) + const value = yield* this.Expression(declaration.init) + yield* this.scope.bind(declaration.id, value, this) + } + return this.ctx.of(undefined) + } + if (node.type === 'ExpressionStatement') { + return yield* this.Expression(node.expression) + } + if (node.type === 'ReturnStatement') { + const value = node.argument == null ? this.ctx.of(undefined) : yield* this.Expression(node.argument) + yield* this.makeReturn(value, node) + } + if (node.type === 'IfStatement') { + // Short-circuit: don't eval the condition if the test is null or undefined + const condition = yield* this.$(node.test) + if (condition) { + return yield* this.Statement(node.consequent) + } + else if (node.alternate) { + return yield* this.Statement(node.alternate) + } + return this.ctx.of(undefined) + } + this.inv.parse(false, 'unsupported statement type', node) + } + + Program(node: acorn.Program): Expr | Promise { + const iter = this.ctx.doGen(this.evalStatements(node.body)) + let step = iter.next() + if (step.done) { + return step.value + } + this.inv.eval(getControl(step.value).control === 'await', 'unexpected top-level control statement', node, step.value) + + // Need to evaluate it as async: + const fn = async () => { + while (!step.done) { + const raw = getControl(step.value) + this.inv.eval(raw.control === 'await', 'internal error: control is not `await`', node, step.value) + step = iter.next(await this.ctx.sequencePromiseLike(raw.value)) + } + return step.value + } + return fn() + } +} diff --git a/src/exoeval/expr.ts b/src/exoeval/expr.ts new file mode 100644 index 0000000..2ae2df7 --- /dev/null +++ b/src/exoeval/expr.ts @@ -0,0 +1,100 @@ +import { toolFieldsSymbol } from './tool' + +const control = Symbol('control') +export type Control = { [control]: V } + +export function getControl(cntrl: C): C[typeof control] +export function getControl(value: object): { value: unknown } | undefined +export function getControl(value: object): { value: unknown } | undefined { + return (value as Control)[control] as { value: unknown } | undefined +} +export const isControl = (value: unknown): value is Control => { + return value != null && (getControl(value) != null) +} +export const makeControl = (obj: V): Control => { + return { [control]: obj } +} + +export const isPlainObject = (value: unknown): value is { [key: string]: unknown } => { + if (typeof value !== 'object' || value === null) { + return false + } + const proto = Object.getPrototypeOf(value) + return proto === null || proto === Object.prototype +} + +export abstract class ExpressionContext { + abstract of(value: unknown): Expr + abstract doGen>(gen: Generator): Generator + // Returns a plain object, array, or just the toolable fields of an expression if it's an object + abstract sequence(expr: Expr): { [key in string | number]: Expr } | Expr[] | null | undefined + abstract sequencePromiseLike(expr: Expr): PromiseLike | Expr + abstract distribute(entries: { [ key in string | number]: Expr } | Expr[]): Expr + abstract isExpr(value: unknown): value is Expr + abstract call(callee: Expr, args: Expr[]): Expr + + do(gen: Generator): Expr { + const genInner = this.doGen(gen) + let step = genInner.next() + while (!step.done) { + if (isControl(step.value)) { + throw new Error('unexpected control statement in do') + } + step = genInner.next(step.value) + } + return step.value + } + + chain(expr: Expr, then: (value: unknown) => Expr): Expr { + return this.do((function* () { + const value = yield expr + return then(value) + })()) + } +} + +export class IdentityContext extends ExpressionContext { + * doGen>(gen: Generator): Generator { + let step = gen.next() + while (!step.done) { + const next = isControl(step.value) ? yield (step.value as C) : step.value + step = gen.next(next) + } + return step.value + } + + of(value: unknown) { + return value + } + + call(callee: unknown, args: unknown[]): unknown { + return Reflect.apply(callee as (...args: unknown[]) => unknown, undefined, args) + } + + sequence(obj: unknown): { [key in string | number]: unknown } | unknown[] | null | undefined { + if (obj == null || isPlainObject(obj) || Array.isArray(obj)) { + return obj + } + const result: Record = {} + const fields = (obj as any)[toolFieldsSymbol] as Set | undefined + if (fields) { + for (const key of fields) { + result[key] = (obj as any)[key] + } + } + + return result + } + + sequencePromiseLike(expr: unknown) { + return expr + } + + distribute(entries: unknown) { + return entries + } + + isExpr(value: unknown): value is unknown { + return !isControl(value) + } +} diff --git a/src/exoeval/index.ts b/src/exoeval/index.ts new file mode 100644 index 0000000..ed99178 --- /dev/null +++ b/src/exoeval/index.ts @@ -0,0 +1,47 @@ +import type * as acorn from 'acorn' +import type { ExpressionContext } from './expr' +import { parse } from 'acorn' +import { ExoArray, ExoBoolean, ExoDate, ExoJSON, ExoMath, ExoNumber, ExoObject, ExoString } from './builtins' +import { Evaluator } from './evaluator' +import { IdentityContext } from './expr' +import { Scope } from './scope' + +export { asToolFn } from './tool' +export type { ToolFunction } from './tool' + +const builtins: Record = { + Array: ExoArray, + String: ExoString, + Object: ExoObject, + Date: ExoDate, + Boolean: ExoBoolean, + JSON: ExoJSON, + Math: ExoMath, + Number: ExoNumber, +} + +export function exoEval(code: string): unknown +export function exoEval(code: string, ctx: ExpressionContext): T +export function exoEval(code: string, ctx = new IdentityContext()): unknown { + const ast = parse(code, { ecmaVersion: 2022 }) + const rootScope = new Scope(undefined) + const evaluator = new Evaluator(ast, code, ctx, rootScope, { + Array: ExoArray.prototype, + String: ExoString.prototype, + Date: ExoDate.prototype, + }) + + for (const [name, value] of Object.entries(builtins)) { + rootScope.set( + { type: 'Identifier', name, start: 0, end: 0 } as acorn.Identifier, + ctx.of(value), + evaluator, + ) + } + + return evaluator.Program(ast) +} + +export function exoFn unknown>(fn: T): T { + return exoEval(fn.toString()) as T +} diff --git a/src/exoeval/scope.ts b/src/exoeval/scope.ts new file mode 100644 index 0000000..17c8fee --- /dev/null +++ b/src/exoeval/scope.ts @@ -0,0 +1,101 @@ +import type * as acorn from 'acorn' +import type { EvalResult, Evaluator } from './evaluator' +import { isPlainObject } from './expr' + +export class Scope { + private readonly bindings: Map = new Map() + constructor(private readonly parent: Scope | undefined) {} + + get(node: acorn.Identifier, evaluator: Evaluator): T { + const res = this.bindings.get(node.name) + if (res === undefined) { + if (this.bindings.has(node.name)) { + // It's in the map, but is undefined: + return res as T + } + evaluator.inv.parse(this.parent != null, `variable is not defined: ${node.name}`, node) + return this.parent.get(node, evaluator) + } + return res + } + + set(node: acorn.Identifier, value: T, evaluator: Evaluator): void { + evaluator.inv.parse(!this.bindings.has(node.name), `variable is already defined: ${node.name}`, node) + this.bindings.set(node.name, value) + } + + * bind(param: acorn.Pattern, value: T, evaluator: Evaluator): EvalResult { + switch (param.type) { + case 'AssignmentPattern': + yield* this.AssignmentPattern(param, value, evaluator) + break + case 'ArrayPattern': + yield* this.ArrayPattern(param, value, evaluator) + break + case 'ObjectPattern': + yield* this.ObjectPattern(param, value, evaluator) + break + case 'Identifier': + this.set(param, value, evaluator) + break + case 'RestElement': + { const rawValue = yield value + evaluator.inv.eval(Array.isArray(rawValue), 'expected array', param, rawValue) + } + return yield* this.bind(param.argument, value, evaluator) + default: + param.type satisfies 'MemberExpression' + evaluator.inv.parse(false, 'unexpected pattern type', param) + } + return value + } + + * AssignmentPattern(param: acorn.AssignmentPattern, value: T, evaluator: Evaluator): EvalResult { + const rawValue = yield value + const rhs = rawValue === undefined ? (yield* evaluator.Expression(param.right)) : value + yield* this.bind(param.left, rhs, evaluator) + } + + * ArrayPattern(param: acorn.ArrayPattern, value: T, evaluator: Evaluator): EvalResult { + const rawValue = yield value + evaluator.inv.eval(Array.isArray(rawValue), 'expected array', param, rawValue) + const arrayVal: unknown[] = rawValue + for (const [idx, pat] of param.elements.entries()) { + if (pat == null) { + continue + } + if (pat.type === 'RestElement') { + evaluator.inv.parse(idx === param.elements.length - 1, 'rest element must be last', param) + const rest = arrayVal.slice(idx) + yield* this.bind(pat.argument, evaluator.ctx.of(rest), evaluator) + break + } + yield* this.bind(pat, evaluator.ctx.of(arrayVal[idx]), evaluator) + } + } + + * ObjectPattern(param: acorn.ObjectPattern, value: T, evaluator: Evaluator): EvalResult { + const bound: Set = new Set() + const val = evaluator.ctx.sequence(value) + evaluator.inv.eval(val != null, 'cannot destructure null object', param, val) + + for (const [i, property] of param.properties.entries()) { + if (property.type === 'Property') { + const key = property.key.type === 'Identifier' && !property.computed + ? property.key.name + : yield (yield* evaluator.Expression(property.key)) + evaluator.inv.eval(typeof key === 'string' || typeof key === 'number', 'key is not a string or number', property.key, key) + + bound.add(key) + yield* this.bind(property.value, yield* evaluator.getExoProperty(value, key, property.key, false), evaluator) + } + else { + evaluator.inv.parse(property.type === 'RestElement', 'unexpected property type', property) + evaluator.inv.parse(i === param.properties.length - 1, 'rest element must be last', property) + evaluator.inv.eval(isPlainObject(val), 'cannot destructure non-object', property, val) + const rest = Object.fromEntries(Object.entries(val).filter(([key]) => !bound.has(key))) + yield* this.bind(property.argument, evaluator.ctx.distribute(rest), evaluator) + } + } + } +} diff --git a/src/exoeval/tool.test.ts b/src/exoeval/tool.test.ts new file mode 100644 index 0000000..99ca6ac --- /dev/null +++ b/src/exoeval/tool.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { exoEval } from './index' +import { fn, isToolableFunction, tool, toolFieldsSymbol, toolSymbol } from './tool' + +class SampleToolset { + value = 10 + + // Function-valued field that is not a tool + fnField = (x: number) => this.value + x + + // Plain instance method, not a tool + plainAdd(x: number, y: number) { + return x + y + } + + @tool(z.number(), z.number()) + add(x: number, y: number) { + return this.value + x + y + } + + @tool() + getValue() { + return this.value + } +} + +describe('tool decorator with class instances', () => { + it('decorated methods are toolable functions on prototype', () => { + const instance = new SampleToolset() + expect(isToolableFunction(instance.add)).toBe(true) + expect(isToolableFunction(instance.getValue)).toBe(true) + expect(isToolableFunction(instance.plainAdd)).toBe(false) + }) + + it('exoEval can call a tool on an instance argument', () => { + const instance = new SampleToolset() + const fn = exoEval('(c) => c.add(1, 2)') as (c: SampleToolset) => number + expect(fn(instance)).toBe(13) + }) + + it('exoEval cannot call non-tool methods', () => { + const instance = new SampleToolset() + const fn = exoEval('(c) => c.plainAdd(1, 2)') as (c: SampleToolset) => number + expect(() => fn(instance)).toThrow(/callee is not a toolable function/) + }) + + it('accessing a field that isn\'t a tool returns undefined', () => { + const instance = new SampleToolset() + const fn = exoEval('(c) => c.value') as (c: SampleToolset) => number + expect(fn(instance)).toBe(undefined) + }) + + it('method runtime arg validation rejects invalid args (direct and exoEval)', () => { + const instance = new SampleToolset() + expect(() => instance.add(1, 'not a number' as any)).toThrow(/Invalid value/) + const run = exoEval('(c) => c.add(1, "not a number")') as (c: SampleToolset) => number + expect(() => run(instance)).toThrow(/Invalid value/) + expect(instance.add(1, 2)).toBe(13) + expect((exoEval('(c) => c.add(1, 2)') as (c: SampleToolset) => number)(instance)).toBe(13) + }) +}) + +describe('getter decorator', () => { + class WithGetters { + private data = [1, 2, 3] + + @tool() + get length() { return this.data.length } + + @tool(fn.returns(z.any())) + get map() { return this.data.map.bind(this.data) } + + // Non-tool getter + get raw() { return this.data } + } + + it('getter with toolSymbol is accessible via exoEval', () => { + const instance = new WithGetters() + const lengthFn = exoEval('(c) => c.length') as (c: WithGetters) => number + expect(lengthFn(instance)).toBe(3) + }) + + it('getter returning function is wrapped as toolable', () => { + const instance = new WithGetters() + const fn = exoEval('(c) => c.map((x) => x * 10)') as (c: WithGetters) => number[] + expect(fn(instance)).toEqual([10, 20, 30]) + }) + + it('non-tool getter is not accessible', () => { + const instance = new WithGetters() + const fn = exoEval('(c) => c.raw') as (c: WithGetters) => number[] + expect(fn(instance)).toBeUndefined() + }) + + it('getter function has toolSymbol', () => { + const desc = Object.getOwnPropertyDescriptor(WithGetters.prototype, 'length') + expect(desc?.get).toBeDefined() + expect((desc!.get as any)[toolSymbol]).toBe('raw') + }) + + it('non-tool getter does not have toolSymbol', () => { + const desc = Object.getOwnPropertyDescriptor(WithGetters.prototype, 'raw') + expect(desc?.get).toBeDefined() + expect((desc!.get as any)[toolSymbol]).toBeUndefined() + }) + + it('getter-returned function runtime validation rejects invalid callback result (direct and exoEval)', () => { + class WithValidatedMap { + private data = [1, 2, 3] + @tool(fn.returns(z.array(z.number()))) + get map() { return this.data.map.bind(this.data) } + } + const instance = new WithValidatedMap() + expect(() => instance.map(() => 'not a number' as any)).toThrow(/Invalid value/) + const run = exoEval('(c) => c.map(() => "not a number")') as (c: WithValidatedMap) => unknown + expect(() => run(instance)).toThrow(/Invalid value/) + // Valid: callback return value is validated per element; return [x] satisfies z.array(z.number()) + expect(instance.map((x: number) => [x])).toEqual([[1], [2], [3]]) + expect((exoEval('(c) => c.map((x) => [x])') as (c: WithValidatedMap) => unknown)(instance)).toEqual([[1], [2], [3]]) + }) +}) + +describe('class decorator', () => { + @tool(z.any().optional()) + class Constructable { + readonly value: number + constructor(value?: number) { + this.value = value ?? 42 + } + + @tool() + get val() { return this.value } + } + + it('class is marked toolable', () => { + expect((Constructable as any)[toolSymbol]).toBe('constructor') + }) + + it('new expression works in exoEval', () => { + const fn = exoEval('(C) => { const c = new C(10); return c.val }') as (c: typeof Constructable) => number + expect(fn(Constructable)).toBe(10) + }) + + it('new expression with no args', () => { + const fn = exoEval('(C) => { const c = new C(); return c.val }') as (c: typeof Constructable) => number + expect(fn(Constructable)).toBe(42) + }) + + it('non-toolable class cannot be constructed', () => { + class NotToolable {} + expect(() => { + const fn = exoEval('(C) => new C()') as (c: typeof NotToolable) => NotToolable + fn(NotToolable) + }).toThrow(/constructor is not a toolable class/) + }) + + it('class constructor runtime arg validation rejects invalid args (direct and exoEval)', () => { + @tool(z.number()) + class NumOnly { + constructor(public n: number) {} + } + expect(() => new NumOnly('not a number' as any)).toThrow(/Invalid value/) + const run = exoEval('(C) => new C("not a number")') as (c: typeof NumOnly) => NumOnly + expect(() => run(NumOnly)).toThrow(/Invalid value/) + expect(new NumOnly(42).n).toBe(42) + expect((exoEval('(C) => new C(42)') as (C: typeof NumOnly) => NumOnly)(NumOnly).n).toBe(42) + }) +}) + +describe('field decorator', () => { + class WithFields { + @tool() + label = 'hello' + + @tool(z.number()) + compute = (x: number) => x * 2 + + plain = 'not a tool' + } + + it('tool field is in toolFieldsSymbol set', () => { + const instance = new WithFields() + const fields = (instance as any)[toolFieldsSymbol] as Set + expect(fields).toBeInstanceOf(Set) + expect(fields.has('label')).toBe(true) + expect(fields.has('compute')).toBe(true) + expect(fields.has('plain')).toBe(false) + }) + + it('tool field is accessible via exoEval', () => { + const instance = new WithFields() + const fn = exoEval('(c) => c.label') as (c: WithFields) => string + expect(fn(instance)).toBe('hello') + }) + + it('tool function field is callable via exoEval', () => { + const instance = new WithFields() + const fn = exoEval('(c) => c.compute(5)') as (c: WithFields) => number + expect(fn(instance)).toBe(10) + }) + + it('non-tool field is not accessible', () => { + const instance = new WithFields() + const fn = exoEval('(c) => c.plain') as (c: WithFields) => string + expect(fn(instance)).toBeUndefined() + }) + + it('field function runtime arg validation rejects invalid args (direct and exoEval)', () => { + const instance = new WithFields() + expect(() => instance.compute('not a number' as any)).toThrow(/Invalid value/) + const run = exoEval('(c) => c.compute("not a number")') as (c: WithFields) => number + expect(() => run(instance)).toThrow(/Invalid value/) + expect(instance.compute(5)).toBe(10) + expect((exoEval('(c) => c.compute(5)') as (c: WithFields) => number)(instance)).toBe(10) + }) +}) + +describe('static decorator', () => { + class WithStatics { + @tool(z.number(), z.number()) + static add(a: number, b: number) { return a + b } + + @tool() + static get name2() { return 'test' } + } + + it('static method is toolable', () => { + expect(isToolableFunction(WithStatics.add)).toBe(true) + }) + + it('static method callable via exoEval', () => { + const fn = exoEval('(C) => C.add(3, 4)') as (c: typeof WithStatics) => number + expect(fn(WithStatics)).toBe(7) + }) + + it('static getter accessible via exoEval', () => { + const fn = exoEval('(C) => C.name2') as (c: typeof WithStatics) => string + expect(fn(WithStatics)).toBe('test') + }) + + it('static method runtime arg validation rejects invalid args (direct and exoEval)', () => { + expect(() => WithStatics.add(3, 'four' as any)).toThrow(/Invalid value/) + const run = exoEval('(C) => C.add(3, "four")') as (c: typeof WithStatics) => number + expect(() => run(WithStatics)).toThrow(/Invalid value/) + expect(WithStatics.add(3, 4)).toBe(7) + const run2 = exoEval('(C) => C.add(3, 4)') as (c: typeof WithStatics) => number + expect(run2(WithStatics)).toBe(7) + }) +}) + +describe('@tool type checking', () => { + it('rejects schema/method type mismatches', () => { + // These should all produce TypeScript errors. + // If the @ts-expect-error is unnecessary (no error), the test itself fails. + + class _TypeChecks { + // @ts-expect-error — z.string() does not match number parameter + @tool(z.string()) + numMethod(x: number) { return x } + + // @ts-expect-error — too few schemas (expects 2 args, only 1 schema) + @tool(z.number()) + twoArgs(a: number, b: number) { return a + b } + + // @ts-expect-error — wrong schema type for second param + @tool(z.number(), z.boolean()) + stringSecond(a: number, b: string) { return `${a}${b}` } + } + }) +}) diff --git a/src/exoeval/tool.ts b/src/exoeval/tool.ts new file mode 100644 index 0000000..ce8990c --- /dev/null +++ b/src/exoeval/tool.ts @@ -0,0 +1,237 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' + +export const toolSymbol = Symbol('tool') +export const toolFieldsSymbol = Symbol('toolFields') + +export type ToolKind = 'raw' | 'expr' | 'constructor' + +export type ToolFunction unknown = (...args: unknown[]) => unknown> = T & ((...args: unknown[]) => unknown) & { + [toolSymbol]?: ToolKind +} + +export type ToolConstructor unknown = new (...args: unknown[]) => unknown> = T & { + [toolSymbol]?: ToolKind +} + +type SchemaToParam = Schema extends StandardSchemaV1 ? Params : never +type SchemasToParams = { + [K in keyof Schemas]: SchemaToParam +} + +const validate = (schema: Schema, value: unknown, index?: number): unknown => { + const validation = schema['~standard'].validate(value) + if (validation instanceof Promise) { + throw new TypeError(`Validation must be synchronous: ${validation} ${value}`) + } + if (validation.issues) { + const msg = validation.issues.map(e => e.message).join(', ') + throw new TypeError(`Invalid value: ${msg}${index != null ? ` for argument ${index}` : ''}`) + } + return validation.value +} + +export const validateArgs = (methodName: string, inputSchemas: Schemas, args: SchemasToParams): SchemasToParams => { + const ret = [] + if (args.length > inputSchemas.length) { + throw new TypeError(`${methodName}: Too many arguments: ${args.length} > ${inputSchemas.length}`) + } + for (const [index, schema] of inputSchemas.entries()) { + ret.push(validate(schema, args[index], index)) + } + return ret as SchemasToParams +} + +export const registerToolField = (obj: unknown, key: string) => { + if (!Object.getOwnPropertyDescriptor(obj, toolFieldsSymbol)) { + Object.defineProperty(obj, toolFieldsSymbol, { + value: new Set(), + writable: false, + enumerable: false, + configurable: false, + }) + } + (obj as any)[toolFieldsSymbol].add(key) +} + +/** + * @tool decorator - marks a method, getter, field, or class as a tool + * + * Methods: returns a replacement function on the prototype with validation + toolSymbol. + * Getters: returns a replacement getter with toolSymbol on the function. + * If the getter returns a function, it's wrapped with asTool(fn, undefined, schemas). + * Fields: adds field name to toolFieldsSymbol Set on instance. + * If value is a function with schemas, wraps with asTool. + * Classes: returns replacement class with constructor validation + toolSymbol. + */ +export function tool( + ...argSchemas: Schemas +): { + ) => any>( + target: Value, + context: ClassMethodDecoratorContext, + ): Value + ( + target: (this: This) => Value, + context: ClassGetterDecoratorContext, + ): (this: This) => Value + ( + target: undefined, + context: ClassFieldDecoratorContext, + ): (value: Value) => Value + // Class decorator last so method/getter/field decorators match first + any>( + target: Value, + context: ClassDecoratorContext, + ): void +} +export function tool(...argSchemas: Schemas) { + return function ( + target: any, + context: ClassMethodDecoratorContext | ClassGetterDecoratorContext | ClassFieldDecoratorContext | ClassDecoratorContext, + ): any { + const key = String(context.name) + + if (context.kind === 'method') { + return asToolFn(target, argSchemas) + } + + if (context.kind === 'getter') { + // Return replacement getter with toolSymbol on the function + const newGetter = function (this: unknown) { + const result = Reflect.apply(target, this, []) + if (typeof result === 'function') { + return asToolFn(result as (...args: any[]) => unknown, argSchemas) + } + return result + } + return asToolFn(newGetter, []) + } + + if (context.kind === 'field') { + // Register field name in toolFieldsSymbol Set via addInitializer + context.addInitializer(function (this: unknown) { + registerToolField(this, key) + }) + // Return init function that wraps function values + return function (initialValue: unknown) { + if (typeof initialValue === 'function') { + return asToolFn(initialValue as (...args: SchemasToParams) => unknown, argSchemas) + } + return initialValue + } + } + + if (context.kind === 'class') { + return asToolConstructor(target, argSchemas) + } + } +} + +const makeFnSchema = (retSchema: StandardSchemaV1, allowOptional = false): StandardSchemaV1 & { optional: () => StandardSchemaV1 } => { + const schema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'exoeval', + validate: (value: unknown) => { + if (value === undefined && allowOptional) + return { value: undefined } + if (typeof value !== 'function') { + return { issues: [{ message: 'Expected a function' }] } + } + return { + value: (...args: unknown[]) => { + const res = Reflect.apply(value, undefined, args) + if (res != null && typeof res === 'object' && 'then' in res && typeof (res as Promise).then === 'function') { + // For now, just disable returning promises -- we need a special tool for promises + // that we can add later. + throw new TypeError(`${value.name}: Promise not allowed`) + } + return validate(retSchema, res) + }, + } + }, + }, + } + return Object.assign(schema, { + optional() { return makeFnSchema(retSchema, true) }, + }) +} + +export const fn = { + returns(retSchema: StandardSchemaV1) { return makeFnSchema(retSchema) }, +} + +export const expr = () => (target: any, _context: ClassMethodDecoratorContext): any => { + ;(target as any)[toolSymbol] = 'expr' + return target +} + +export function asToolFn) => unknown>(fn: T, schemas: Schemas): T & ToolFunction { + const name = fn.name || 'anonymous' + const { [name]: wrapped } = { + [name](this: unknown, ...args: SchemasToParams) { + const validatedArgs = validateArgs(name, schemas, args) + return Reflect.apply(fn, this, validatedArgs) + }, + } + ;(wrapped as ToolFunction)[toolSymbol] = 'raw' + return wrapped as T & ToolFunction +} + +function asToolConstructor) => object>(Original: T, argSchemas: Schemas): T & ToolConstructor { + const { [Original.name]: Wrapped } = { [Original.name]: class extends (Original as any) { + constructor(...args: any[]) { + const validated = argSchemas.length > 0 ? validateArgs(`new ${Original.name}`, argSchemas, args as SchemasToParams) : args + super(...validated as any) + } + } } + ;(Wrapped as any)[toolSymbol] = 'constructor' + return Wrapped as T & ToolConstructor +} + +export const isToolableFunction = (value: unknown): value is ToolFunction => { + return typeof value === 'function' && (value as ToolFunction)[toolSymbol] === 'raw' +} + +export const isExprFunction = (value: unknown): boolean => { + return typeof value === 'function' && (value as any)[toolSymbol] === 'expr' +} + +export const isToolableConstructor = (value: unknown): value is ToolConstructor => { + return typeof value === 'function' && (value as ToolConstructor)[toolSymbol] === 'constructor' +} + +const getToolUnbound = (thisArg: unknown, obj: unknown, key: string | number): unknown => { + let current: unknown = obj + while (current != null) { + const desc = Object.getOwnPropertyDescriptor(current, key) + if (desc) { + if (desc.get && isToolableFunction(desc.get)) { + return desc.get.call(thisArg) + } + // Toolable function (method on prototype) — bind to obj + if ((isToolableFunction(desc.value) || isExprFunction(desc.value))) { + return desc.value + } + // Tool field (name in toolFieldsSymbol Set) + if ((current as any)[toolFieldsSymbol]?.has(String(key))) { + return 'value' in desc ? desc.value : desc.get?.call(thisArg) + } + + // The field exists but is not visible, so return undefined: + return undefined + } + current = Object.getPrototypeOf(current) + } + return undefined +} + +export const getTool = (thisArg: unknown, obj: unknown, key: string | number): unknown => { + const unbound = getToolUnbound(thisArg, obj, key) + if (typeof unbound === 'function' && toolSymbol in unbound) { + const bound = unbound.bind(thisArg) + ;(bound as any)[toolSymbol] = unbound[toolSymbol] + return bound + } + return unbound +} diff --git a/src/exoeval/utils.ts b/src/exoeval/utils.ts new file mode 100644 index 0000000..a0d9f02 --- /dev/null +++ b/src/exoeval/utils.ts @@ -0,0 +1,56 @@ +import type * as acorn from 'acorn' + +export const disallowedProperties = new Set(['__proto__', 'constructor', 'toJSON', 'then']) + +export const formatCodeMessage = ( + code: string, + position: number, + message: string, +): string => { + // Find the line containing the position + const lines = code.split('\n') + let currentPos = 0 + let lineNumber = 0 + let columnNumber = 0 + + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1 // +1 for newline + if (currentPos + lineLength > position) { + lineNumber = i + columnNumber = position - currentPos + break + } + currentPos += lineLength + } + + const line = lines[lineNumber] ?? '' + const pointer = `${' '.repeat(columnNumber)}^` + + return `${message}\n ${lineNumber + 1} | ${line}\n | ${pointer}` +} + +export class Invariant { + constructor(private readonly code: string) {} + + parse( + condition: boolean, + message: string, + node: acorn.Node, + ): asserts condition { + if (!condition) { + throw new Error(formatCodeMessage(this.code, node.start, `Parse error: ${message}`)) + } + } + + eval( + condition: boolean, + message: string, + node: acorn.Node, + value: unknown, + ): asserts condition { + if (!condition) { + const valueStr = JSON.stringify(value) + throw new Error(formatCodeMessage(this.code, node.start, `Eval error: ${message} (value: ${valueStr})`)) + } + } +} diff --git a/src/index.ts b/src/index.ts index 8942c23..8f771ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,3 @@ -export { createDenoSandbox } from './code-mode-deno.js' -export { CodeMode } from './code-mode.js' -export type { SafeEvalContext, SafeEvalResult } from './code-mode.js' - -// RPC toolset for Cap'n Web integration -export { RpcToolset, tool } from './rpc-toolset.js' -export type { ToolCallback } from './rpc-toolset.js' +export { codemode } from './code-mode' +export { exoEval } from './exoeval' +export { tool } from './exoeval/tool' diff --git a/src/rpc-toolset-test-helpers.ts b/src/rpc-toolset-test-helpers.ts index 4801f9e..1abd46c 100644 --- a/src/rpc-toolset-test-helpers.ts +++ b/src/rpc-toolset-test-helpers.ts @@ -1,9 +1,9 @@ import { z } from 'zod' -import { RpcToolset, tool } from './rpc-toolset' +import { tool } from './exoeval/tool' import { Database } from './sql/builder' import { dummyDialect } from './sql/test-helpers' -export class TestToolset extends RpcToolset { +export class TestToolset { @tool(z.object({ a: z.number(), b: z.number(), @@ -25,7 +25,7 @@ export class TestToolset extends RpcToolset { } } -export class TestToolset2 extends RpcToolset { +export class TestToolset2 { @tool(z.object({ a: z.number(), b: z.number(), diff --git a/src/rpc-toolset.test.ts b/src/rpc-toolset.test.ts deleted file mode 100644 index afe6bd7..0000000 --- a/src/rpc-toolset.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { z } from 'zod' -import { RpcToolset, tool } from './rpc-toolset' -import { TestToolset, User } from './rpc-toolset-test-helpers' -import { isFromItem } from './sql/builder' -import { compiledQuery } from './sql/test-helpers' - -describe('tool decorator', () => { - it('validates input when schema is provided', () => { - class TestToolset extends RpcToolset { - @tool(z.object({ - name: z.string(), - age: z.number(), - })) - greet(input: { name: string, age: number }) { - return `Hello, ${input.name}! You are ${input.age} years old.` - } - } - - const toolset = new TestToolset() - expect(toolset.greet({ name: 'Alice', age: 30 })).toBe('Hello, Alice! You are 30 years old.') - }) - - it('throws error when input does not match schema', () => { - class TestToolset extends RpcToolset { - @tool(z.object({ - name: z.string(), - age: z.number(), - })) - greet(input: { name: string, age: number }) { - return `Hello, ${input.name}!` - } - } - - const toolset = new TestToolset() - expect(() => toolset.greet({ name: 'Alice', age: '30' as any })).toThrow('Invalid value') - }) - - it('allows no arguments when no schema is provided', () => { - class TestToolset extends RpcToolset { - @tool() - getCount() { - return 42 - } - } - - const toolset = new TestToolset() - expect(toolset.getCount()).toBe(42) - }) - - it('throws error when argument provided but no schema', () => { - class TestToolset extends RpcToolset { - @tool() - getCount() { - return 42 - } - } - - const toolset = new TestToolset() - expect(() => (toolset as any).getCount({ some: 'arg' })).toThrow('Tool getCount got too many arguments: 1 provided (expected 0)') - }) - - it('throws error when multiple arguments provided', () => { - class TestToolset extends RpcToolset { - @tool(z.object({ - name: z.string(), - })) - greet(input: { name: string }) { - return `Hello, ${input.name}!` - } - } - - const toolset = new TestToolset() - expect(() => (toolset as any).greet({ name: 'Alice' }, { extra: 'arg' })).toThrow('Tool greet got too many arguments: 2 provided (expected 1)') - }) - - it('correctly types the method argument based on schema', () => { - class Calculator extends RpcToolset { - @tool(z.object({ - x: z.number(), - y: z.number(), - })) - add(input: { x: number, y: number }) { - return input.x + input.y - } - } - - const calc = new Calculator() - const result = calc.add({ x: 5, y: 3 }) - expect(result).toBe(8) - // TypeScript should infer the correct type for input - expect(typeof result).toBe('number') - }) - - it('works with async methods', async () => { - class AsyncToolset extends RpcToolset { - @tool(z.object({ - delay: z.number(), - })) - async wait(input: { delay: number }) { - await new Promise(resolve => setTimeout(resolve, input.delay)) - return `Waited ${input.delay}ms` - } - } - - const toolset = new AsyncToolset() - const result = await toolset.wait({ delay: 10 }) - expect(result).toBe('Waited 10ms') - }) - - it('works with optional schema fields', () => { - class TestToolset extends RpcToolset { - @tool(z.object({ - required: z.string(), - optional: z.string().optional(), - })) - process(input: { required: string, optional?: string }) { - return input.optional ?? 'default' - } - } - - const toolset = new TestToolset() - expect(toolset.process({ required: 'test' })).toBe('default') - expect(toolset.process({ required: 'test', optional: 'value' })).toBe('value') - }) - - it('should cause TypeScript error when schema type does not match method argument signature', () => { - class TestToolset extends RpcToolset { - // @ts-expect-error - age is specified as a number but we're passing a string - @tool(z.object({ - name: z.string(), - age: z.number(), - })) - greet(input: { name: string, age: string }) { - return `Hello, ${input.name}!` - } - } - - // This test verifies that TypeScript will error if types don't match - // Note: Type checking happens at compile time, so this test mainly verifies runtime behavior - const toolset = new TestToolset() - // @ts-expect-error - age should be number but we're passing 30 (number) to a method expecting string - expect(toolset.greet({ name: 'Alice', age: 30 })).toBe('Hello, Alice!') - }) - - it('throws error when toolset method is not a tool', () => { - class TestToolset extends RpcToolset { - notATool() { - return 'not a tool' - } - } - - expect(() => new TestToolset()).toThrow('Prototype method `notATool` is not a tool. Did you forget to use the @tool decorator?') - }) -}) - -describe('sql integration with RPC toolset', () => { - it('can call table method decorated with @tool() and use returned table in join', () => { - const user = new User() - const postsTable = user.posts() - - expect(isFromItem(postsTable)).toBe(true) - expect(postsTable.tableName).toBe('posts') - - // Verify the table can be used in a join - const query = User.from() - .join(({ user }) => user.posts()) - .select(({ user, post }) => ({ userName: user.name, postTitle: post.title })) - - expect(compiledQuery(query.compile())).toEqual({ - sql: 'SELECT "user"."name" as "userName", "post"."title" as "postTitle" FROM "users" AS "user" JOIN "posts" AS "post" ON "post"."user_id" = "user"."id"', - parameters: [], - }) - }) - - it('can return table with on expression from toolset method and use in query', async () => { - const toolset = new TestToolset() - const userTable = await toolset.userForId({ id: '123' }) - - expect(isFromItem(userTable)).toBe(true) - expect(userTable.tableName).toBe('users') - - // Verify the table can be used in a query with the on expression applied as a where clause - const query = userTable.from() - .select(({ user }) => ({ id: user.id, name: user.name })) - - expect(compiledQuery(query.compile())).toEqual({ - sql: 'SELECT "user"."id" as "id", "user"."name" as "name" FROM "users" AS "user" WHERE "user"."id" = $1', - parameters: ['123'], - }) - }) -}) diff --git a/src/rpc-toolset.ts b/src/rpc-toolset.ts deleted file mode 100644 index b25788b..0000000 --- a/src/rpc-toolset.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { StandardSchemaV1 } from '@standard-schema/spec' -import { inspect } from 'node:util' -import { RpcTarget } from 'capnweb' - -const validate = (schema: StandardSchemaV1 | ((arg: unknown) => boolean), value: unknown): void => { - if ('~standard' in schema) { - const validation = schema['~standard'].validate(value) - if (validation instanceof Promise) { - throw new TypeError(`Validation must be synchronous`) - } - if (validation.issues) { - throw new Error(`Invalid value: ${validation.issues.map(e => e.message).join(', ')}`) - } - } - else { - if (!schema(value)) { - throw new Error(`Invalid value: ${inspect(value)} (expected ${schema.name || schema.toString()})`) - } - } -} - -const toolMetadataKey = Symbol('toolMetadata') -type ToolMetadata = { - [toolMetadataKey]?: { - runtimeValidationEnabled?: boolean - } -} -export const setToolMetadata = (target: (...args: any[]) => unknown, metadata: ToolMetadata[typeof toolMetadataKey]) => { - const meta = target as unknown as ToolMetadata - meta[toolMetadataKey] = { - ...meta[toolMetadataKey], - ...metadata, - } -} - -// `@tool` is a decorator that annotates the input of a method -// it is used for validation and typing -function toolDef(): ( - target: (this: This) => Return, - context: ClassMethodDecoratorContext Return>, -) => (this: This) => Return -function toolDef(inputSchema: StandardSchemaV1): ( - target: (this: This, arg: TInput) => Return, - context: ClassMethodDecoratorContext Return>, -) => (this: This, arg: TInput) => Return -function toolDef(inputSchema?: StandardSchemaV1) { - return ( - target: ((this: This) => Return) | ((this: This, arg: TInput) => Return), - context: ClassMethodDecoratorContext Return>, - ): any => { - if (context.kind !== 'method') { - throw new Error(`Tool decorator can only be used on methods`) - } - - const methodName = String(context.name) - - const replacementMethod: (this: This, arg: TInput) => Return = function (this: This, ...args: [TInput, ...unknown[]]): Return { - const expectedArgs = inputSchema ? 1 : 0 - if (args.length > expectedArgs) { - throw new Error(`Tool ${methodName} got too many arguments: ${args.length} provided (expected ${expectedArgs})`) - } - if (inputSchema) { - const arg = args[0] - validate(inputSchema, arg) - return (target as (this: This, arg: TInput) => Return).call(this, arg) - } - else { - return (target as (this: This) => Return).call(this) - } - } - - setToolMetadata(replacementMethod, { runtimeValidationEnabled: true }) - - return replacementMethod - } -} - -function toolUnsafeNoValidation() { - return ( - target: (this: This, ...args: any[]) => Return, - context: ClassMethodDecoratorContext Return>, - ): any => { - if (context.kind !== 'method') { - throw new Error(`Tool decorator can only be used on methods`) - } - - setToolMetadata(target, { runtimeValidationEnabled: true }) - - return target - } -} - -const callbackMetadataKey = Symbol('callbackMetadata') -export type ToolCallback unknown> = T | { [callbackMetadataKey]: { - callback: T -} } - -type Fn = ToolCallback<(arg: any) => any> - -function callbackTool( -): ( - target: (this: This, arg: Fn) => Return, - context: ClassMethodDecoratorContext Return>, -) => (this: This, arg: Fn) => Return { - return ( - target: (this: This, arg: Fn) => Return, - context: ClassMethodDecoratorContext Return>, - ): any => { - if (context.kind !== 'method') { - throw new Error(`Tool decorator can only be used on methods`) - } - - const methodName = String(context.name) - - const replacementMethod: (this: This, arg: Fn) => Return = function (this: This, ...args: [Fn, ...unknown[]]): Return { - if (args.length !== 1) { - throw new Error(`Tool ${methodName} got too many arguments: ${args.length} provided (expected 1)`) - } - const callback = args[0] - if (typeof callback !== 'function') { - throw new TypeError(`Callback for tool ${methodName} must be a function`) - } - const callbackWrapped: Fn = (() => { - throw new Error(`Callback for tool ${methodName} must be wrapped with \`tool.unwrapCallback\``) - }) as unknown as Fn - (callbackWrapped as any)[callbackMetadataKey] = { - callback, - } - return target.call(this, callbackWrapped as unknown as Fn) - } - - setToolMetadata(replacementMethod, { runtimeValidationEnabled: true }) - - return replacementMethod - } -} - -// Helper function to consume a callback for a tool.callback tool. The main idea is that: -// 1. If running locally, we don't do anything fancy. -// 2. If invoked over RPC, `callback` will actually return a promise that will be resolved -// and delivered by the RPC layer. We need to chain actions to the actual result: -const unwrapCallback = (toolCallback: ToolCallback<(arg: A) => V>, returnSchema: StandardSchemaV1 | ((arg: unknown) => arg is V)) => - (arg: A, then: (result: V) => R, opts?: { catch?: (error: unknown) => R, finally?: () => void }): R => { - const callback = callbackMetadataKey in toolCallback ? toolCallback[callbackMetadataKey].callback : toolCallback - let isPromise = false - - try { - const result = callback(arg) - if (result instanceof Promise) { - isPromise = true - let ret: Promise - ret = result.then(async (r) => { - validate(returnSchema, r) - return then(r) - }, opts?.catch) - if (opts?.finally) { - ret = ret.finally(opts.finally) - } - return ret as unknown as R - } - - validate(returnSchema, result) - return then(result) - } - catch (error) { - if (opts?.catch) { - return opts.catch(error) - } - throw error - } - finally { - if (!isPromise) { - opts?.finally?.() - } - } - } - -export type ToolAnnotation = typeof toolDef & { - callback: typeof callbackTool - unwrap: typeof unwrapCallback - unsafeNoValidation: typeof toolUnsafeNoValidation -} - -export const tool: ToolAnnotation = toolDef as ToolAnnotation -tool.callback = callbackTool -tool.unwrap = unwrapCallback -tool.unsafeNoValidation = toolUnsafeNoValidation - -// Special kind of `RpcTarget` that ensures all methods are tools (have the @tool decorator). -// This ensures that: -// 1. Only methods with the @tool decorator are exposed to the RPC layer. -// 2. All methods have input validation. -export class RpcToolset extends RpcTarget { - constructor() { - super() - let prototype = Object.getPrototypeOf(this) - while (prototype !== null && prototype !== RpcToolset.prototype) { - for (const key of Object.getOwnPropertyNames(prototype)) { - if (key === 'constructor') { - continue - } - const descriptor = Object.getOwnPropertyDescriptor(prototype, key) - const fn = descriptor?.value ?? descriptor?.get - if (!fn || typeof fn !== 'function') { - throw new Error(`RpcToolset prototype must be methods or getters: ${key} is not`) - } - const meta = fn as unknown as ToolMetadata - if (!meta[toolMetadataKey]?.runtimeValidationEnabled) { - throw new Error(`Prototype method \`${key}\` is not a tool. Did you forget to use the @tool decorator?`) - } - } - prototype = Object.getPrototypeOf(prototype) - } - } -} diff --git a/src/sql/builder.test.ts b/src/sql/builder.test.ts index 6c3f473..f33f2af 100644 --- a/src/sql/builder.test.ts +++ b/src/sql/builder.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { tool } from '../rpc-toolset' +import { tool } from '../exoeval/tool' import { Database } from './builder' import { LiteralExpression } from './expression' import { compiledQuery, dummyDialect } from './test-helpers' diff --git a/src/sql/builder.ts b/src/sql/builder.ts index b03b9ae..b5def8f 100644 --- a/src/sql/builder.ts +++ b/src/sql/builder.ts @@ -1,11 +1,10 @@ import type { CompiledQuery, Dialect } from 'kysely' -import type { ToolCallback } from '../rpc-toolset' import type { SqlExpressionIn } from './expression' import type { RawSql } from './sql' import { Kysely } from 'kysely' import invariant from 'tiny-invariant' import z from 'zod' -import { RpcToolset, setToolMetadata, tool } from '../rpc-toolset' +import { fn, registerToolField, tool } from '../exoeval/tool' import { asSqlExpression, ColumnReferenceExpression, isSqlExpressionIn, OrderByValue, SqlExpression, UnboundColumnReferenceExpression } from './expression' import { buildSql, sql } from './sql' @@ -113,7 +112,7 @@ const combinePredicates = (...predicates: (SqlExpression | undefined)[]): SqlExp type TableNamespace = { [key: string]: RowLike } -type OrderByItem = NamespacedExpression +type OrderByItem = SqlExpression | SqlExpression[] | OrderByValue | OrderByValue[] | (SqlExpression | OrderByValue)[] type Tables = { [k in keyof TN & string]: { fromItem: FromItem @@ -125,6 +124,13 @@ type Tables = { } } +const isOrderByItem = (value: unknown): value is OrderByItem => { + if (Array.isArray(value)) { + return value.every(e => e instanceof SqlExpression || e instanceof OrderByValue) + } + return value instanceof SqlExpression || value instanceof OrderByValue +} + const namespacedArg = (tables: Tables): TN => { return Object.fromEntries(Object.entries(tables).map(([k, v]) => [k, v.fromItem.toRowLike()])) as unknown as TN } @@ -141,7 +147,7 @@ type QueryBuilderParams | undefined // set only when created directly from a TableClass } -class QueryBuilder extends RpcToolset implements FromItem { +class QueryBuilder implements FromItem { #db: Database public readonly alias: N private selectRowLike: S @@ -154,7 +160,6 @@ class QueryBuilder) { - super() this.#db = params.db this.alias = params.alias this.selectRowLike = params.selectRowLike @@ -167,39 +172,32 @@ class QueryBuilder(select: ToolCallback<(arg: TN) => S2>) { - const selectUnwrapped = tool.unwrap(select, isRowLikeIn as (arg: unknown) => arg is S2) - return selectUnwrapped(this.arg, (result) => { - return new QueryBuilder>({ ...this.paramsForCopy(), selectRowLike: asRowLike(result) }) - }) + // @ts-expect-error — tool() overload resolution fails with generic method signature + @tool(fn.returns(z.any())) + select(select: (arg: TN) => S2) { + const result = select(this.arg) + invariant(isRowLikeIn(result), 'select must return a RowLikeIn') + return new QueryBuilder>({ ...this.paramsForCopy(), selectRowLike: asRowLike(result) }) } - @tool.callback() - where(where: ToolCallback<(arg: TN) => SqlExpressionIn>) { - const whereUnwrapped = tool.unwrap(where, isSqlExpressionIn) - return whereUnwrapped(this.arg, (result) => { - return new QueryBuilder({ ...this.paramsForCopy(), whereExpression: combinePredicates(this.whereExpression, asSqlExpression(result)) }) - }) + // @ts-expect-error — tool() overload resolution fails with generic method signature + @tool(fn.returns(z.custom(isSqlExpressionIn))) + where(where: (arg: TN) => SqlExpressionIn) { + const result = where(this.arg) + return new QueryBuilder({ ...this.paramsForCopy(), whereExpression: combinePredicates(this.whereExpression, asSqlExpression(result)) }) } - @tool.callback() - orderBy(orderBy: ToolCallback>) { - const orderByUnwrapped = tool.unwrap(orderBy, (arg): arg is ReturnType> => { - if (Array.isArray(arg)) { - return arg.every(e => e instanceof SqlExpression || e instanceof OrderByValue) - } - return arg instanceof SqlExpression || arg instanceof OrderByValue - }) - return orderByUnwrapped(this.arg, (raw) => { - const rawArray = Array.isArray(raw) ? raw : [raw] - const exprs = rawArray.map((e) => { - invariant(e instanceof SqlExpression || e instanceof OrderByValue, 'orderBy must return a SqlExpression/OrderByValue or an array of SqlExpressions/OrderByValues') - return e instanceof SqlExpression ? new OrderByValue(e) : e - }) - - return new QueryBuilder({ ...this.paramsForCopy(), orderByExpressions: (this.orderByExpressions ?? []).concat(exprs) }) + // @ts-expect-error — tool() overload resolution fails with generic method signature + @tool(fn.returns(z.custom(isOrderByItem))) + orderBy(orderBy: (arg: TN) => OrderByItem) { + const raw = orderBy(this.arg) + const rawArray = Array.isArray(raw) ? raw : [raw] + const exprs = rawArray.map((e) => { + invariant(e instanceof SqlExpression || e instanceof OrderByValue, 'orderBy must return a SqlExpression/OrderByValue or an array of SqlExpressions/OrderByValues') + return e instanceof SqlExpression ? new OrderByValue(e) : e }) + + return new QueryBuilder({ ...this.paramsForCopy(), orderByExpressions: (this.orderByExpressions ?? []).concat(exprs) }) } // Note that `limit` "attenuates": the new limit is the minimum of the new limit and the existing limit @@ -222,50 +220,47 @@ class QueryBuilder(fromItem: FromItem | NamespacedExpression>, on?: NamespacedExpression): QueryBuilder - @tool.unsafeNoValidation() + @tool(z.any(), z.any()) join(fromItem: FromItem | NamespacedExpression>, on?: NamespacedExpression) { const fromItemCallbackRaw = isFromItem(fromItem) ? () => fromItem : fromItem as NamespacedExpression> - const fromItemCallback = tool.unwrap(fromItemCallbackRaw, isFromItem as (arg: unknown) => arg is FromItem) - const res = fromItemCallback(this.arg, (fromItemResolved) => { - if (fromItemResolved instanceof QueryBuilder && fromItemResolved.rawTable) { - // If we're joining to a raw table, use it because it might have an `onExpression` - // (and its more efficient to use the raw table than to re-SELECT from it) - fromItemResolved = fromItemResolved.rawTable as unknown as FromItem - } - const alias = fromItemResolved.alias - - if (this.tables[alias]) { - throw new Error(`Join already exists: ${alias} in ${Object.keys(this.tables)}`) - } + let fromItemResolved: FromItem = fromItemCallbackRaw(this.arg) + invariant(isFromItem(fromItemResolved), 'fromItem must return a FromItem') + if (fromItemResolved instanceof QueryBuilder && fromItemResolved.rawTable) { + // If we're joining to a raw table, use it because it might have an `onExpression` + // (and its more efficient to use the raw table than to re-SELECT from it) + fromItemResolved = fromItemResolved.rawTable as unknown as FromItem + } + const alias = fromItemResolved.alias + invariant(typeof alias === 'string' && isSafeAlias(alias), 'alias must be a safe alias') - const arg = { - ...this.arg, - [alias]: fromItemResolved.toRowLike(), - } + if (this.tables[alias]) { + throw new Error(`Join already exists: ${alias} in ${Object.keys(this.tables)}`) + } - const onCallback = tool.unwrap(on ?? (() => undefined), (raw: unknown): raw is SqlExpressionIn | undefined => raw === undefined || isSqlExpressionIn(raw)) - return onCallback(arg, (onRaw) => { - const onResolved = combinePredicates(onRaw !== undefined ? asSqlExpression(onRaw) : undefined, fromItemResolved.onExpression) - invariant(onResolved != null, 'Must specify an `on` expression or use `Table.on` to set the on expression') - - invariant(onResolved instanceof SqlExpression, 'on must return a SqlExpression') - const tablesWithAlias: Tables = { - ...this.tables, - [alias]: { - fromItem: fromItemResolved, - on: onResolved, - joinType: 'inner', - // If `fromItem` is a function that returns a QueryBuilder, it is implicitly a lateral join (depends on the other tables) - isLateral: !isFromItem(fromItem) && fromItemResolved instanceof QueryBuilder, - }, - } as Tables - return new QueryBuilder({ ...this.paramsForCopy(), tables: tablesWithAlias }) - }) - }) + const arg = { + ...this.arg, + [alias]: fromItemResolved.toRowLike(), + } - return res + const onRaw = on?.(arg) + invariant(onRaw === undefined || isSqlExpressionIn(onRaw), 'on must return a SqlExpressionIn') + const onResolved = combinePredicates(onRaw !== undefined ? asSqlExpression(onRaw) : undefined, (fromItemResolved as FromItem).onExpression) + invariant(onResolved != null, 'Must specify an `on` expression or use `Table.on` to set the on expression') + + invariant(onResolved instanceof SqlExpression, 'on must return a SqlExpression') + const tablesWithAlias: Tables = { + ...this.tables, + [alias]: { + fromItem: fromItemResolved, + on: onResolved, + joinType: 'inner', + // If `fromItem` is a function that returns a QueryBuilder, it is implicitly a lateral join (depends on the other tables) + isLateral: !isFromItem(fromItem) && fromItemResolved instanceof QueryBuilder, + }, + } as Tables + return new QueryBuilder({ ...this.paramsForCopy(), tables: tablesWithAlias }) } @tool() @@ -334,7 +329,7 @@ class QueryBuilder ` to ensure the method is a direct property of the class instance, // not a method of the class prototype @@ -395,26 +390,11 @@ const table = (db: Database, name: N): TableClass => { static toRowLike = function >(this: T, opts?: { remapColumns?: boolean }): InstanceType { const rowLike = new this(opts) as unknown as InstanceType - // Convert any unbound column references to getters that are bound to the current - // table alias. This serves two purposes: - // 1. It allows the columns to be lazily bound to the both the subquery alias and column alias when the row is created. - // 2. By binding to prototype instead of `this`, it tells Cap'n Web columns can be traversed over RPC. for (const key of Object.keys(rowLike)) { - const value = rowLike[key as keyof typeof rowLike] + const value = (rowLike as Record)[key] if (value instanceof UnboundColumnReferenceExpression) { - const proto = Object.getPrototypeOf(rowLike) - const getter = function (this: InstanceType>) { - const cls = this.constructor as TableClass - // remapColumns means we've rebound the column to the subquery alias - return new ColumnReferenceExpression(cls.alias ?? cls.tableName, this.opts?.remapColumns ? key : value.column) - } - setToolMetadata(getter, { runtimeValidationEnabled: true }) - Object.defineProperty(proto, key, { - get: getter, - enumerable: true, - configurable: true, - }) - delete rowLike[key as keyof typeof rowLike] + (rowLike as Record)[key] = new ColumnReferenceExpression(this.alias ?? this.tableName, opts?.remapColumns ? key : value.column) + registerToolField(rowLike, key) } } diff --git a/src/sql/expression.ts b/src/sql/expression.ts index 85a67d1..a654286 100644 --- a/src/sql/expression.ts +++ b/src/sql/expression.ts @@ -1,6 +1,6 @@ import type { RawSql } from './sql' import z from 'zod' -import { RpcToolset, tool } from '../rpc-toolset' +import { tool } from '../exoeval/tool' import { buildSql, sql } from './sql' type LiteralValue = number | string | boolean | null @@ -16,10 +16,8 @@ const zSqlExpression = z.custom((val): val is SqlExpression => va const zNumericSqlExpression = z.union([zSqlExpression, z.number(), z.string()]) const zSqlExpressionIn = z.union([zSqlExpression, z.number(), z.string(), z.boolean(), z.null()]) -export class SqlExpression extends RpcToolset { - constructor(public precedence: number = 100) { - super() - } +export class SqlExpression { + constructor(public precedence: number = 100) { } // `= () => ` to ensure the method is a direct property of the class instance, // not a method of the class prototype diff --git a/src/sql/integration.test.ts b/src/sql/integration.test.ts index 19280c2..3b7b907 100644 --- a/src/sql/integration.test.ts +++ b/src/sql/integration.test.ts @@ -1,9 +1,7 @@ -import { setGlobalRpcSessionOptions } from 'capnweb' import { describe, expect, it } from 'vitest' -import { TestHarness } from '../capnweb-test-helpers' -import { CodeMode } from '../code-mode' -import { createDenoSandbox } from '../code-mode-deno' -import { RpcToolset, tool } from '../rpc-toolset' +import { codemode } from '../code-mode' +import { exoFn } from '../exoeval' +import { tool } from '../exoeval/tool' import { Database } from './builder' import { sql } from './sql' import { pgliteDialect } from './test-helpers' @@ -28,7 +26,7 @@ class Post extends db.Table('posts').as('post') { content = this.column('content') } -class Api extends RpcToolset { +class Api { @tool() users() { return User.from() @@ -53,71 +51,43 @@ db.execute(sql`CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER, titl db.execute(sql`INSERT INTO posts (id, user_id, title, content) VALUES (1, 1, 'Hello, world!', 'This is a test post')`) db.execute(sql`INSERT INTO posts (id, user_id, title, content) VALUES (2, 2, 'Hello, world!', 'This is a test post')`) -setGlobalRpcSessionOptions(() => ({ recordReplayMode: 'all' })) - -describe('sql integration over capnweb', () => { +describe('sql integration over exoeval', () => { it('executes a basic query over RPC', async () => { - await using harness = new TestHarness(new Api()) - const api = harness.stub - - using users = api.users() - using query = users.select(({ user }) => ({ - id: user.id, - name: user.name, - })) + const fn = exoFn(async (api: Api) => { + const users = api.users() + const query = users.select(({ user }) => ({ + id: user.id, + name: user.name, + })) - const { results } = await query.execute() + return await query.execute() + }) + const { results } = await fn(new Api()) expect(results).toEqual([{ id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Doe' }]) }) it('executes a join query over RPC', async () => { - await using harness = new TestHarness(new Api()) - const api = harness.stub - - using users = api.users() - using posts = api.posts() - // Unfortuneately when wrapping with stubs, some generics get lost so we need - // manual type assertions to get the right types. - // @ts-expect-error - there's also random TS issues here:L - using join = users.join(posts, ({ user, post }) => user.id['=']((post as Post).userId)) - using query = join.select(({ user, post }) => ({ - id: user.id, - name: user.name, - title: (post as Post).title, - })) - - const { results } = await query.execute() - - expect(results).toEqual([{ id: 1, name: 'John Doe', title: 'Hello, world!' }, { id: 2, name: 'Jane Doe', title: 'Hello, world!' }]) - }) - - it('executes a query in a `map` (no `usings` needed)', async () => { - await using harness = new TestHarness(new Api()) - const api = harness.stub - - const { results } = await api.map(api => - api.users().select(({ user }) => ({ + const fn = exoFn(async (api: Api) => { + const users = api.users() + const posts = api.posts() + const join = users.join(posts, ({ user, post }) => user.id['=']((post as Post).userId)) + const query = join.select(({ user, post }) => ({ id: user.id, name: user.name, - })).execute(), - ) - expect(results).toEqual([{ id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Doe' }]) - }) - - it('executes a nested closures in a `map` (no `usings` needed)', async () => { - // const localResult = await new Api().users().join(({ user }) => new Api().posts().select(({ user }) => ({ - // id: user.id, - // name: user.name, - // })).execute(), ({ user, post }) => user.id['='](post.userId)) + title: (post as Post).title, + })) - // expect(localResult).toEqual([{ id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Doe' }]) + return await query.execute() + }) - await using harness = new TestHarness(new Api()) - const api = harness.stub + const { results } = await fn(new Api()) + expect(results).toEqual([{ id: 1, name: 'John Doe', title: 'Hello, world!' }, { id: 2, name: 'Jane Doe', title: 'Hello, world!' }]) + }) - const { results } = await api.map(api => - api.users() + it('executes a nested closures', async () => { + const fn = exoFn(async (api: Api) => { + return await api.users() // TODO: for some reaons we need the explicit `{ user: User }` so that the right // type is inferred later. .join(({ user }: { user: User }) => api.posts().select(({ post }) => ({ @@ -134,52 +104,42 @@ describe('sql integration over capnweb', () => { postId: post.postId, postTitle: post.postTitle, })) - .execute(), - ) + .execute() + }) + + const { results } = await fn(new Api()) expect(results).toEqual([{ userName: 'John Doe', postUserName: 'John Doe', userId: 1, postId: 1, postTitle: 'Hello, world!' }, { userName: 'Jane Doe', postUserName: 'Jane Doe', userId: 2, postId: 2, postTitle: 'Hello, world!' }]) }) - it('cannot reference own properties', async () => { - await using harness = new TestHarness(new Api()) - const api = harness.stub - - await expect(async () => { - using users = api.users() - return await users.compile() - }).rejects.toThrow('Attempted to access property \'compile\', which is an instance property of the RpcTarget.') - - await expect(async () => { - using users = api.users() - return await users.select( - ({ user }) => ({ foo: user.column('foo') }), - ) - }).rejects.toThrow('Attempted to access property \'column\', which is an instance property of the RpcTarget.') - - // TODO: this is actually being allowed, but it shouldn't be: - await expect(async () => { - using posts = api.posts() - return await posts.compile() - }).rejects.toThrow('Attempted to access property \'compile\', which is an instance property of the RpcTarget.') + it('cannot reference unexposed properties', async () => { + const api = new Api() + expect(exoFn((api: Api) => api.users().compile)(api)).toBeUndefined() + expect(() => (exoFn((api: Api) => api.users().compile())(api))).toThrow(/callee is not a toolable function \(value: undefined\)/) + + // Just for sanity, a different field should be accessible: + expect(exoFn((api: Api) => api.users().limit)(api)).toBeDefined() + + const fn2 = exoFn(async (api: Api) => { + const users = api.users() + return users.select(({ user }) => ({ foo: user.column('foo') })) + }) + await expect(fn2(new Api())).rejects.toThrow(/callee is not a toolable function \(value: undefined\)/) }) it('can do a join to a toolset method', async () => { - await using harness = new TestHarness(new Api()) - const api = harness.stub + const fn = exoFn(async ({ users }: Api) => { + return await users().join(({ user }) => user.posts()).select(({ user, post }) => ({ userName: user.name, postTitle: post.title })).execute() + }) - const { results } = await api.map(api => - api.users().join(({ user }) => user.posts()).select(({ user, post }) => ({ userName: user.name, postTitle: post.title })).execute(), - ) + const { results } = await fn(new Api()) expect(results).toEqual([{ userName: 'John Doe', postTitle: 'Hello, world!' }, { userName: 'Jane Doe', postTitle: 'Hello, world!' }]) }) }) -describe('sql integration with deno sandbox', () => { - it('executes a basic select via CodeMode', async () => { - const codeMode = new CodeMode(createDenoSandbox()) - const codeTool = await codeMode.wrap({ - users: () => User.from(), - }, `class User extends db.Table('users').as('user') { +describe('sql integration with codemode', () => { + it('executes a basic select via codemode', async () => { + const codeTool = await codemode(new Api(), `class User extends db.Table('users').as('user') { id = this.column('id') name = this.column('name') email = this.column('email') @@ -194,13 +154,10 @@ describe('sql integration with deno sandbox', () => { }, { toolCallId: 'test-1', messages: [] }) expect(result).toEqual({ results: [{ id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Doe' }] }) - }, 10000) + }) - it('executes a join via CodeMode', async () => { - const codeMode = new CodeMode(createDenoSandbox()) - const codeTool = await codeMode.wrap({ - users: () => User.from(), - }, `class User extends db.Table('users').as('user') { + it('executes a join via codemode', async () => { + const codeTool = await codemode({ users: User.from() }, `class User extends db.Table('users').as('user') { id = this.column('id') name = this.column('name') email = this.column('email') @@ -220,7 +177,7 @@ class Post extends db.Table('posts').as('post') { const result = await codeTool.execute({ code: `async ({ users }) => { - return await users() + return await users .join(({ user }) => user.posts()) .select(({ user, post }) => ({ userName: user.name, postTitle: post.title })) .execute() @@ -228,5 +185,5 @@ class Post extends db.Table('posts').as('post') { }, { toolCallId: 'test-2', messages: [] }) expect(result).toEqual({ results: [{ userName: 'John Doe', postTitle: 'Hello, world!' }, { userName: 'Jane Doe', postTitle: 'Hello, world!' }] }) - }, 10000) + }) }) diff --git a/src/stream-transport.test.ts b/src/stream-transport.test.ts deleted file mode 100644 index 90de20c..0000000 --- a/src/stream-transport.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { RpcSession, RpcTarget } from 'capnweb' -import { describe, expect, it } from 'vitest' -import { StreamTransport } from './stream-transport.js' - -function createPairedStreams() { - const aToB: Uint8Array[] = [] - const bToA: Uint8Array[] = [] - let aController: ReadableStreamDefaultController | null = null - let bController: ReadableStreamDefaultController | null = null - - const aReadable = new ReadableStream({ - start(controller) { - aController = controller - for (const chunk of bToA) controller.enqueue(chunk) - }, - }) - const aWritable = new WritableStream({ - write(chunk) { - aToB.push(chunk) - if (bController) - bController.enqueue(chunk) - }, - }) - - const bReadable = new ReadableStream({ - start(controller) { - bController = controller - for (const chunk of aToB) controller.enqueue(chunk) - }, - }) - const bWritable = new WritableStream({ - write(chunk) { - bToA.push(chunk) - if (aController) - aController.enqueue(chunk) - }, - }) - - return { transportA: new StreamTransport(aReadable, aWritable), transportB: new StreamTransport(bReadable, bWritable) } -} - -function createReadable(chunks: Uint8Array[]) { - return new ReadableStream({ - start(controller) { - for (const chunk of chunks) controller.enqueue(chunk) - controller.close() - }, - }) -} - -function collectWritable() { - const chunks: Uint8Array[] = [] - const writable = new WritableStream({ write: (chunk) => { chunks.push(chunk) } }) - return { writable, chunks } -} - -describe('streamTransport', () => { - const testMessage = (message: string) => async () => { - const { transportA, transportB } = createPairedStreams() - await transportA.send(message) - expect(await transportB.receive()).toBe(message) - } - - it('sends and receives a single message', testMessage('hello')) - - it('handles empty string', testMessage('')) - - it('handles unicode characters', testMessage('Hello 世界 🌍')) - - it('handles large messages', testMessage('x'.repeat(100000))) - - it('sends and receives multiple messages', async () => { - const { transportA, transportB } = createPairedStreams() - await transportA.send('first') - await transportA.send('second') - await transportA.send('third') - expect(await transportB.receive()).toBe('first') - expect(await transportB.receive()).toBe('second') - expect(await transportB.receive()).toBe('third') - }) - - it('handles bidirectional communication', async () => { - const { transportA, transportB } = createPairedStreams() - await transportA.send('A to B') - await transportB.send('B to A') - expect(await transportB.receive()).toBe('A to B') - expect(await transportA.receive()).toBe('B to A') - }) - - it('validates message length in send', async () => { - const { writable } = collectWritable() - const transport = new StreamTransport(new ReadableStream(), writable) - const testMessage = 'x'.repeat(1000) - expect(new TextEncoder().encode(testMessage).length).toBeLessThan(0xFFFFFFFF) - await transport.send(testMessage) - }) - - it('rejects message exceeding 100MB limit in receive', async () => { - const lengthBytes = new Uint8Array(4) - new DataView(lengthBytes.buffer).setUint32(0, 100 * 1024 * 1024 + 1, true) - const transport = new StreamTransport(createReadable([lengthBytes]), new WritableStream()) - await expect(transport.receive()).rejects.toThrow('Message length exceeds maximum allowed size') - }) - - it('handles fragmented reads correctly', async () => { - const message = 'hello world' - const messageBytes = new TextEncoder().encode(message) - const lengthBytes = new Uint8Array(4) - new DataView(lengthBytes.buffer).setUint32(0, messageBytes.length, true) - const chunks = [lengthBytes.slice(0, 2), lengthBytes.slice(2), messageBytes.slice(0, 3), messageBytes.slice(3, 7), messageBytes.slice(7)] - const transport = new StreamTransport(createReadable(chunks), new WritableStream()) - expect(await transport.receive()).toBe(message) - }) - - it('aborts transport correctly', async () => { - const { transportA } = createPairedStreams() - expect(() => transportA.abort('test reason')).not.toThrow() - expect(() => transportA.abort('another reason')).not.toThrow() - }) - - it('handles stream closure during read', async () => { - const transport = new StreamTransport(createReadable([new Uint8Array([0x01, 0x00])]), new WritableStream()) - await expect(transport.receive()).rejects.toThrow('Stream closed') - }) - - it('validates send message format', async () => { - const { writable, chunks } = collectWritable() - const transport = new StreamTransport(new ReadableStream(), writable) - await transport.send('test') - transport.abort('done') - - // Length and message are written as a single chunk - expect(chunks.length).toBeGreaterThan(0) - const firstChunk = chunks[0]! - expect(firstChunk.length).toBeGreaterThanOrEqual(4) - const length = new DataView(firstChunk.buffer, firstChunk.byteOffset, 4).getUint32(0, true) - expect(length).toBe(4) - const messageBytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0) - 4) - let offset = 0 - let chunkOffset = 0 - for (const chunk of chunks) { - const start = chunkOffset === 0 ? 4 : 0 - const end = chunk.length - if (end > start) { - messageBytes.set(chunk.slice(start), offset) - offset += end - start - } - chunkOffset += chunk.length - } - expect(new TextDecoder().decode(messageBytes)).toBe('test') - }) - - it('works with capnweb RPC', async () => { - const { transportA, transportB } = createPairedStreams() - - class TestApi extends RpcTarget { - async getValue(): Promise { - return 'test-value' - } - } - - const apiA = new TestApi() - const _sessionA = new RpcSession(transportA, apiA) - - const sessionB = new RpcSession(transportB) - const apiB = sessionB.getRemoteMain() as unknown as TestApi - - // Give sessions time to initialize - await new Promise(resolve => setTimeout(resolve, 10)) - - const result = await apiB.getValue() - expect(result).toBe('test-value') - }, 10000) -}) diff --git a/src/stream-transport.ts b/src/stream-transport.ts deleted file mode 100644 index 543c8cb..0000000 --- a/src/stream-transport.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { RpcTransport } from 'capnweb' - -function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array { - const result = new Uint8Array(a.length + b.length) - result.set(a, 0) - result.set(b, a.length) - return result -} - -class BufferedReader { - private buffer: Uint8Array - private reader: ReadableStreamDefaultReader - - constructor(input: ReadableStream) { - this.buffer = new Uint8Array(0) - this.reader = input.getReader() - } - - async read(numberOfBytes: number): Promise { - if (numberOfBytes <= 0) { - throw new Error('numberOfBytes must be positive') - } - while (this.buffer.length < numberOfBytes) { - const result = await this.reader.read() - if (result.done || result.value == null) { - throw new Error('Stream closed') - } - this.buffer = concatBuffers(this.buffer, result.value) - } - const result = this.buffer.slice(0, numberOfBytes) - this.buffer = this.buffer.slice(numberOfBytes) - return result - } -} - -export class StreamTransport implements RpcTransport { - private bufferReader: BufferedReader - private writer: WritableStreamDefaultWriter - - constructor( - private input: ReadableStream, - private output: WritableStream, - ) { - this.bufferReader = new BufferedReader(input) - this.writer = output.getWriter() - } - - async send(message: string): Promise { - const encoder = new TextEncoder() - const messageBytes = encoder.encode(message) - const length = messageBytes.length - - // Validate length fits in 32-bit unsigned integer - if (length > 0xFFFFFFFF) { - throw new Error('Message length exceeds maximum allowed size (4GB)') - } - - const lengthBytes = new Uint8Array(4) - const view = new DataView(lengthBytes.buffer) - view.setUint32(0, length, true) // true = little-endian - - // Write length and message as a single chunk to avoid fragmentation issues - const combined = new Uint8Array(4 + length) - combined.set(lengthBytes, 0) - combined.set(messageBytes, 4) - await this.writer.write(combined) - } - - async receive(): Promise { - // Read length (4 bytes) - const lengthBytes = await this.bufferReader.read(4) - // Create a new buffer copy to ensure DataView reads from the correct position - // (slice() creates a view that might share the underlying buffer) - const lengthBuffer = new Uint8Array(lengthBytes).buffer - const length = new DataView(lengthBuffer).getUint32(0, true) // true = little-endian - - // Validate length to prevent DoS - if (length > 100 * 1024 * 1024) { // 100MB limit - throw new Error('Message length exceeds maximum allowed size') - } - - // Extract message (handle empty messages) - if (length === 0) { - return '' - } - const messageBytes = await this.bufferReader.read(length) - const decoder = new TextDecoder() - return decoder.decode(messageBytes) - } - - abort(reason: unknown): void { - this.input.cancel(reason).catch(() => {}) - // Per documentation, we want to try to write out any remaining data: - this.writer.close().catch(() => {}) - } -} diff --git a/src/tool-wrapper.ts b/src/tool-wrapper.ts index 08e3354..053b54a 100644 --- a/src/tool-wrapper.ts +++ b/src/tool-wrapper.ts @@ -1,14 +1,14 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' import type { Tool, ToolExecutionOptions } from 'ai' -import { asSchema } from 'ai' +import type { ToolFunction } from './exoeval' +import { asSchema } from '@ai-sdk/provider-utils' import camelCase from 'camelcase' -import { RpcTarget } from 'capnweb' -import { validate } from 'json-schema' import { compile as compileJsonSchemaToTs } from 'json-schema-to-typescript' -import invariant from 'tiny-invariant' import { zodToJsonSchema } from 'zod-to-json-schema' -import { RpcToolset } from './rpc-toolset' +import { asToolFn } from './exoeval' +import { isToolableFunction } from './exoeval/tool' -export type WrappableTools = { [key: string]: Tool | (() => RpcToolset) } | Tool[] +export type WrappableTools = { [key: string]: Tool } | Tool[] | { [k in string]: ToolFunction } const extractTypeBody = (interfaceCode: string): string => { const match = interfaceCode.match(/interface \w+ \{([\s\S]*)\}/) @@ -58,6 +58,35 @@ const getJsonSchema = (schema: unknown): Parameters[0] } +const toStandardSchema = (schema: Tool['inputSchema']): StandardSchemaV1 => { + if (schema && typeof schema === 'object' && '~standard' in schema) { + return schema + } + + const base = asSchema(schema as any) + const vendor = '@ai-sdk/provider-utils' + + const validateFn = base.validate + if (!validateFn) { + throw new TypeError('Schema has no validate method; use a schema with validation (e.g. Zod) or add JSON Schema validation support') + } + return { + '~standard': { + version: 1, + vendor, + validate: (value: unknown) => { + const result = validateFn(value) + if (!('success' in result)) { + throw new Error('Validation must be synchronous') + } + return result.success + ? { value: result.value } + : { issues: [{ message: result.error.message }] } + }, + }, + } +} + export async function* generateToolTypes( tools: WrappableTools, name: string, @@ -75,12 +104,6 @@ export async function* generateToolTypes( : Object.entries(tools) for (const [toolName, tool] of toolEntries) { - if (typeof tool === 'function') { - const toolset = tool() - yield `// \`RpcToolset\`: ${toolName} (see .d.ts below for methods)` - yield ` ${toolName}: () => RpcPromise<${toolset.constructor.name}>` - continue - } const inputSchema = getJsonSchema(tool.inputSchema) const outputSchema = tool.outputSchema ? getJsonSchema(tool.outputSchema) @@ -122,65 +145,23 @@ export async function* generateToolTypes( yield `}\n\nexport default ${name};` } -export const generateToolApi = (tools: WrappableTools, opts: ToolExecutionOptions) => { - class ToolApi extends RpcTarget { - __return_value__: unknown = null - __raw_code__: string - - constructor(code: string) { - super() - this.__raw_code__ = code - } - - async __code__(): Promise { - return this.__raw_code__ - } - - __return__(result: unknown) { - this.__return_value__ = result - } +const wrapTool = (tool: Tool, opts: ToolExecutionOptions): ((...args: unknown[]) => unknown) => { + if (!tool.execute) { + throw new Error(`Tool ${tool.title} does not have an execute function`) } + const schema = toStandardSchema(tool.inputSchema) + return asToolFn((input: unknown) => tool.execute!(input, opts), [schema]) +} +export const wrapTools = (tools: WrappableTools, opts: ToolExecutionOptions): { [k in string]: ToolFunction } => { const toolEntries = Array.isArray(tools) ? tools.map( (tool, index) => [ tool.title ?? `tool_${index}`, - tool, - ] as [string, Tool], + wrapTool(tool, opts), + ] as const, ) - : Object.entries(tools) - for (const [toolName, tool] of toolEntries) { - // We modify the prototype because Cap'n Web will only call methods on the - // prototype, not the instance. - (ToolApi.prototype as any)[toolName] = async function (this: ToolApi, ...args: any) { - if (typeof tool === 'function') { - // If it's a toolset, then it's a zero-arg function: - const toolset = tool() - invariant(toolset instanceof RpcToolset, 'Tool must return an instance of RpcToolset') - return toolset - } - - if (args.length !== 1) { - throw new Error(`Tool ${toolName} only accepts exactly one argument, but ${args.length} were provided`) - } - // Get JSON schema for validation - const schema = asSchema(tool.inputSchema) - const jsonSchema = await schema.jsonSchema - - // Validate arguments - const validation = validate(args[0], jsonSchema) - if (!validation.valid) { - throw new Error(`Invalid arguments for tool ${toolName}: ${validation.errors.map(e => e.message || e.property).join(', ')}`) - } - if (!tool.execute) { - throw new Error(`Tool ${toolName} does not have an execute function`) - } - return tool.execute(args[0], opts) - } - } - - return ToolApi + : Object.entries(tools).map(([toolName, tool]) => [toolName, isToolableFunction(tool) ? tool : wrapTool(tool, opts)]) + return Object.fromEntries(toolEntries) } - -export type ToolApi = InstanceType> diff --git a/vitest.config.ts b/vitest.config.ts index 647f393..ec66e80 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'packages/**/*.test.ts'], + exclude: ['**/.direnv/**', '**/node_modules/**'], }, }) diff --git a/website/package-lock.json b/website/package-lock.json index 95e4354..ef9d848 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -13,6 +13,7 @@ "@electric-sql/pglite": "^0.3.15", "@marsidev/react-turnstile": "^1.4.1", "ai": "^6.0.48", + "capnweb": "^0.5.0", "exoagent": "file:..", "kysely": "0.28.9", "kysely-d1": "^0.4.0", @@ -48,7 +49,6 @@ "license": "MIT", "dependencies": { "camelcase": "^9.0.0", - "capnweb": "file:dist/capnweb", "json-schema": "^0.4.0", "json-schema-to-typescript": "^15.0.0", "kysely": "^0.28.9", @@ -63,6 +63,7 @@ "@electric-sql/pglite": "^0.3.15", "@standard-schema/spec": "^1.1.0", "@types/node": "25.0.1", + "acorn": "^8.16.0", "bumpp": "10.3.2", "eslint": "9.39.2", "kysely-pglite-dialect": "^1.2.0", @@ -2964,6 +2965,12 @@ "node": ">=6" } }, + "node_modules/capnweb": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/capnweb/-/capnweb-0.5.0.tgz", + "integrity": "sha512-fujruGZ4jw3u7mzjqgU+Iad9ms2pS7jwEaAR42bbQe9Eax8Rq+MAOCLT1zmmL5AB/wuKf+0YoCYxfcnSfk2/1Q==", + "license": "MIT" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", diff --git a/website/package.json b/website/package.json index 2590487..c845a92 100644 --- a/website/package.json +++ b/website/package.json @@ -19,6 +19,7 @@ "@electric-sql/pglite": "^0.3.15", "@marsidev/react-turnstile": "^1.4.1", "ai": "^6.0.48", + "capnweb": "^0.5.0", "exoagent": "file:..", "kysely": "0.28.9", "kysely-d1": "^0.4.0", diff --git a/website/src/AgentChat.tsx b/website/src/AgentChat.tsx index 2190cde..f8bd2ed 100644 --- a/website/src/AgentChat.tsx +++ b/website/src/AgentChat.tsx @@ -1,7 +1,6 @@ -import type { RpcTarget } from 'capnweb' import type { Database } from 'sql.js' -import type { Api, CodeResult, SqlResult } from '../worker/index' -import { explicitCallback, newHttpBatchRpcSession, newWebSocketRpcSession, setGlobalRpcSessionOptions } from 'capnweb' +import type { Api, SqlResult } from '../worker/index' +import { newHttpBatchRpcSession, newWebSocketRpcSession } from 'capnweb' import React, { useEffect, useRef, useState } from 'react' import initSqlJs from 'sql.js' import sqlWasmUrl from 'sql.js/dist/sql-wasm.wasm?url' @@ -513,7 +512,7 @@ export function RawSqlAgentChat({ sessionIdPromise, leaderboard, isBountyClaimed using agent = api.currentSession({ sessionId: await sessionIdPromise }) const db = await getDb() - return await agent.chatRawSql(message, explicitCallback(async (sql: string): Promise => { + return await agent.chatRawSql(message, async (sql: string): Promise => { // eslint-disable-next-line no-console console.log('running sql', sql) try { @@ -526,7 +525,7 @@ export function RawSqlAgentChat({ sessionIdPromise, leaderboard, isBountyClaimed console.error('error executing sql', error) return { error: String(error) } } - }, 'stub')) + }) } const hasLeaderboard = leaderboard && (leaderboard.last24h.length > 0 || leaderboard.recent.length > 0) @@ -580,14 +579,6 @@ export function RawSqlAgentChat({ sessionIdPromise, leaderboard, isBountyClaimed } export function ExoAgentChat({ sessionIdPromise }: { sessionIdPromise: Promise }) { - // Set default mode to record/replay - useEffect(() => { - setGlobalRpcSessionOptions(() => ({ recordReplayMode: 'all' })) - return () => { - setGlobalRpcSessionOptions(() => ({})) - } - }, []) - // ExoAgent chat callback const chat = async (message: string): Promise => { using api = newWebSocketRpcSession('/api/bounty/rpc', undefined, { @@ -595,23 +586,7 @@ export function ExoAgentChat({ sessionIdPromise }: { sessionIdPromise: Promise => { - // eslint-disable-next-line no-console - console.log('executing code', code) - - let queryResult: unknown - - try { - // eslint-disable-next-line no-new-func -- we're running this code (that the user is prompting) intentionally for the hack challenge - const fn = new Function('api', `return (${code})(api)`) - queryResult = await fn(api) - } - catch (error) { - console.error('error executing code', error) - throw error - } - return queryResult as CodeResult - }, 'stub')) + return await agent.chatExoAgent(message) } return ( diff --git a/website/worker/index.ts b/website/worker/index.ts index 50348e3..974cc34 100644 --- a/website/worker/index.ts +++ b/website/worker/index.ts @@ -2,9 +2,9 @@ import type { LanguageModel } from 'ai' import type { StatsResult } from './stats' import { createGoogleGenerativeAI } from '@ai-sdk/google' import { generateText, stepCountIs } from 'ai' -import { newWorkersRpcResponse } from 'capnweb' +import { newWorkersRpcResponse, RpcTarget } from 'capnweb' import { env } from 'cloudflare:workers' -import { CodeMode, RpcToolset, tool } from 'exoagent' +import { codemode, tool } from 'exoagent' import { z } from 'zod' import { User } from './bounty-db' import { getStats } from './stats' @@ -66,7 +66,7 @@ async function validateSession(sessionId: string, db: D1Database): Promise Promise, @@ -306,14 +306,10 @@ NOTE: ALL QUERIES MUST BE SCOPED AGAINST USER WITH \`id = 1\`. THIS IS VERY IMPO } // ExoAgent chat - client provides code executor callback - @tool.unsafeNoValidation() + @tool(z.string().max(MAX_MESSAGE_LENGTH)) async chatExoAgent( message: string, - executeCode: (code: string, api: RpcToolset) => Promise, ): Promise<{ text: string, toolResults: Array<{ toolName: string, args: unknown, result: unknown }> }> { - z.string().max(MAX_MESSAGE_LENGTH).parse(message) - z.function().parse(executeCode) - // Load thread (checks rate limit and conversation limit) const { entries, threadId } = await getChatThread(this.#db, this.#sessionId, 'exoagent') @@ -356,13 +352,7 @@ class User extends db.Table('users').as('user') { return Account.on(account => account.userId['='](this.id)).from() } }` - const codeMode = await new CodeMode({ - kind: 'rpc', - safeEval: async (code: string, api: RpcToolset) => { - return codeResultSchema.parse(await executeCode(code, api)) - }, - }) - .wrap({ users: () => User.on(user => user.id['='](1)).from() }, dts) + const codeMode = await codemode({ users: User.on(user => user.id['='](1)).from() }, dts) const result = await generateText({ model: this.#model, @@ -373,22 +363,22 @@ class User extends db.Table('users').as('user') { IMPORTANT: You MUST use the execute_code tool to run queries. Never output code directly in your response - always execute it via the tool. -IMPORTANT: api.users() is the ONLY entry point. To access related data, you MUST use .join(): -- WRONG: api.users().accounts() -- CORRECT: api.users().join(({ user }) => user.accounts()) +IMPORTANT: api.users is the ONLY entry point. To access related data, you MUST use .join(): +- WRONG: api.users.accounts() +- CORRECT: api.users.join(({ user }) => user.accounts()) Examples: -- Select all user columns: api.users().select(({ user }) => user).execute() -- Select specific columns: api.users().select(({ user }) => ({ id: user.id, name: user.name })).execute() -- Join accounts: api.users().join(({ user }) => user.accounts()).select(({ user, account }) => ({ id: user.id, accountName: account.accountName })).execute() -- Join wallets: api.users().join(({ user }) => user.accounts()).join(({ account }) => account.wallet()).select(({ user, account, wallet }) => ({ name: user.name, walletName: wallet.name })).execute() -- Select all columns from joined table: api.users().join(({ user }) => user.accounts()).select(({ account }) => account).execute() +- Select all user columns: api.users.select(({ user }) => user).execute() +- Select specific columns: api.users.select(({ user }) => ({ id: user.id, name: user.name })).execute() +- Join accounts: api.users.join(({ user }) => user.accounts()).select(({ user, account }) => ({ id: user.id, accountName: account.accountName })).execute() +- Join wallets: api.users.join(({ user }) => user.accounts()).join(({ account }) => account.wallet()).select(({ user, account, wallet }) => ({ name: user.name, walletName: wallet.name })).execute() +- Select all columns from joined table: api.users.join(({ user }) => user.accounts()).select(({ account }) => account).execute() NOTE: all callbacks receive the current namespace object, e.g., select((ns) => ({ id: ns.user.id })) -api.users() is already auto-scoped to user with id = 1. No need to do any additional checks. +api.users is already auto-scoped to user with id = 1. No need to do any additional checks. -As a shorthand for selecting all columns, you can do e.g., api.users().select(({ user }) => user) +As a shorthand for selecting all columns, you can do e.g., api.users.select(({ user }) => user) `, messages, tools: { execute_code: codeMode },