branch:
server.ts
21438 bytesRaw
/**
 * Sandbox Example — Dynamic Code Execution with Worker Loader
 *
 * The AI agent writes JavaScript code, which runs in a dynamically loaded
 * Worker isolate. The isolate has NO access to the internet (globalOutbound
 * is null) — its only connection to the outside world is an `env.db` binding
 * that proxies back to the CustomerDatabase facet.
 *
 * This is the full Gadgets stack:
 *
 *   ┌─────────── SandboxAgent (parent DO) ──────────────────────────┐
 *   │                                                                │
 *   │  LLM ──▶ executeCode tool ──▶ env.LOADER                     │
 *   │                                   │                            │
 *   │         ┌─────────────────────────┼─────────────────────┐     │
 *   │         │  Dynamic Isolate        │                     │     │
 *   │         │  (agent-written code)   │                     │     │
 *   │         │  - no fetch()           │                     │     │
 *   │         │  - no connect()         │                     │     │
 *   │         │  - env.db is the        │                     │     │
 *   │         │    ONLY binding         │                     │     │
 *   │         └─────────────────────────┼─────────────────────┘     │
 *   │                                   │                            │
 *   │         DatabaseLoopback ◀────────┘                            │
 *   │              │ (WorkerEntrypoint that proxies to sub-agent)    │
 *   │              ▼                                                 │
 *   │  ┌─────────────────────────────────────────────────────┐      │
 *   │  │  CustomerDatabase (sub-agent — own isolated SQLite) │      │
 *   │  │  query() / execute() / getAllCustomers()            │      │
 *   │  └─────────────────────────────────────────────────────┘      │
 *   └────────────────────────────────────────────────────────────────┘
 *
 * Three layers of isolation:
 * 1. The dynamic isolate can't reach the internet (globalOutbound: null)
 * 2. The only binding is env.db, which goes through DatabaseLoopback
 * 3. The DatabaseLoopback proxies to the CustomerDatabase sub-agent, which
 *    has its own SQLite the parent can't access directly
 *
 * Console output from the isolate is captured via Tail events and
 * delivered back to the parent through TailLoopback.
 */

import { createWorkersAI } from "workers-ai-provider";
import { Agent, routeAgentRequest } from "agents";
import { AIChatAgent } from "@cloudflare/ai-chat";
import { WorkerEntrypoint } from "cloudflare:workers";
import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";
import { z } from "zod";

// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────

export type SandboxState = {
  customers: CustomerRecord[];
  executions: ExecutionRecord[];
};

export type CustomerRecord = {
  id: number;
  name: string;
  email: string;
  tier: string;
  region: string;
};

export type ExecutionRecord = {
  id: string;
  code: string;
  output: string;
  error: string | null;
  timestamp: string;
};

// ─────────────────────────────────────────────────────────────────────────────
// CustomerDatabase — sub-agent with isolated SQLite
// ─────────────────────────────────────────────────────────────────────────────

export class CustomerDatabase extends Agent<Env> {
  private _db!: SqlStorage;

  onStart() {
    this._db = this.ctx.storage.sql;
    this._seed();
  }

  private _seed() {
    this._db.exec(`
      CREATE TABLE IF NOT EXISTS customers (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT NOT NULL,
        tier TEXT NOT NULL DEFAULT 'Bronze',
        region TEXT NOT NULL DEFAULT 'Unknown'
      )
    `);
    const row = this._db
      .exec("SELECT COUNT(*) as cnt FROM customers")
      .one() as {
      cnt: number;
    };
    if (row.cnt === 0) {
      this._db.exec(`INSERT INTO customers (name, email, tier, region) VALUES
        ('Alice Chen', 'alice@example.com', 'Gold', 'West'),
        ('Bob Martinez', 'bob@example.com', 'Silver', 'East'),
        ('Carol Johnson', 'carol@example.com', 'Bronze', 'West'),
        ('Dave Kim', 'dave@example.com', 'Gold', 'Central'),
        ('Eve Williams', 'eve@example.com', 'Silver', 'East'),
        ('Frank Brown', 'frank@example.com', 'Bronze', 'West'),
        ('Grace Lee', 'grace@example.com', 'Gold', 'Central'),
        ('Hank Davis', 'hank@example.com', 'Silver', 'East')
      `);
    }
  }

  query(sqlText: string): Record<string, unknown>[] {
    return [...this._db.exec(sqlText).toArray()] as Record<string, unknown>[];
  }

  execute(sqlText: string): { success: boolean } {
    this._db.exec(sqlText);
    return { success: true };
  }

  getAllCustomers(): CustomerRecord[] {
    return [
      ...this._db
        .exec("SELECT id, name, email, tier, region FROM customers ORDER BY id")
        .toArray()
    ] as CustomerRecord[];
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// DatabaseLoopback — WorkerEntrypoint that proxies to the sub-agent
//
// Dynamic isolates (from env.LOADER) can have ServiceStubs in their env
// but not direct sub-agent stubs. So we create a WorkerEntrypoint whose
// methods proxy to the CustomerDatabase sub-agent via the parent.
//
// The dynamic isolate sees `env.db` as a service binding. When the code
// calls `env.db.query(sql)`, it goes:
//   dynamic isolate → DatabaseLoopback → SandboxAgent.proxyDbQuery() → sub-agent
//
// This is the Gatekeeper Loopback pattern — see gadgets.md.
// ─────────────────────────────────────────────────────────────────────────────

type LoopbackProps = {
  agentId: string;
};

export class DatabaseLoopback extends WorkerEntrypoint<Env, LoopbackProps> {
  private _agentId: string = this.ctx.props.agentId;

  private _getAgent(): DurableObjectStub<SandboxAgent> {
    // @ts-expect-error — experimental: ctx.exports
    const ns = this.ctx.exports
      .SandboxAgent as DurableObjectNamespace<SandboxAgent>;
    return ns.get(ns.idFromString(this._agentId));
  }

  /** Called by code in the dynamic isolate: env.db.query(sql) */
  async query(sql: string): Promise<Record<string, unknown>[]> {
    return this._getAgent().proxyDbQuery(sql);
  }

  /** Called by code in the dynamic isolate: env.db.execute(sql) */
  async execute(sql: string): Promise<{ success: boolean }> {
    return this._getAgent().proxyDbExecute(sql);
  }

  /** Called by code in the dynamic isolate: env.db.getAllCustomers() */
  async getAllCustomers(): Promise<CustomerRecord[]> {
    return this._getAgent().proxyDbGetAll();
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// TailLoopback — captures console output from dynamic isolates
//
// When code in the dynamic isolate calls console.log(), the output is
// captured as a Tail event. This WorkerEntrypoint receives those events
// and delivers them back to the SandboxAgent.
// ─────────────────────────────────────────────────────────────────────────────

type TailLoopbackProps = {
  executionId: string;
  agentId: string;
};

export class TailLoopback extends WorkerEntrypoint<Env, TailLoopbackProps> {
  async tail(events: TraceItem[]) {
    if (events.length === 0) return;

    const event = events[0];
    // Skip the verify() call trace
    if (
      event.event &&
      "rpcMethod" in event.event &&
      event.event.rpcMethod === "verify"
    ) {
      return;
    }

    // Round-trip through JSON to make traces serializable
    const serializable = JSON.parse(JSON.stringify(event));

    // ctx.exports is available on WorkerEntrypoints in the experimental runtime,
    // but the types only declare it on DurableObjectState.
    // @ts-expect-error — experimental: ctx.exports on WorkerEntrypoint
    const ns = this.ctx.exports
      .SandboxAgent as DurableObjectNamespace<SandboxAgent>;
    const stub = ns.get(ns.idFromString(this.ctx.props.agentId));
    await stub.deliverTrace(this.ctx.props.executionId, serializable);
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Harness code — wraps agent-written code in a WorkerEntrypoint
// ─────────────────────────────────────────────────────────────────────────────

const CODE_HARNESS = `
import { WorkerEntrypoint } from "cloudflare:workers";
import run from "user-code.js";

export default class extends WorkerEntrypoint {
  verify() {}
  async run() {
    await run(this.env);
  }
}
`;

// ─────────────────────────────────────────────────────────────────────────────
// SandboxAgent
// ─────────────────────────────────────────────────────────────────────────────

export class SandboxAgent extends AIChatAgent<Env, SandboxState> {
  initialState: SandboxState = {
    customers: [],
    executions: []
  };

  async onStart() {
    this._initTables();
    await this._syncState();
  }

  // ─── Database sub-agent ────────────────────────────────────────────────

  private _db() {
    return this.subAgent(CustomerDatabase, "database");
  }

  // These proxy methods are called by DatabaseLoopback, which is called
  // by code running in the dynamic isolate. The chain is:
  //   isolate code → env.db (DatabaseLoopback) → these methods → sub-agent

  async proxyDbQuery(sql: string): Promise<Record<string, unknown>[]> {
    const db = await this._db();
    return db.query(sql);
  }

  async proxyDbExecute(sql: string): Promise<{ success: boolean }> {
    const db = await this._db();
    return db.execute(sql);
  }

  async proxyDbGetAll(): Promise<CustomerRecord[]> {
    const db = await this._db();
    return db.getAllCustomers();
  }

  // ─── Tables ──────────────────────────────────────────────────────────

  private _initTables() {
    this.sql`
      CREATE TABLE IF NOT EXISTS executions (
        id TEXT PRIMARY KEY,
        code TEXT NOT NULL,
        output TEXT NOT NULL DEFAULT '',
        error TEXT,
        timestamp TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `;
  }

  // ─── State sync ──────────────────────────────────────────────────────

  private async _syncState() {
    const db = await this._db();
    const customers = await db.getAllCustomers();
    const executions = this.sql<ExecutionRecord>`
      SELECT id, code, output, error, timestamp
      FROM executions ORDER BY timestamp DESC LIMIT 20
    `;
    this.setState({ customers, executions });
  }

  // ─── Trace delivery (from TailLoopback) ──────────────────────────────

  #traceResolvers = new Map<string, (trace: TraceItem) => void>();

  async deliverTrace(executionId: string, trace: TraceItem) {
    const resolver = this.#traceResolvers.get(executionId);
    if (resolver) {
      resolver(trace);
      this.#traceResolvers.delete(executionId);
    }
  }

  // ─── Code execution via LOADER ───────────────────────────────────────

  /**
   * Execute user/agent-written code in a sandboxed dynamic Worker isolate.
   *
   * The isolate gets:
   *   - env.db: a DatabaseLoopback binding (the ONLY way to reach data)
   *   - globalOutbound: null (no fetch, no connect — fully sandboxed)
   *   - tails: [TailLoopback] to capture console.log output
   *
   * This follows the Gadgets code execution pattern — see gadgets.md.
   */
  private async _executeCode(code: string): Promise<{
    output: string;
    error: string | null;
  }> {
    const executionId = crypto.randomUUID();

    // Set up a promise that resolves when the tail event arrives
    const tracePromise = new Promise<TraceItem>((resolve) => {
      this.#traceResolvers.set(executionId, resolve);
    });

    // Build the loopback bindings. The dynamic isolate will see env.db
    // as a service binding pointing at DatabaseLoopback, which proxies
    // back to our facet.
    const loopbackProps: LoopbackProps = {
      agentId: this.ctx.id.toString()
    };
    const tailProps: TailLoopbackProps = {
      executionId,
      agentId: this.ctx.id.toString()
    };

    // @ts-expect-error — experimental: ctx.exports
    const dbBinding = this.ctx.exports.DatabaseLoopback({
      props: loopbackProps
    });
    // @ts-expect-error — experimental: ctx.exports
    const tailBinding = this.ctx.exports.TailLoopback({ props: tailProps });

    // Create the dynamic isolate via the Worker Loader.
    // Each execution gets a unique ID so isolates don't collide.
    const worker = this.env.LOADER.get(executionId, () => ({
      compatibilityDate: "2026-01-28",
      mainModule: "harness.js",
      modules: {
        "harness.js": CODE_HARNESS,
        "user-code.js": code
      },
      // The ONLY binding the code gets — everything else is blocked
      env: { db: dbBinding },
      // Capture console.log output
      tails: [tailBinding],
      // No internet access
      globalOutbound: null
    }));

    // Verify the code compiles and the isolate starts.
    // We cast because getEntrypoint's type expects a WorkerEntrypoint brand,
    // but our harness is dynamically loaded.
    const entrypoint = worker.getEntrypoint() as unknown as {
      verify(): Promise<void>;
      run(): Promise<void>;
    };
    await entrypoint.verify();

    // Run the code
    let error: string | null = null;
    try {
      await entrypoint.run();
    } catch (err) {
      error = err instanceof Error && err.stack ? err.stack : String(err);
    }

    // Wait for the tail event (with timeout)
    const timeout = new Promise<null>((resolve) =>
      setTimeout(() => resolve(null), 5000)
    );
    const trace = await Promise.race([tracePromise, timeout]);

    let output = "";
    if (trace) {
      output = (trace.logs || [])
        .map((log: { message: unknown[] }) =>
          (log.message as unknown[])
            .map((part) =>
              typeof part === "string" ? part : JSON.stringify(part)
            )
            .join(" ")
        )
        .join("\n");
    }

    if (error) {
      output += (output ? "\n\n" : "") + `Error: ${error}`;
    }

    return { output: output || "(no output)", error };
  }

  // ─── Chat ────────────────────────────────────────────────────────────

  async onChatMessage() {
    const workersai = createWorkersAI({ binding: this.env.AI });
    const agent = this;

    const result = streamText({
      model: workersai("@cf/moonshotai/kimi-k2.5", {
        sessionAffinity: this.sessionAffinity
      }),
      system: `You are a helpful assistant that can write and execute JavaScript code to work with a customer database.

You have access to an executeCode tool. The code you write runs in a SANDBOX — a completely
isolated environment with no internet access. The only thing the code can do is interact with
a customer database through the \`env.db\` binding.

Available methods on env.db:
- env.db.query(sql) — run a SELECT query, returns an array of row objects
- env.db.execute(sql) — run INSERT/UPDATE/DELETE, returns { success: true }
- env.db.getAllCustomers() — returns all customers as an array

The database has a "customers" table with columns: id, name, email, tier (Bronze/Silver/Gold), region (West/East/Central).

Your code must export a default async function that receives env:

\`\`\`js
export default async function(env) {
  const rows = await env.db.query("SELECT * FROM customers WHERE tier = 'Gold'");
  console.log("Gold customers:", rows.length);
  for (const row of rows) {
    console.log(\`  - \${row.name} (\${row.region})\`);
  }
}
\`\`\`

console.log() output is captured and returned. Use it to show results.
Write clean, readable code. Handle errors gracefully.`,
      messages: await convertToModelMessages(this.messages),
      tools: {
        executeCode: tool({
          description:
            "Execute JavaScript code in a sandboxed Worker isolate. " +
            "The code has no internet access — only env.db for database operations. " +
            "Console output is captured and returned.",
          inputSchema: z.object({
            code: z
              .string()
              .describe(
                "JavaScript module exporting a default async function(env). " +
                  "Use env.db.query(sql), env.db.execute(sql), or env.db.getAllCustomers(). " +
                  "Use console.log() to produce output."
              )
          }),
          execute: async ({ code }) => {
            try {
              const { output, error } = await agent._executeCode(code);

              // Persist the execution
              const id = crypto.randomUUID();
              agent.sql`
                INSERT INTO executions (id, code, output, error)
                VALUES (${id}, ${code}, ${output}, ${error})
              `;
              await agent._syncState();

              return { output, error };
            } catch (err) {
              return { output: "", error: String(err) };
            }
          }
        }),

        queryDatabase: tool({
          description:
            "Directly query the database with a SELECT. " +
            "Use this for simple queries; use executeCode for complex logic.",
          inputSchema: z.object({
            sql: z.string().describe("A SELECT query")
          }),
          execute: async ({ sql }) => {
            try {
              const db = await agent._db();
              const results = await db.query(sql);
              return { rowCount: results.length, rows: results };
            } catch (err) {
              return { error: String(err) };
            }
          }
        })
      },
      stopWhen: stepCountIs(5)
    });

    return result.toUIMessageStreamResponse();
  }
}

// ─────────────────────────────────────────────────────────────────────────────

export default {
  async fetch(request: Request, env: Env) {
    return (
      (await routeAgentRequest(request, env)) ||
      new Response("Not found", { status: 404 })
    );
  }
} satisfies ExportedHandler<Env>;