From 31153a14a8657064420cb8b50a312f909ade6643 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 20:03:26 +0530 Subject: [PATCH 01/32] docs: add E2B sandbox provider design --- .../2026-10-01-e2b-sandbox-provider-design.md | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md diff --git a/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md b/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md new file mode 100644 index 0000000..2615463 --- /dev/null +++ b/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md @@ -0,0 +1,244 @@ +# E2B Sandbox Provider Design + +**Date:** 2026-10-01 +**Status:** Approved + +--- + +## Overview + +Codevil runs every Session in a Cloudflare Sandbox container (`standard-1`: ½ vCPU, 4 GiB). In practice this causes three problems: + +1. **CPU starvation.** `npm install`, dev servers, Pi, and workspace-cache `mksquashfs` (zstd, 4 threads) all share half a core. Installs and live preview stutter or fail. +2. **Unreliable workspace snapshots.** The R2 squashfs backup/restore path flakes (restore is retried up to four times) and never preserves running processes. +3. **Frequent disconnects** and a long preview path (Worker → Orchestrator DO → Sandbox DO → container, including HMR WebSockets). + +This design introduces a provider-neutral sandbox interface, makes **E2B** the default provider, keeps Cloudflare as an opt-in adapter, and adds **pause-when-idle** on E2B using E2B's pause/resume (filesystem, memory, and running processes are preserved; resume takes about one second). + +## Goals + +- One `SandboxProvider` / `SandboxHandle` interface that the Orchestrator, preview, logs, and diagnostics use instead of calling `@cloudflare/sandbox` directly. +- An E2B adapter (default) and a Cloudflare adapter (opt-in) behind that interface. +- E2B sandboxes with 2 vCPU / 4 GiB, created from a Codevil template built from the shared Dockerfile. +- Pause the E2B sandbox after `max_idle_time` without agent input or preview traffic; resume transparently on the next Agent Request or preview request. +- Preview on E2B through the existing Codevil preview URLs and tokens, with E2B's public URLs locked behind a traffic access token. +- Preserve the existing security boundary: provider credentials, the GitHub PAT, and the E2B API key stay in the Worker; the sandbox receives only short-lived, audience-bound capabilities. + +## Non-Goals + +- A warm workspace cache on E2B. New E2B Sessions start cold (clone and install). +- Moving the Pi agent loop out of the sandbox. +- Fixing Cloudflare idle/keepalive behavior (keepalive is still only cleared by `terminateSandbox`). +- Removing `[[containers]]` or the `Sandbox` Durable Object from `wrangler.toml`. +- Per-Session provider choice in the UI or API. +- A Daytona or other third adapter (the interface allows one later). + +## Background: Current Coupling + +Direct `@cloudflare/sandbox` usage today: + +| Concern | Location | +|---|---| +| Sandbox DO subclass, lifecycle hooks, keepalive deferral, WS port routing | `packages/worker/src/index.ts` | +| Provision + start agent process (`startProcess`), retries, keepalive | `packages/worker/src/sandbox.ts` | +| Logs and diagnostics (`getProcessLogs`, lifecycle snapshot) | `sandbox.ts`, `http-handlers.ts` (`handleLogs`, `handleDiagnostics`), `orchestrator.ts` (`logSandboxDisconnectDiagnostics`) | +| Stop (`setKeepAlive(false)` + `stop()`) | `orchestrator.ts` (`terminateSandbox`) | +| Preview proxy (`sandbox.fetch` with `cf-container-target-port`) | `orchestrator/preview.ts` | +| Workspace cache (`createBackup` / `restoreBackup`) | `workspace-cache.ts`, `orchestrator/workspace-cache-job.ts`, `orchestrator/sandbox-handlers.ts` | +| Image (`FROM cloudflare/sandbox:0.12.7`) | `Dockerfile.sandbox` | + +The agent opens its own WebSocket back to the Orchestrator DO (`CODEVIL_DO_WS_URL` + `sandbox_ws_token`) and uses the Worker's LLM and Git proxies. That protocol does not depend on the provider. + +## Architecture + +### Provider interface + +The shape follows Flue's sandbox adapter pattern (a factory per provider that returns a handle with a small `exec`/`ShellResult` core) and adds the lifecycle, background process, and port methods Codevil needs. + +```ts +export type SandboxProviderName = "e2b" | "cloudflare"; + +export interface SandboxRef { + provider: SandboxProviderName; + /** E2B sandboxId; for Cloudflare, the Session ID (the Sandbox DO name). */ + id: string; +} + +export interface ShellResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface SandboxProvider { + readonly name: SandboxProviderName; + readonly capabilities: { + pauseResume: boolean; + workspaceCache: boolean; + }; + create(options: { sessionId: string; leaseMs: number }): Promise; + /** Attaches to an existing sandbox. Resumes it when paused. */ + connect(ref: SandboxRef, options?: { leaseMs?: number }): Promise; +} + +export interface SandboxHandle { + readonly ref: SandboxRef; + exec( + command: string, + options?: { cwd?: string; env?: Record; timeoutMs?: number; user?: "root" | "codevil" }, + ): Promise; + writeFile(path: string, content: string, options?: { mode?: number; owner?: "root" | "codevil" }): Promise; + startProcess( + command: string, + options: { processId: string; cwd: string; env: Record }, + ): Promise; + readProcessLogs(processId: string): Promise<{ stdout: string; stderr: string }>; + /** Proxies an HTTP or WebSocket-upgrade request to a port inside the sandbox. */ + fetchPort(port: number, request: Request): Promise; + renewLease(ms: number): Promise; + /** Present only when the provider's capabilities.pauseResume is true. */ + pause?(): Promise; + destroy(): Promise; +} + +export class SandboxNotFoundError extends Error {} +``` + +Module layout (worker package): + +- `src/sandbox-provider/types.ts`: the interface, `SandboxNotFoundError`, and helpers. +- `src/sandbox-provider/index.ts`: `resolveSandboxProvider(env, name)` and `configuredSandboxProvider(env)`. +- `src/sandbox-provider/cloudflare.ts`: wraps today's code (`getCodevilSandbox`, keepalive, `startProcess`, `getProcessLogs`, `fetch` with `cf-container-target-port`, `stop`, `createBackup`/`restoreBackup`). `pause` is absent; `capabilities = { pauseResume: false, workspaceCache: true }`. `renewLease` is a no-op (Cloudflare uses keepalive). +- `src/sandbox-provider/e2b.ts`: the E2B adapter. `capabilities = { pauseResume: true, workspaceCache: false }`. + +Workspace-cache code keeps its current types but is reached only through the Cloudflare adapter (the adapter exposes the `WorkspaceCacheSandbox` view when `capabilities.workspaceCache` is true). On E2B, cache restore and snapshot jobs are skipped. + +### Provider selection and stored state + +- `SANDBOX_PROVIDER` (`e2b` | `cloudflare`, default `e2b`) is read **once at Session creation** and stored as `meta.sandbox_provider`. Changing the variable affects only new Sessions. +- Sessions created before this change have no `sandbox_provider`; they are treated as `cloudflare`. +- After `create`, the Orchestrator stores `meta.sandbox_ref`. The E2B adapter also needs the traffic access token; the Orchestrator stores it in DO storage under its own key (never in `SessionMeta` broadcasts) and adds it to the Orchestrator's redaction secrets. +- `handleLogs` and `handleDiagnostics` are routed through the Orchestrator DO (an RPC on the stub), because only the Orchestrator knows the `SandboxRef`. Responses keep the existing redaction. + +### Lifecycle + +**Lease (E2B).** Sandboxes are created with E2B timeout `leaseMs = min(remaining max_time, E2B_MAX_SANDBOX_SECONDS × 1000)`. The Orchestrator alarm renews the lease while the sandbox runs. If the Orchestrator stops renewing, E2B kills the sandbox on its own. + +**States.** `sandbox_state` gains `paused`. While paused, `meta.expected_close` is true, so the 60-second reconnect grace (`SANDBOX_RECONNECT_GRACE_MS`) does not apply and the Session is not failed. + +**Activity.** The Orchestrator tracks `meta.last_activity_at`, updated on: + +- a new Agent Request; +- an answer to an open question; +- an Agent Run finishing; +- any preview HTTP request. + +An open preview WebSocket does not count as activity by itself. + +**Pause.** On each alarm tick, when `capabilities.pauseResume` is true and all of the following hold: + +- the sandbox is running and connected; +- no Agent Run is active or queued, and no question is open; +- `now − last_activity_at ≥ max_idle_time` (default `10m`); + +the Orchestrator: + +1. sets `meta.expected_close = true` and saves meta; +2. calls `handle.pause()`; +3. sets `sandbox_state = "paused"` (meta and session directory); +4. broadcasts a status "Sandbox paused (idle)." + +The alarm is armed for the earlier of the idle deadline and existing deadlines. + +**Resume.** Triggered by a new Agent Request or a preview request while `paused`: + +1. `provider.connect(ref, { leaseMs })` resumes the sandbox. +2. The Orchestrator issues a fresh sandbox WebSocket capability and writes it to `/run/codevil/ws-token` (mode `0600`, owner `codevil`) with `handle.writeFile`. +3. `sandbox_state` becomes `connecting`; `expected_close` is cleared. +4. The agent's existing reconnect loop sees the dropped socket and reconnects with the new token. On connect it already requests fresh LLM and Git capabilities. + +Concurrent resume triggers share one in-flight resume promise. Agent Requests queue as today until the sandbox WebSocket reconnects. A preview request waits for the resume for up to 15 seconds, then returns 503. + +**Agent-side change.** `packages/sandbox-image/src/entrypoint.ts` reads `/run/codevil/ws-token` (when present and non-empty) in `createSocket` before each connection attempt and uses it in place of the in-memory token. When the file is absent (Cloudflare, or E2B before any resume), behavior is unchanged. + +**Termination.** When a Session reaches a terminal state (`max_time`, user stop, failure), the Orchestrator calls `handle.destroy()` best-effort, whether the sandbox is running or paused. Paused E2B sandboxes never expire, so this is the only cleanup for them. `max_time` keeps counting from Session creation while paused. + +**Cloudflare.** `pause` is absent, so the idle check never fires. Keepalive, `sleepAfter`, and lifecycle hooks in the `Sandbox` DO subclass stay unchanged. + +### Preview + +The request path on E2B is Browser → Worker → Orchestrator DO → `handle.fetchPort(port, request)` → `https://{port}-{sandboxId}.e2b.app`. + +All existing preview logic stays in `orchestrator/preview.ts`: preview tokens and hashing, revocation, `validatePreviewAccess`, `rewriteHeadersForSandboxDevServer`, path-based body rewriting, and 502/503/504 retries. Only the final `sandbox.fetch` call is replaced by `handle.fetchPort`. + +E2B sandboxes are created with `secure: true` and `allowPublicTraffic: false`. The E2B adapter's `fetchPort` adds the `e2b-traffic-access-token` header and forwards WebSocket upgrades with an outbound `fetch` carrying `Upgrade: websocket`, returning the 101 response. + +### Image and template + +- `Dockerfile.sandbox` gains `ARG SANDBOX_BASE=docker.io/cloudflare/sandbox:0.12.7`. The E2B build passes `node:22-slim`. The runtime stage installs `bun` when it is missing. Everything else (apt packages, the `codevil` user with UID/GID 10001, `/workspace`, the built `sandbox-image` app, Pi skills) is shared. +- `packages/sandbox-image` gains an `e2b:template` script that builds the image with local Docker and publishes it as E2B template `codevil-sandbox` with 2 vCPU / 4096 MiB. The exact publishing tool (E2B CLI or Template SDK) is chosen in the feasibility gate. +- `dockerfile.test.mjs` covers both base variants. + +### Agent process and logs (E2B) + +- `startProcess` runs today's command (`chown` + `setpriv` to UID 10001, then `node /app/packages/sandbox-image/dist/index.js`) as root with `commands.run(..., { background: true, user: "root", cwd, envs })`. +- Output is redirected to `/var/log/codevil/.out` and `/var/log/codevil/.err`, because E2B streams output only while a client is attached. +- `readProcessLogs` returns the last 64 KiB of each file via `exec`. The existing redaction and disconnect-diagnostics payloads are unchanged. + +### Configuration + +| Variable | Default | Notes | +|---|---|---| +| `SANDBOX_PROVIDER` | `e2b` | Read at Session creation. | +| `E2B_API_KEY` | none (secret) | Stays in the Worker; added to redaction secrets. Provisioning fails with a clear error when missing. | +| `E2B_TEMPLATE_ID` | `codevil-sandbox` | Template name or ID. | +| `E2B_MAX_SANDBOX_SECONDS` | `3600` | Lease cap (matches the E2B Hobby session limit). | + +`.dev.vars.example`, `wrangler.operator.example.toml`, and deployment docs document the new variables. + +## Error Handling (E2B) + +| Failure | Behavior | +|---|---| +| `create` fails | Retry 429, 5xx, and network errors with `retrySandboxOperation`; then fail the Session as today. | +| `SandboxNotFoundError` on connect, renew, or resume | The sandbox is gone. Fail the Session with "Sandbox expired." Never recreate silently. | +| Resume fails (other errors) | Retry three times, then fail the Session and cancel queued Agent Requests with that reason. | +| Pause fails | Log, leave the sandbox running, retry on the next alarm tick. | +| Lease renewal fails | Log, retry on the next tick. `SandboxNotFoundError` is handled as above. | +| Unexpected disconnect while running | Existing 60-second grace, then diagnostics through the handle. | +| Destroy fails at termination | Log only. The lease covers running sandboxes; paused leftovers are visible in the E2B dashboard. | + +E2B error messages pass through `redactEvent` before logging, like Cloudflare errors today. + +## Feasibility Gate + +Implementation task 1 is a throwaway probe. Nothing else starts until it passes. It runs in `wrangler dev` with `nodejs_compat` and a real `E2B_API_KEY`, and answers: + +1. Does the E2B JS SDK run in workerd for create, `commands.run` (foreground and background), file write and read, `pause`, `connect` (resume), `setTimeout`, and `kill`? +2. Does a Worker `fetch` to `https://{port}-{sandboxId}.e2b.app` with `e2b-traffic-access-token` work for HTTP and for a WebSocket upgrade? +3. Does resuming a paused sandbox reset the Hobby one-hour session clock? +4. Which tool publishes a template from our multi-stage Dockerfile? + +If (1) fails, implementation stops and the design is revisited (a Node control service or E2B's REST API are the fallbacks). Answers to (2)–(4) are recorded in this spec before task 2. + +## Testing + +- **Cloudflare adapter:** a behavior-preserving refactor. Existing worker tests keep their assertions. +- **E2B adapter:** unit tests with a mocked E2B client covering create options (`secure`, `allowPublicTraffic: false`, timeout), the access-token header and WebSocket forwarding in `fetchPort`, log file tails, `writeFile` mode and owner, and mapping not-found errors to `SandboxNotFoundError`. +- **Orchestrator** (fake host and fake provider): + - idle pause fires after `max_idle_time` without input; + - an active or queued run, or an open question, blocks pause; + - preview requests delay pause; + - an Agent Request and a preview request each resume and write a fresh token; + - concurrent resume triggers share one resume; + - `max_time` destroys a paused sandbox; + - the stored `sandbox_provider` wins over a changed env var; + - legacy Sessions without `sandbox_provider` use Cloudflare; + - workspace-cache restore and jobs are skipped on E2B. +- **Entrypoint:** rereads `/run/codevil/ws-token` on reconnect; works unchanged without the file. +- **Manual end-to-end on E2B (free credits):** + 1. Create a Session on a real repo; clone and `npm install` complete. + 2. Start preview; hot reload works through the Codevil preview URL. + 3. Idle past `max_idle_time`; the sandbox pauses. + 4. Resume once with a prompt and once by opening the preview; the dev server is still running and the agent continues. + 5. Stop the Session; the sandbox is destroyed in the E2B dashboard. From 2b09218d92501d2236c47ece30816ab51995d38c Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 20:08:59 +0530 Subject: [PATCH 02/32] docs: add E2B sandbox provider implementation plan --- .../plans/2026-10-01-e2b-sandbox-provider.md | 930 ++++++++++++++++++ .../2026-10-01-e2b-sandbox-provider-design.md | 4 +- 2 files changed, 932 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-01-e2b-sandbox-provider.md diff --git a/docs/superpowers/plans/2026-10-01-e2b-sandbox-provider.md b/docs/superpowers/plans/2026-10-01-e2b-sandbox-provider.md new file mode 100644 index 0000000..70af1db --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-e2b-sandbox-provider.md @@ -0,0 +1,930 @@ +# E2B Sandbox Provider Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put Codevil's sandbox behind a provider-neutral interface, add an E2B adapter as the default provider, and pause idle E2B sandboxes. + +**Architecture:** A `SandboxProvider` factory returns a `SandboxHandle` (Flue-style adapter pattern plus lifecycle, background process, and port methods). The Orchestrator Durable Object stores the provider name and a `SandboxRef` on `SessionMeta` and talks only to the interface. The Cloudflare adapter wraps today's `@cloudflare/sandbox` code unchanged; the E2B adapter uses the `e2b` JS SDK from inside the Worker. Idle pause is driven by the Orchestrator's existing alarm loop. + +**Tech Stack:** TypeScript, Cloudflare Workers + Durable Objects (workerd, `nodejs_compat`), `@cloudflare/sandbox` 0.12.7, `e2b` JS SDK v2, zod, `node:test` (tests are `.mjs` files that import from `dist/` after `tsc`), pnpm workspaces. + +**Spec:** `docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md` + +## Global Constraints + +- Provider credentials, the GitHub PAT, `E2B_API_KEY`, and the E2B traffic access token never enter the sandbox and never appear unredacted in logs, broadcasts, or HTTP responses. +- `SANDBOX_PROVIDER` values: `e2b` | `cloudflare`; default `e2b` (from Task 4 onward; `cloudflare` until then). Read once at Session creation; stored as `meta.sandbox_provider`. Missing on legacy Sessions → `cloudflare`. +- `E2B_TEMPLATE_ID` default `codevil-sandbox`; `E2B_MAX_SANDBOX_SECONDS` default `3600`. +- E2B sandboxes: 2 vCPU / 4096 MiB template; created with `secure: true`, `allowPublicTraffic: false`. +- Preview traffic to E2B carries header `e2b-traffic-access-token`. +- Agent WS token file: `/run/codevil/ws-token`, mode `0600`, owner `codevil` (UID/GID 10001). +- Agent logs on E2B: `/var/log/codevil/.out` and `.err`; `readProcessLogs` returns the last 64 KiB (65536 bytes) of each. +- `max_idle_time` default `10m`. Preview wait-for-resume timeout: 15 seconds, then 503. +- Resume retries: 3. Lease renewal interval: 5 minutes. +- Cloudflare behavior (keepalive, `sleepAfter = "10m"`, lifecycle hooks, workspace cache) must not change. Existing worker tests keep their assertions. +- Worker tests: `pnpm --filter @codevil/worker test` (builds, then `node --test test/*.test.mjs`). Sandbox-image tests: `pnpm --filter @codevil/sandbox-image test`. Shared: `pnpm --filter @codevil/shared test`. Full gate: `pnpm verify`. +- Commit after every task with a conventional message (`feat(sandbox): …`, `refactor(sandbox): …`). Work on the current branch `t3code/443d5acd`; never on `main`. + +## Task Ordering + +Tasks 1, 3, 5, and 6 do not touch E2B and may run while Task 2 (the feasibility gate) waits for an `E2B_API_KEY`. Tasks 4, 7, 8, and 9 start only after Task 2 passes. If Task 2 fails, stop after the provider-neutral tasks and report. + +## Review Focus + +1. **Pause while the agent is mid-reconnect or cloning** — a sandbox in `cloning_repo` or with `sandbox_disconnected_at` set must never be paused (state must be `ready`, socket attached). Test owned by Task 6. +2. **Agent Request arriving while a resume is already in flight** — two triggers (prompt + preview) must share one resume, and the queued run must start after the agent reconnects, not be dropped or doubled. Test owned by Task 7. +3. **Resumed agent reconnect rejected as "not expected"** — after pause the DO must close its own sandbox sockets so `sandboxConnectionMode` sees `attachedSandboxCount === 0` and returns `"resume"`, not `"reject"` and not `"initialize"` (which would re-clone). Test owned by Task 7. +4. **Session ends while paused** — `max_time`, user stop, and failure must destroy a paused E2B sandbox (paused sandboxes never expire). Test owned by Task 7. +5. **Secrets leaking through E2B errors or diagnostics** — E2B SDK error messages, the traffic token, and `E2B_API_KEY` must pass through redaction. Test owned by Task 4. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `packages/worker/src/sandbox-provider/types.ts` (create) | `SandboxProvider`, `SandboxHandle`, `SandboxRef`, `ShellResult`, `SandboxNotFoundError`, `SandboxProviderName`, `parseSandboxProviderName`. | +| `packages/worker/src/sandbox-provider/cloudflare.ts` (create) | Cloudflare adapter wrapping existing `sandbox.ts` helpers. | +| `packages/worker/src/sandbox-provider/e2b.ts` (create, Task 4) | E2B adapter; SDK injected for tests. | +| `packages/worker/src/sandbox-provider/index.ts` (create) | `configuredSandboxProviderName(env)`, `resolveSandboxProvider(env, name)`. | +| `packages/worker/src/sandbox.ts` (modify) | Keep helpers; `provisionSandbox` becomes provider-neutral `startAgentOnHandle`. | +| `packages/worker/src/orchestrator/sandbox-lifecycle.ts` (create, Task 6) | Pure helpers: idle deadline, pause decision, lease length. | +| `packages/worker/src/orchestrator/sandbox-resume.ts` (create, Task 7) | Pause/resume/renew/destroy orchestration against `OrchestratorHost`. | +| `packages/worker/src/orchestrator/{host,types,alarm,preview,sandbox-handlers,cli-handlers}.ts` (modify) | Use the interface; idle/lease deadlines. | +| `packages/worker/src/{orchestrator,http-handlers,session-service,worker-env}.ts` (modify) | Store provider/ref, route logs/diagnostics via DO, config. | +| `packages/shared/src/{session-meta-schema,room}.ts` (modify) | New meta fields; `paused` sandbox state. | +| `packages/sandbox-image/src/entrypoint.ts` (modify, Task 5) | Reread WS token file on reconnect. | +| `Dockerfile.sandbox`, `packages/sandbox-image/scripts/e2b-template.mjs`, `packages/sandbox-image/package.json` (modify/create, Task 8) | Shared image for both providers; E2B template publish. | + +--- + +### Task 1: Provider interface and Cloudflare adapter (pure refactor) + +**Files:** +- Create: `packages/worker/src/sandbox-provider/types.ts`, `packages/worker/src/sandbox-provider/cloudflare.ts`, `packages/worker/src/sandbox-provider/index.ts` +- Modify: `packages/worker/src/sandbox.ts`, `packages/worker/src/orchestrator/sandbox-handlers.ts`, `packages/worker/src/orchestrator.ts`, `packages/worker/src/orchestrator/preview.ts`, `packages/worker/src/orchestrator/host.ts`, `packages/worker/src/orchestrator/types.ts`, `packages/worker/src/http-handlers.ts`, `packages/worker/src/http-router.ts`, `packages/worker/src/session-service.ts`, `packages/worker/src/workspace-cache.ts`, `packages/shared/src/session-meta-schema.ts`, `packages/worker/test/helpers/fake-host.mjs` +- Test: `packages/worker/test/sandbox-provider-cloudflare.test.mjs` (create), plus all existing worker tests + +**Interfaces:** +- Produces (used by every later task): + +```ts +// packages/worker/src/sandbox-provider/types.ts +export type SandboxProviderName = "e2b" | "cloudflare"; +export const SANDBOX_PROVIDER_NAMES: readonly SandboxProviderName[] = ["e2b", "cloudflare"]; + +export interface SandboxRef { provider: SandboxProviderName; id: string } +export interface ShellResult { stdout: string; stderr: string; exitCode: number } +export interface SandboxLifecycleView { + keepAlive?: { active: boolean; reason?: string; updated_at?: string }; + lastEvent?: { type: string; at: string; exit_code?: number; reason?: string; error?: string }; +} + +export interface SandboxProvider { + readonly name: SandboxProviderName; + readonly capabilities: { pauseResume: boolean; workspaceCache: boolean }; + create(options: { sessionId: string; leaseMs: number }): Promise; + /** `secret` is the opaque value returned as `handle.secret` at create time. */ + connect(ref: SandboxRef, options?: { leaseMs?: number; secret?: string }): Promise; +} + +export interface SandboxHandle { + readonly ref: SandboxRef; + /** Provider-specific secret the Orchestrator must persist and redact (E2B traffic token). */ + readonly secret?: string; + exec(command: string, options?: { cwd?: string; env?: Record; timeoutMs?: number; user?: "root" | "codevil" }): Promise; + writeFile(path: string, content: string, options?: { mode?: number; owner?: "root" | "codevil" }): Promise; + startProcess(command: string, options: { processId: string; cwd: string; env: Record }): Promise; + readProcessLogs(processId: string): Promise<{ stdout: string; stderr: string }>; + readLifecycle?(): Promise; + fetchPort(port: number, request: Request): Promise; + renewLease(ms: number): Promise; + pause?(): Promise; + destroy(reason: string): Promise; + /** Present only when provider.capabilities.workspaceCache is true. */ + readonly workspaceCache?: import("../workspace-cache.js").WorkspaceCacheSandbox; +} + +export class SandboxNotFoundError extends Error { + constructor(message = "Sandbox not found") { super(message); this.name = "SandboxNotFoundError"; } +} + +export function parseSandboxProviderName(value: unknown): SandboxProviderName | undefined { + return typeof value === "string" && (SANDBOX_PROVIDER_NAMES as readonly string[]).includes(value) + ? value as SandboxProviderName + : undefined; +} +``` + +```ts +// packages/worker/src/sandbox-provider/index.ts +export function configuredSandboxProviderName(env: Pick): SandboxProviderName; // Task 1: always "cloudflare" unless env says "cloudflare"; unknown → throws Error("Unsupported SANDBOX_PROVIDER") +export function resolveSandboxProvider(env: Env, name: SandboxProviderName): SandboxProvider; // Task 1: "e2b" → throws Error("E2B sandbox provider is not available") +export function sandboxProviderForMeta(env: Env, meta: { sandbox_provider?: string }): SandboxProvider; // missing → cloudflare +``` + +- `SessionMeta` gains (shared schema, all optional): `sandbox_provider: z.enum(["e2b","cloudflare"]).optional()`, `sandbox_ref: z.object({ provider: z.enum(["e2b","cloudflare"]), id: z.string() }).optional()`. +- `InitOptions` gains `sandbox_provider?: "e2b" | "cloudflare"`. +- `OrchestratorHost` gains `sandboxProvider(): SandboxProvider` and `sandboxHandle(): Promise` (null when `meta.sandbox_ref` is unset). +- Orchestrator gains RPC methods `readSandboxLogs(): Promise` and `readSandboxDiagnosticsResponse(): Promise` used by `handleLogs` / `handleDiagnostics`. +- `Env` gains `SANDBOX_PROVIDER?: string`. + +Cloudflare adapter behavior (must match today exactly): + +| Method | Implementation | +|---|---| +| `create({ sessionId })` | `getCodevilSandbox(getSandbox, env.Sandbox, sessionId)`; then `retrySandboxOperation(() => setCodevilSandboxKeepAlive(sandbox, true, "session provisioning"))`. `ref = { provider: "cloudflare", id: sessionId }`. | +| `connect(ref)` | `getCodevilSandbox(getSandbox, env.Sandbox, ref.id)` (no keepalive change). | +| `startProcess` | `retrySandboxOperation(() => sandbox.startProcess(command, { cwd, env, processId, autoCleanup: true }))`. | +| `readProcessLogs` | `sandbox.getProcessLogs(processId)`. | +| `readLifecycle` | `sandbox.getCodevilLifecycleSnapshot?.() ?? null`. | +| `fetchPort(port, request)` | Clone headers, set `cf-container-target-port: String(port)`, return `sandbox.fetch(new Request(request, { headers }))`. | +| `exec` | `sandbox.exec(command, { cwd, env, timeout: timeoutMs })` mapped to `{ stdout, stderr, exitCode }`. | +| `writeFile` | `sandbox.writeFile(path, content)` then `exec("chmod && chown : ")` when options given (owner `codevil` → `10001:10001`, `root` → `0:0`). | +| `renewLease` | no-op. | +| `pause` | absent. | +| `destroy(reason)` | `setCodevilSandboxKeepAlive(sandbox, false, reason)` then `sandbox.stop()`. | +| `workspaceCache` | the sandbox itself (it already satisfies `WorkspaceCacheSandbox`). | +| `capabilities` | `{ pauseResume: false, workspaceCache: true }`. | + +Call-site rewiring: +- `provisionSessionSandbox` (`orchestrator/sandbox-handlers.ts:47`): `const provider = host.sandboxProvider()`; `const handle = await provider.create({ sessionId, leaseMs })` where `leaseMs = parseMaxTimeMs(meta.max_time) ?? 3_600_000`; store `meta.sandbox_ref = handle.ref` and `saveMeta()`; if `handle.secret`, `await host.ctx.storage.put("codevil:sandbox_secret", handle.secret)`; if `provider.capabilities.workspaceCache && handle.workspaceCache`, run the existing restore; then `await startAgentOnHandle(handle, envOptions)`. +- `sandbox.ts`: replace `provisionSandbox`/`provisionSandboxOnInstance` with `startAgentOnHandle(handle: SandboxHandle, options: SandboxProcessEnvOptions): Promise` that calls `handle.startProcess(AGENT_COMMAND, { processId: "codevil-agent", cwd: "/workspace", env: sandboxProcessEnv(options) })`. Export `AGENT_COMMAND` (today's exact string). Update `test/sandbox.test.mjs` imports that referenced `provisionSandboxOnInstance` to cover `startAgentOnHandle` with a fake handle asserting the same command, cwd, env, processId. +- `terminateSandbox` (`orchestrator.ts:754`): `const handle = await this.sandboxHandle(); await handle?.destroy(reason)` inside the existing try/catch. +- `readProcessLogs` in the provisioning-timeout path (`orchestrator.ts:~325`): use `handle.readProcessLogs("codevil-agent")` with redaction; null on error. +- `logSandboxDisconnectDiagnostics`: build a reader `{ getProcessLogs: (id) => handle.readProcessLogs(id), getCodevilLifecycleSnapshot: handle.readLifecycle ? () => handle.readLifecycle() : undefined }` and call `collectSandboxDiagnostics`. +- `proxyPreviewRequest` (`orchestrator/preview.ts`): signature becomes `(request, meta, token, handle: SandboxHandle | null)`; return `404 "Preview is not active."` when handle is null; replace `portedHeaders.set("cf-container-target-port", …)` + `sandbox.fetch` with `handle.fetchPort(previewPort, portedRequest)` inside `fetchPreviewWithRetries` (change its first parameter to `fetchOnce: (request: Request) => Promise`). +- `handleLogs` / `handleDiagnostics` (`http-handlers.ts:723-750`): call `env.ORCHESTRATOR.get(env.ORCHESTRATOR.idFromName(sessionId)).readSandboxLogs()` / `.readSandboxDiagnosticsResponse()`; the DO methods return the same JSON bodies and status codes as today (`{ error: "Failed to read sandbox logs" }` 500 on failure). +- Workspace cache job: `createWorkspaceCacheSnapshotForSandbox` takes the `WorkspaceCacheSandbox` from `(await host.sandboxHandle())?.workspaceCache`; when absent, the job is marked done without a snapshot. `enqueueWorkspaceCacheJob` call at `sandbox-handlers.ts:338` is guarded by `host.sandboxProvider().capabilities.workspaceCache`. +- Session creation (`session-service.ts` and `http-handlers.ts` `stub.init` calls): pass `sandbox_provider: configuredSandboxProviderName(env)`; `Orchestrator.init` stores it on meta. + +- [ ] **Step 1: Write the failing adapter test** — `packages/worker/test/sandbox-provider-cloudflare.test.mjs`: + +```js +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createCloudflareSandboxProvider } from "../dist/sandbox-provider/cloudflare.js"; +import { parseSandboxProviderName, SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; +import { sandboxProviderForMeta, configuredSandboxProviderName } from "../dist/sandbox-provider/index.js"; + +function fakeCloudflareSandbox() { + const calls = []; + return { + calls, + setKeepAlive: async (active) => { calls.push(["setKeepAlive", active]); }, + setCodevilKeepAlive: async (active, reason) => { calls.push(["setCodevilKeepAlive", active, reason]); }, + startProcess: async (command, options) => { calls.push(["startProcess", command, options]); }, + getProcessLogs: async (id) => ({ stdout: `out:${id}`, stderr: "" }), + getCodevilLifecycleSnapshot: async () => ({ lastEvent: { type: "start", at: "t" } }), + fetch: async (request) => { calls.push(["fetch", request.headers.get("cf-container-target-port")]); return new Response("ok"); }, + stop: async () => { calls.push(["stop"]); }, + }; +} + +function providerWith(sandbox) { + const seen = []; + const provider = createCloudflareSandboxProvider({ + binding: {}, + getSandbox: (_binding, id, options) => { seen.push([id, options]); return sandbox; }, + }); + return { provider, seen }; +} + +test("cloudflare create enables keepalive and uses the session id as the ref", async () => { + const sandbox = fakeCloudflareSandbox(); + const { provider, seen } = providerWith(sandbox); + const handle = await provider.create({ sessionId: "ses_1", leaseMs: 60_000 }); + assert.deepEqual(handle.ref, { provider: "cloudflare", id: "ses_1" }); + assert.deepEqual(seen, [["ses_1", { keepAlive: true }]]); + assert.deepEqual(sandbox.calls, [["setKeepAlive", true], ["setCodevilKeepAlive", true, "session provisioning"]]); + assert.deepEqual(provider.capabilities, { pauseResume: false, workspaceCache: true }); + assert.equal(handle.pause, undefined); + assert.equal(handle.workspaceCache, sandbox); +}); + +test("cloudflare startProcess keeps processId and autoCleanup", async () => { + const sandbox = fakeCloudflareSandbox(); + const { provider } = providerWith(sandbox); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + await handle.startProcess("node x", { processId: "codevil-agent", cwd: "/workspace", env: { A: "1" } }); + assert.deepEqual(sandbox.calls, [["startProcess", "node x", { cwd: "/workspace", env: { A: "1" }, processId: "codevil-agent", autoCleanup: true }]]); +}); + +test("cloudflare fetchPort routes by the container target-port header", async () => { + const sandbox = fakeCloudflareSandbox(); + const { provider } = providerWith(sandbox); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + const response = await handle.fetchPort(5173, new Request("http://localhost/")); + assert.equal(await response.text(), "ok"); + assert.deepEqual(sandbox.calls, [["fetch", "5173"]]); +}); + +test("cloudflare destroy clears keepalive before stopping", async () => { + const sandbox = fakeCloudflareSandbox(); + const { provider } = providerWith(sandbox); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + await handle.destroy("stopped by user"); + assert.deepEqual(sandbox.calls, [["setKeepAlive", false], ["setCodevilKeepAlive", false, "stopped by user"], ["stop"]]); +}); + +test("cloudflare readProcessLogs and readLifecycle pass through", async () => { + const { provider } = providerWith(fakeCloudflareSandbox()); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + assert.deepEqual(await handle.readProcessLogs("codevil-agent"), { stdout: "out:codevil-agent", stderr: "" }); + assert.deepEqual(await handle.readLifecycle(), { lastEvent: { type: "start", at: "t" } }); +}); + +test("provider name parsing and legacy meta default", () => { + assert.equal(parseSandboxProviderName("e2b"), "e2b"); + assert.equal(parseSandboxProviderName("modal"), undefined); + assert.equal(new SandboxNotFoundError().name, "SandboxNotFoundError"); + assert.equal(configuredSandboxProviderName({}), "cloudflare"); + assert.throws(() => configuredSandboxProviderName({ SANDBOX_PROVIDER: "modal" }), /Unsupported SANDBOX_PROVIDER/); + const provider = sandboxProviderForMeta({ Sandbox: {} }, {}); + assert.equal(provider.name, "cloudflare"); +}); +``` + +`createCloudflareSandboxProvider({ binding, getSandbox })` takes `getSandbox` injected (default: dynamic `import("@cloudflare/sandbox")` inside `resolveSandboxProvider`), so tests never load the real SDK. + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @codevil/worker test` +Expected: FAIL — `Cannot find module '../dist/sandbox-provider/cloudflare.js'`. + +- [ ] **Step 3: Implement** `types.ts` (code above), `cloudflare.ts` per the behavior table, `index.ts`. Because `getSandbox` is async-imported in production, `resolveSandboxProvider` returns a provider whose methods `await import("@cloudflare/sandbox")` lazily when no `getSandbox` override is given: + +```ts +// packages/worker/src/sandbox-provider/index.ts +import type { Env } from "../worker-env.js"; +import { createCloudflareSandboxProvider } from "./cloudflare.js"; +import { parseSandboxProviderName, type SandboxProvider, type SandboxProviderName } from "./types.js"; + +export function configuredSandboxProviderName(env: { SANDBOX_PROVIDER?: string }): SandboxProviderName { + const raw = env.SANDBOX_PROVIDER?.trim(); + if (!raw) return "cloudflare"; + const name = parseSandboxProviderName(raw); + if (!name) throw new Error("Unsupported SANDBOX_PROVIDER"); + return name; +} + +export function resolveSandboxProvider(env: Env, name: SandboxProviderName): SandboxProvider { + if (name === "cloudflare") return createCloudflareSandboxProvider({ binding: env.Sandbox }); + throw new Error("E2B sandbox provider is not available"); +} + +export function sandboxProviderForMeta(env: Env, meta: { sandbox_provider?: string }): SandboxProvider { + return resolveSandboxProvider(env, parseSandboxProviderName(meta.sandbox_provider) ?? "cloudflare"); +} +``` + +- [ ] **Step 4: Rewire the call sites** listed above. Add `sandbox_provider` / `sandbox_ref` to `SessionMetaSchema` and `sandbox_provider` to `InitOptions`; `Orchestrator.init` sets `sandbox_provider: options.sandbox_provider ?? "cloudflare"`. Implement on `Orchestrator`: + +```ts +sandboxProvider(): SandboxProvider { + return sandboxProviderForMeta(this.workerEnv, this.meta ?? {}); +} + +async sandboxHandle(): Promise { + const ref = this.meta?.sandbox_ref + ?? (this.meta && this.sandboxProvider().name === "cloudflare" + ? { provider: "cloudflare" as const, id: this.meta.session_id } + : undefined); + if (!ref) return null; + const secret = await this.ctx.storage.get("codevil:sandbox_secret"); + return this.sandboxProvider().connect(ref, secret ? { secret } : undefined); +} +``` + +(The Cloudflare fallback keeps legacy Sessions without `sandbox_ref` working.) Add both to `OrchestratorHost` and to `createFakeHost` in `test/helpers/fake-host.mjs` (`options.sandboxHandle` and `options.sandboxProvider` overrides; defaults return a Cloudflare-like fake with `capabilities: { pauseResume: false, workspaceCache: true }`). + +- [ ] **Step 5: Run the whole worker suite and typecheck** + +Run: `pnpm --filter @codevil/worker typecheck && pnpm --filter @codevil/worker test && pnpm --filter @codevil/shared test` +Expected: all pass, including every pre-existing test with unchanged assertions (only import-path adjustments for removed `provisionSandboxOnInstance` are allowed). + +- [ ] **Step 6: Commit** + +```bash +git add packages/worker packages/shared +git commit -m "refactor(sandbox): route sandbox access through a provider interface" +``` + +--- + +### Task 2: E2B feasibility gate (throwaway probe) + +**Requires:** a real `E2B_API_KEY`. If it is not available in `packages/worker/.dev.vars` or the environment, STOP and report `BLOCKED: needs E2B_API_KEY`. Do not fake results. + +**Files:** +- Create (throwaway, never committed): `packages/worker/probe/e2b-probe.ts`, `packages/worker/probe/wrangler.probe.toml` +- Modify (committed): `docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md` — append a `## Feasibility Findings` section +- Modify (committed): `packages/worker/package.json` — add dependency `e2b` (latest 2.x; record exact version) + +**Interfaces:** +- Produces: written answers that Tasks 4 and 8 rely on: SDK runs in workerd (yes/no + any required import path or polyfill), WebSocket upgrade through `getHost(port)` with token works (yes/no), whether `Sandbox.connect` repopulates `trafficAccessToken`, Hobby 1-hour clock behavior after resume, template publish command. + +- [ ] **Step 1:** `pnpm --filter @codevil/worker add e2b@^2` +- [ ] **Step 2:** Write `probe/wrangler.probe.toml`: + +```toml +name = "codevil-e2b-probe" +main = "e2b-probe.ts" +compatibility_date = "2026-04-07" +compatibility_flags = ["nodejs_compat"] +``` + +- [ ] **Step 3:** Write `probe/e2b-probe.ts` — a Worker whose `fetch` runs, in order, and returns a JSON report of each step's result or error message: + 1. `Sandbox.create({ apiKey, timeoutMs: 300_000, secure: true, allowPublicTraffic: false })` (default `base` template). + 2. `commands.run("echo hi")` → expect stdout `hi\n`. + 3. `files.write("/tmp/x", "y")` then `commands.run("cat /tmp/x")`. + 4. `commands.run("python3 -m http.server 8000", { background: true })`, wait 2s, Worker `fetch("https://" + sandbox.getHost(8000) + "/", { headers: { "e2b-traffic-access-token": sandbox.trafficAccessToken } })` → expect 200; same without the header → expect 403. + 5. Start a WebSocket echo server: `commands.run("npx -y wscat --listen 8001", { background: true })` (or `python3 -c` with `websockets` if available); Worker `fetch(url, { headers: { Upgrade: "websocket", "e2b-traffic-access-token": token } })` → expect `response.status === 101` and `response.webSocket` non-null; send/receive one message. + 6. `sandbox.pause()`; then `Sandbox.connect(sandboxId, { apiKey, timeoutMs: 300_000 })`; record resume duration, whether `trafficAccessToken` is set on the connected instance, and whether the background server from step 4 still answers. + 7. `sandbox.setTimeout(120_000)`; `sandbox.kill()`; `Sandbox.connect(killedId)` → record the error class name (expect `NotFoundError`). +- [ ] **Step 4:** Run `pnpm --filter @codevil/worker exec wrangler dev -c probe/wrangler.probe.toml --var E2B_API_KEY:$E2B_API_KEY`, `curl localhost:8787`, save the JSON. +- [ ] **Step 5:** Hobby clock: check E2B docs/dashboard for the max-timeout rule on Hobby; if resume resets it, record "resets"; otherwise record the observed limit. +- [ ] **Step 6:** Template publishing: confirm which of `e2b template build` (CLI, Dockerfile) or the v2 Template SDK (`Template().fromDockerfile(...)` / `fromImage(...)`) accepts a multi-stage Dockerfile; record the exact command. +- [ ] **Step 7:** Append `## Feasibility Findings` to the spec with the JSON results, the `e2b` version, and the answers above. Delete `packages/worker/probe/`. +- [ ] **Step 8:** Decision. If step 1–3 failed in workerd: commit only the findings, report `GATE FAILED`, and stop the plan. Otherwise commit: + +```bash +git add docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md packages/worker/package.json pnpm-lock.yaml +git commit -m "docs(sandbox): record E2B feasibility findings" +``` + +--- + +### Task 3: Shared schema and config plumbing for idle pause + +**Files:** +- Modify: `packages/shared/src/room.ts` (`SandboxStateSchema` add `"paused"`), `packages/shared/src/session-meta-schema.ts`, `packages/worker/src/orchestrator/types.ts` (`InitOptions.max_idle_time?: string`), `packages/worker/src/orchestrator.ts` (`init`), `packages/worker/src/session-service.ts`, `packages/worker/src/http-handlers.ts` (pass `max_idle_time: normalized.max_idle_time`) +- Modify: web labels if a `SandboxState` → label map exists (`grep -rn "timed_out" packages/web/src` and add `paused: "Paused"` wherever sandbox states are mapped) +- Test: `packages/shared/test/session.test.mjs` or a new `packages/shared/test/session-meta-schema.test.mjs`, `packages/worker/test/session-service.test.mjs` + +**Interfaces:** +- Produces on `SessionMeta` (all optional): `max_idle_time: z.string()`, `last_activity_at: z.string()`, `sandbox_paused_at: z.string()`, `sandbox_lease_renewed_at: z.string()`. +- `Orchestrator.init` sets `max_idle_time: options.max_idle_time ?? "10m"`, `last_activity_at: created_at`. + +- [ ] **Step 1: Failing tests** + +```js +// packages/shared/test/session-meta-schema.test.mjs +import assert from "node:assert/strict"; +import test from "node:test"; +import { SessionMetaSchema, SandboxStateSchema } from "../dist/index.js"; + +test("session meta accepts idle-pause and provider fields", () => { + const parsed = SessionMetaSchema.parse({ + session_id: "ses_1", prompt: "", repo: "r", worker_url: "https://w", provider: "openai", + plan_model: "m", exec_model: "m", max_time: "30m", state: "ready", refinement_round: 0, + verification_attempts: 0, cost_total_usd: 0, created_at: "2026-10-01T00:00:00.000Z", + sandbox_provider: "e2b", sandbox_ref: { provider: "e2b", id: "sbx_1" }, + max_idle_time: "10m", last_activity_at: "2026-10-01T00:00:00.000Z", + sandbox_paused_at: "2026-10-01T00:10:00.000Z", sandbox_lease_renewed_at: "2026-10-01T00:05:00.000Z", + }); + assert.equal(parsed.sandbox_ref.id, "sbx_1"); + assert.equal(parsed.max_idle_time, "10m"); +}); + +test("sandbox state includes paused", () => { + assert.equal(SandboxStateSchema.parse("paused"), "paused"); +}); +``` + +Add to `session-service.test.mjs` an assertion that the `stub.init` options include `max_idle_time` (default `"10m"`) and `sandbox_provider`. + +- [ ] **Step 2:** Run `pnpm --filter @codevil/shared test && pnpm --filter @codevil/worker test` → FAIL on the new assertions. +- [ ] **Step 3:** Implement the schema fields, `InitOptions.max_idle_time`, the two `stub.init` call sites, and `init` defaults. Check `SessionMetaSchema` is exported from shared's index (it is used by the worker; export it if not). +- [ ] **Step 4:** Rerun → PASS. Run `pnpm --filter @codevil/web test` too if web labels changed. +- [ ] **Step 5: Commit** — `git commit -m "feat(sandbox): add idle-pause session fields and paused sandbox state"` + +--- + +### Task 4: E2B adapter, configuration, default provider + +**Precondition:** Task 2 passed. Read its `## Feasibility Findings` first; if a finding contradicts this task (for example the traffic token is not repopulated on `connect`, or WebSockets need a different call), follow the finding and note the deviation in the commit message. + +**Files:** +- Create: `packages/worker/src/sandbox-provider/e2b.ts` +- Modify: `packages/worker/src/sandbox-provider/index.ts`, `packages/worker/src/worker-env.ts`, `packages/worker/src/orchestrator.ts` (redaction secrets include the stored sandbox secret), `packages/worker/.dev.vars.example`, `packages/worker/wrangler.operator.example.toml`, `packages/worker/wrangler.toml` (`[vars] SANDBOX_PROVIDER = "e2b"`, `E2B_TEMPLATE_ID = "codevil-sandbox"`) +- Test: `packages/worker/test/sandbox-provider-e2b.test.mjs` (create), `packages/worker/test/redaction.test.mjs` or `logging-redaction.test.mjs` (extend) + +**Interfaces:** +- Consumes: Task 1 types. +- Produces: + +```ts +export interface E2BSdkSandbox { + sandboxId: string; + trafficAccessToken?: string; + getHost(port: number): string; + commands: { run(cmd: string, opts?: { background?: boolean; user?: string; cwd?: string; envs?: Record; timeoutMs?: number }): Promise<{ stdout: string; stderr: string; exitCode: number }> }; + files: { write(path: string, content: string, opts?: { user?: string }): Promise }; + setTimeout(ms: number): Promise; + pause(): Promise; + kill(): Promise; +} +export interface E2BSdk { + create(template: string, opts: { apiKey: string; timeoutMs: number; secure: true; allowPublicTraffic: false; metadata: Record }): Promise; + connect(sandboxId: string, opts: { apiKey: string; timeoutMs?: number }): Promise; + isNotFound(error: unknown): boolean; +} +export function createE2BSandboxProvider(options: { + apiKey: string; templateId: string; maxLeaseMs: number; sdk?: E2BSdk; fetch?: typeof fetch; +}): SandboxProvider; +export const E2B_TRAFFIC_TOKEN_HEADER = "e2b-traffic-access-token"; +``` + +- `Env` gains `E2B_API_KEY?: string; E2B_TEMPLATE_ID?: string; E2B_MAX_SANDBOX_SECONDS?: string`. `WorkerSecretEnv` gains `E2B_API_KEY` and `collectWorkerSecretValues` includes it. +- `configuredSandboxProviderName` default flips to `"e2b"`. +- `resolveSandboxProvider(env, "e2b")` throws `Error("E2B_API_KEY is not configured")` when the key is missing; otherwise builds the provider with `templateId = env.E2B_TEMPLATE_ID || "codevil-sandbox"`, `maxLeaseMs = (Number(env.E2B_MAX_SANDBOX_SECONDS) || 3600) * 1000`. + +Adapter behavior: + +| Method | Implementation | +|---|---| +| `create({ sessionId, leaseMs })` | `sdk.create(templateId, { apiKey, timeoutMs: min(leaseMs, maxLeaseMs), secure: true, allowPublicTraffic: false, metadata: { codevil_session_id: sessionId } })`; `ref = { provider: "e2b", id: sandbox.sandboxId }`; `secret = sandbox.trafficAccessToken`. | +| `connect(ref, { leaseMs, secret })` | `sdk.connect(ref.id, { apiKey, timeoutMs: leaseMs && min(leaseMs, maxLeaseMs) })`; `secret` = argument ?? `sandbox.trafficAccessToken`. Not-found → `SandboxNotFoundError`. | +| `exec` | `commands.run(command, { cwd, envs: env, timeoutMs, user: user === "codevil" ? "codevil" : "root" })`; a thrown `CommandExitError` with `exitCode` maps to a `ShellResult` instead of throwing. | +| `writeFile(path, content, { mode, owner })` | `files.write(path, content, { user: "root" })`, then `exec("mkdir -p … ; chmod '' && chown <10001:10001|0:0> ''")`. Use `mode.toString(8)`. Shell-quote paths with single quotes. | +| `startProcess(command, { processId, cwd, env })` | `exec("mkdir -p /var/log/codevil")`; `commands.run(\`${command} >/var/log/codevil/${processId}.out 2>/var/log/codevil/${processId}.err\`, { background: true, user: "root", cwd, envs: env })`. Wrap the command as `sh -c ''` so the redirection applies to the whole command. Validate `processId` with `/^[a-z0-9-]+$/`. | +| `readProcessLogs(id)` | two `exec("tail -c 65536 /var/log/codevil/.out 2>/dev/null || true")` calls (`.out`, `.err`). | +| `readLifecycle` | absent. | +| `fetchPort(port, request)` | `url = new URL(request.url)`; target `https://${sandbox.getHost(port)}${url.pathname}${url.search}`; copy headers, set `e2b-traffic-access-token: secret`, delete `host`; `return fetchImpl(new Request(target, { method, headers, body, redirect: "manual" }))`. WebSocket upgrades pass through because `fetch` with `Upgrade: websocket` returns the 101 response with `.webSocket`. | +| `renewLease(ms)` | `sandbox.setTimeout(min(ms, maxLeaseMs))`. | +| `pause()` | `sandbox.pause()`. | +| `destroy()` | `sandbox.kill()`; not-found is success. | +| `capabilities` | `{ pauseResume: true, workspaceCache: false }`. | + +The default `sdk` lazily imports `e2b` (`const { Sandbox, NotFoundError } = await import("e2b")`) so tests and Cloudflare-only paths never load it. + +Orchestrator: when the stored `codevil:sandbox_secret` exists, add it to `this.redactionSecrets` (load it in the constructor's `blockConcurrencyWhile` or lazily in `sandboxHandle()` and push into a mutable secrets array). + +- [ ] **Step 1: Failing tests** — `packages/worker/test/sandbox-provider-e2b.test.mjs`: + +```js +import assert from "node:assert/strict"; +import test from "node:test"; +import { createE2BSandboxProvider, E2B_TRAFFIC_TOKEN_HEADER } from "../dist/sandbox-provider/e2b.js"; +import { SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; +import { collectWorkerSecretValues } from "../dist/worker-env.js"; +import { configuredSandboxProviderName, resolveSandboxProvider } from "../dist/sandbox-provider/index.js"; + +class NotFound extends Error {} + +function fakeSdk() { + const log = []; + const files = new Map(); + const sandbox = { + sandboxId: "sbx_1", + trafficAccessToken: "tat_secret", + getHost: (port) => `${port}-sbx_1.e2b.app`, + commands: { + run: async (cmd, opts) => { + log.push(["run", cmd, opts]); + if (cmd.startsWith("tail -c 65536 /var/log/codevil/codevil-agent.out")) return { stdout: "agent out", stderr: "", exitCode: 0 }; + if (cmd.startsWith("tail -c 65536 /var/log/codevil/codevil-agent.err")) return { stdout: "agent err", stderr: "", exitCode: 0 }; + return { stdout: "", stderr: "", exitCode: 0 }; + }, + }, + files: { write: async (path, content, opts) => { files.set(path, content); log.push(["write", path, opts]); } }, + setTimeout: async (ms) => { log.push(["setTimeout", ms]); }, + pause: async () => { log.push(["pause"]); }, + kill: async () => { log.push(["kill"]); }, + }; + const sdk = { + create: async (template, opts) => { log.push(["create", template, opts]); return sandbox; }, + connect: async (id, opts) => { + log.push(["connect", id, opts]); + if (id === "gone") throw new NotFound("sandbox gone"); + return sandbox; + }, + isNotFound: (error) => error instanceof NotFound, + }; + return { sdk, log, files }; +} + +function provider(overrides = {}) { + const fake = fakeSdk(); + const fetchCalls = []; + const p = createE2BSandboxProvider({ + apiKey: "e2b_key", templateId: "codevil-sandbox", maxLeaseMs: 3_600_000, sdk: fake.sdk, + fetch: async (request) => { fetchCalls.push(request); return new Response("preview"); }, + ...overrides, + }); + return { p, fake, fetchCalls }; +} + +test("create locks public traffic, caps the lease, and returns the traffic token as the secret", async () => { + const { p, fake } = provider(); + const handle = await p.create({ sessionId: "ses_1", leaseMs: 7_200_000 }); + assert.deepEqual(handle.ref, { provider: "e2b", id: "sbx_1" }); + assert.equal(handle.secret, "tat_secret"); + assert.deepEqual(fake.log[0], ["create", "codevil-sandbox", { + apiKey: "e2b_key", timeoutMs: 3_600_000, secure: true, allowPublicTraffic: false, + metadata: { codevil_session_id: "ses_1" }, + }]); + assert.deepEqual(p.capabilities, { pauseResume: true, workspaceCache: false }); +}); + +test("connect maps not-found to SandboxNotFoundError", async () => { + const { p } = provider(); + await assert.rejects(p.connect({ provider: "e2b", id: "gone" }), SandboxNotFoundError); +}); + +test("fetchPort targets the E2B host with the traffic token and keeps path and query", async () => { + const { p, fetchCalls } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }, { secret: "tat_secret" }); + await handle.fetchPort(5173, new Request("http://localhost/src/main.ts?t=1", { headers: { host: "localhost:5173", upgrade: "websocket" } })); + assert.equal(fetchCalls[0].url, "https://5173-sbx_1.e2b.app/src/main.ts?t=1"); + assert.equal(fetchCalls[0].headers.get(E2B_TRAFFIC_TOKEN_HEADER), "tat_secret"); + assert.equal(fetchCalls[0].headers.get("upgrade"), "websocket"); +}); + +test("startProcess redirects output to per-process log files and readProcessLogs tails them", async () => { + const { p, fake } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await handle.startProcess("node /app/x.js", { processId: "codevil-agent", cwd: "/workspace", env: { A: "1" } }); + const background = fake.log.find(([kind, , opts]) => kind === "run" && opts?.background); + assert.match(background[1], />\/var\/log\/codevil\/codevil-agent\.out 2>\/var\/log\/codevil\/codevil-agent\.err/); + assert.deepEqual(background[2], { background: true, user: "root", cwd: "/workspace", envs: { A: "1" } }); + assert.deepEqual(await handle.readProcessLogs("codevil-agent"), { stdout: "agent out", stderr: "agent err" }); + await assert.rejects(handle.startProcess("x", { processId: "../etc", cwd: "/", env: {} })); +}); + +test("writeFile sets mode and owner", async () => { + const { p, fake } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await handle.writeFile("/run/codevil/ws-token", "tok", { mode: 0o600, owner: "codevil" }); + assert.equal(fake.files.get("/run/codevil/ws-token"), "tok"); + const chmod = fake.log.find(([kind, cmd]) => kind === "run" && cmd.includes("chmod")); + assert.match(chmod[1], /chmod 600 '\/run\/codevil\/ws-token' && chown 10001:10001 '\/run\/codevil\/ws-token'/); +}); + +test("renewLease caps at the provider maximum; pause and destroy delegate", async () => { + const { p, fake } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await handle.renewLease(9_999_999); + await handle.pause(); + await handle.destroy("done"); + assert.deepEqual(fake.log.slice(-3), [["setTimeout", 3_600_000], ["pause"], ["kill"]]); +}); + +test("E2B key is a redacted worker secret and e2b is the default provider", () => { + assert.ok(collectWorkerSecretValues({ E2B_API_KEY: "e2b_key" }).includes("e2b_key")); + assert.equal(configuredSandboxProviderName({}), "e2b"); + assert.throws(() => resolveSandboxProvider({ Sandbox: {} }, "e2b"), /E2B_API_KEY is not configured/); +}); +``` + +Update the Task 1 test assertion `configuredSandboxProviderName({}) === "cloudflare"` to `"e2b"` in the same commit. + +- [ ] **Step 2:** Run `pnpm --filter @codevil/worker test` → FAIL (module missing). +- [ ] **Step 3:** Implement `e2b.ts`, config, redaction, and the `wrangler.toml` / example env updates. In `.dev.vars.example` add: + +``` +# Sandbox provider (e2b | cloudflare). E2B is the default. +# SANDBOX_PROVIDER=e2b +E2B_API_KEY= +# E2B_TEMPLATE_ID=codevil-sandbox +# E2B_MAX_SANDBOX_SECONDS=3600 +``` + +- [ ] **Step 4:** Add a redaction test: an `Error` whose message contains `tat_secret` and `e2b_key`, passed through the Orchestrator's error-logging path (`redactEvent(safeExceptionAttributes(error), secrets)` with secrets including both), contains neither string. +- [ ] **Step 5:** Run `pnpm --filter @codevil/worker typecheck && pnpm --filter @codevil/worker test` → PASS. +- [ ] **Step 6: Commit** — `git commit -m "feat(sandbox): add E2B sandbox provider and make it the default"` + +--- + +### Task 5: Agent rereads the WebSocket token file on reconnect + +**Files:** +- Modify: `packages/sandbox-image/src/entrypoint.ts` +- Test: `packages/sandbox-image/test/runtime.test.mjs` or new `packages/sandbox-image/test/ws-token-file.test.mjs` + +**Interfaces:** +- Produces: `export const SANDBOX_WS_TOKEN_FILE = "/run/codevil/ws-token"` and `export function currentSandboxWebSocketUrl(wsUrl: string, readToken: () => string | undefined): string` in `entrypoint.ts`. + +- [ ] **Step 1: Failing test** + +```js +import assert from "node:assert/strict"; +import test from "node:test"; +import { currentSandboxWebSocketUrl, SANDBOX_WS_TOKEN_FILE } from "../dist/entrypoint.js"; + +test("uses the token file when present", () => { + const url = currentSandboxWebSocketUrl("wss://w/sessions/ses_1/sandbox/ws?sandbox_ws_token=old", () => "fresh\n"); + assert.equal(new URL(url).searchParams.get("sandbox_ws_token"), "fresh"); +}); + +test("keeps the in-memory token when the file is missing or empty", () => { + const base = "wss://w/sessions/ses_1/sandbox/ws?sandbox_ws_token=old"; + assert.equal(currentSandboxWebSocketUrl(base, () => undefined), base); + assert.equal(currentSandboxWebSocketUrl(base, () => " "), base); +}); + +test("token file path is fixed", () => { + assert.equal(SANDBOX_WS_TOKEN_FILE, "/run/codevil/ws-token"); +}); +``` + +- [ ] **Step 2:** Run `pnpm --filter @codevil/sandbox-image test` → FAIL. +- [ ] **Step 3:** Implement: + +```ts +export const SANDBOX_WS_TOKEN_FILE = "/run/codevil/ws-token"; + +export function currentSandboxWebSocketUrl(wsUrl: string, readToken: () => string | undefined): string { + const token = readToken()?.trim(); + return token ? withSandboxWebSocketToken(wsUrl, token) : wsUrl; +} + +function readTokenFile(): string | undefined { + try { return readFileSync(SANDBOX_WS_TOKEN_FILE, "utf8"); } catch { return undefined; } +} +``` + +In `createSocket`, replace `new WebSocket(wsUrl)` with `wsUrl = currentSandboxWebSocketUrl(wsUrl, readTokenFile); return new WebSocket(wsUrl);` (keep the log line, logging `wsUrlForLog(wsUrl)`). Make sure importing `entrypoint.js` in tests has no side effects (it currently only exports; keep it that way). + +- [ ] **Step 4:** Rerun → PASS. +- [ ] **Step 5: Commit** — `git commit -m "feat(sandbox-image): reread sandbox WebSocket token file on reconnect"` + +--- + +### Task 6: Pure idle-pause and lease helpers + alarm deadlines + +**Files:** +- Create: `packages/worker/src/orchestrator/sandbox-lifecycle.ts` +- Modify: `packages/worker/src/orchestrator/alarm.ts` +- Test: `packages/worker/test/sandbox-lifecycle.test.mjs` (create), `packages/worker/test/orchestrator-alarm.test.mjs` (extend) + +**Interfaces:** +- Produces: + +```ts +export const SANDBOX_LEASE_RENEW_INTERVAL_MS = 5 * 60_000; +export const DEFAULT_MAX_IDLE_MS = 10 * 60_000; + +export interface IdlePauseInput { + now: number; + pauseSupported: boolean; + sessionState: SessionState; + paused: boolean; + sandboxConnected: boolean; + disconnectedAt?: string; + hasActiveRun: boolean; + queuedRuns: number; + openQuestions: number; + lastActivityAt: string; + maxIdleMs: number | null; +} +export function idlePauseDeadline(input: Pick): number | null; +export function shouldPauseSandbox(input: IdlePauseInput): boolean; +export function sandboxLeaseMs(input: { now: number; createdAt: number; maxTimeMs: number | null; providerMaxMs: number }): number; +export function leaseRenewDeadline(input: { renewedAt?: string; createdAt: string }): number; +``` + +- `AlarmScheduleInput` gains `idlePauseAt?: number | null` and `leaseRenewAt?: number | null`; `nextAlarmDeadline` includes them when non-null and the state is not terminal. + +Rules: +- `shouldPauseSandbox` is true only when `pauseSupported && !paused && sessionState === "ready" && sandboxConnected && !disconnectedAt && !hasActiveRun && queuedRuns === 0 && openQuestions === 0 && maxIdleMs !== null && now >= idlePauseDeadline(...)`. +- `idlePauseDeadline` = `Date.parse(lastActivityAt) + maxIdleMs`, or null when `maxIdleMs` is null. +- `sandboxLeaseMs` = `min(providerMaxMs, maxTimeMs === null ? providerMaxMs : max(60_000, createdAt + maxTimeMs - now))`. +- `leaseRenewDeadline` = `Date.parse(renewedAt ?? createdAt) + SANDBOX_LEASE_RENEW_INTERVAL_MS`. + +- [ ] **Step 1: Failing tests** + +```js +import assert from "node:assert/strict"; +import test from "node:test"; +import { + shouldPauseSandbox, idlePauseDeadline, sandboxLeaseMs, leaseRenewDeadline, SANDBOX_LEASE_RENEW_INTERVAL_MS, +} from "../dist/orchestrator/sandbox-lifecycle.js"; +import { nextAlarmDeadline } from "../dist/orchestrator/alarm.js"; + +const T0 = Date.parse("2026-10-01T00:00:00.000Z"); +const idle = (overrides = {}) => ({ + now: T0 + 10 * 60_000, pauseSupported: true, sessionState: "ready", paused: false, sandboxConnected: true, + hasActiveRun: false, queuedRuns: 0, openQuestions: 0, lastActivityAt: "2026-10-01T00:00:00.000Z", maxIdleMs: 600_000, + ...overrides, +}); + +test("pauses an idle, connected, ready sandbox at the idle deadline", () => { + assert.equal(shouldPauseSandbox(idle()), true); + assert.equal(shouldPauseSandbox(idle({ now: T0 + 599_999 })), false); +}); + +for (const [name, overrides] of [ + ["provider without pause", { pauseSupported: false }], + ["already paused", { paused: true }], + ["cloning", { sessionState: "cloning_repo" }], + ["socket not attached", { sandboxConnected: false }], + ["mid-reconnect", { disconnectedAt: "2026-10-01T00:09:00.000Z" }], + ["active run", { hasActiveRun: true }], + ["queued run", { queuedRuns: 1 }], + ["open question", { openQuestions: 1 }], + ["idle disabled", { maxIdleMs: null }], +]) { + test(`never pauses when ${name}`, () => { + assert.equal(shouldPauseSandbox(idle(overrides)), false); + }); +} + +test("idle deadline follows the last activity", () => { + assert.equal(idlePauseDeadline({ lastActivityAt: "2026-10-01T00:00:00.000Z", maxIdleMs: 600_000 }), T0 + 600_000); + assert.equal(idlePauseDeadline({ lastActivityAt: "2026-10-01T00:00:00.000Z", maxIdleMs: null }), null); +}); + +test("lease is the shorter of remaining session time and the provider cap, at least one minute", () => { + assert.equal(sandboxLeaseMs({ now: T0, createdAt: T0, maxTimeMs: 15 * 60_000, providerMaxMs: 3_600_000 }), 15 * 60_000); + assert.equal(sandboxLeaseMs({ now: T0, createdAt: T0, maxTimeMs: 4 * 3_600_000, providerMaxMs: 3_600_000 }), 3_600_000); + assert.equal(sandboxLeaseMs({ now: T0 + 15 * 60_000, createdAt: T0, maxTimeMs: 15 * 60_000, providerMaxMs: 3_600_000 }), 60_000); + assert.equal(sandboxLeaseMs({ now: T0, createdAt: T0, maxTimeMs: null, providerMaxMs: 3_600_000 }), 3_600_000); +}); + +test("lease renewal deadline", () => { + assert.equal(leaseRenewDeadline({ createdAt: "2026-10-01T00:00:00.000Z" }), T0 + SANDBOX_LEASE_RENEW_INTERVAL_MS); +}); + +test("alarm includes idle pause and lease renewal deadlines", () => { + const base = { now: T0 + 120_000, state: "ready", createdAt: T0, maxTimeMs: null }; + assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: T0 + 200_000, leaseRenewAt: T0 + 300_000 }), T0 + 200_000); + assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: null, leaseRenewAt: T0 + 300_000 }), T0 + 300_000); + assert.equal(nextAlarmDeadline({ ...base, state: "failed", idlePauseAt: T0 + 200_000 }), undefined); +}); +``` + +- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement. **Step 4:** Run `pnpm --filter @codevil/worker test` → PASS (existing alarm tests unchanged). +- [ ] **Step 5: Commit** — `git commit -m "feat(sandbox): add idle-pause and lease scheduling helpers"` + +--- + +### Task 7: Orchestrator pause, resume, lease renewal, and teardown + +**Files:** +- Create: `packages/worker/src/orchestrator/sandbox-resume.ts` +- Modify: `packages/worker/src/orchestrator.ts` (alarm, `armNextAlarm`, `fetchPreview`, `submitAgentRequest`, `terminateSandbox`, `handleStopSession`), `packages/worker/src/orchestrator/cli-handlers.ts` (`handleAgentRequest`), `packages/worker/src/orchestrator/host.ts`, `packages/worker/src/orchestrator/sandbox-handlers.ts` (provisioning uses `sandboxLeaseMs`; agent run finish records activity), `packages/worker/test/helpers/fake-host.mjs` +- Test: `packages/worker/test/sandbox-resume.test.mjs` (create) + +**Interfaces:** +- Consumes: Task 1 `SandboxHandle`/`SandboxProvider`/`SandboxNotFoundError`; Task 3 meta fields; Task 6 helpers; Task 4 lease cap (`E2B_MAX_SANDBOX_SECONDS`) via a new `sandboxProviderMaxLeaseMs(env): number` exported from `sandbox-provider/index.ts` (Cloudflare: `Number.POSITIVE_INFINITY` clamp is not used because Cloudflare `renewLease` is a no-op; return `3_600_000`). +- Produces on `OrchestratorHost`: `recordActivity(): void`, `requestSandboxResume(): void`, `closeSandboxSockets(reason: string): void` (make the existing private method public), `issueSandboxWebSocketToken(): Promise` (wrap the existing `issueSandboxWebSocketCapability(host)`). +- Produces in `sandbox-resume.ts`: + +```ts +export const PREVIEW_RESUME_TIMEOUT_MS = 15_000; +export const RESUME_ATTEMPTS = 3; +export async function pauseIdleSandbox(host: OrchestratorHost, now: number): Promise; +export function resumeSandbox(host: OrchestratorHost): Promise; // shares one in-flight promise per host +export async function renewSandboxLeaseIfDue(host: OrchestratorHost, now: number): Promise; +export async function failLostSandbox(host: OrchestratorHost): Promise; // "Sandbox expired." +``` + +Behavior: +- **`pauseIdleSandbox`**: build `IdlePauseInput` from meta (`openQuestions` = count of open rows in `questions` for the active run — 0 when no active run; `sandboxConnected = host.ctx.getWebSockets("sandbox").length > 0`; `maxIdleMs = parseMaxTimeMs(meta.max_idle_time ?? "10m")`). If `shouldPauseSandbox` → `meta.expected_close = true; saveMeta(); await handle.pause!(); meta.sandbox_paused_at = new Date(now).toISOString(); saveMeta(); host.closeSandboxSockets("sandbox paused"); host.updateDirectory({ sandbox_state: "paused" }); host.appendAndBroadcast({ type: "status", message: "Sandbox paused (idle)." })`. On pause error: `meta.expected_close = false; saveMeta();` log `sandbox.pause.failed` (redacted) and return false. `SandboxNotFoundError` → `failLostSandbox`. +- **`resumeSandbox`**: if `!meta.sandbox_paused_at` resolve immediately. Otherwise (single shared promise stored in a `WeakMap>`, cleared on settle): up to `RESUME_ATTEMPTS` tries of `provider.connect(ref, { leaseMs: sandboxLeaseMs(...), secret })`; then `await handle.writeFile("/run/codevil/ws-token", await host.issueSandboxWebSocketToken(), { mode: 0o600, owner: "codevil" })`; then `meta.sandbox_paused_at = undefined; meta.expected_close = false; meta.sandbox_lease_renewed_at = now; saveMeta(); updateDirectory({ sandbox_state: meta.state === "cloning_repo" ? "cloning" : "ready" }); appendAndBroadcast({ type: "status", message: "Sandbox resumed." })`. `SandboxNotFoundError` at any point → `failLostSandbox`. Exhausted retries → transition `failed`, cancel queued runs with `agent_run_failed` events (message `"Sandbox failed to resume."`), `updateDirectory({ room_state: "failed", sandbox_state: "failed" })`. +- **`renewSandboxLeaseIfDue`**: when not paused, ref set, state non-terminal, and `now >= leaseRenewDeadline(...)` → `handle.renewLease(sandboxLeaseMs(...))`, set `sandbox_lease_renewed_at`. Errors logged; `SandboxNotFoundError` → `failLostSandbox`. +- **`failLostSandbox`**: transition `failed`, fail the active run if any (`agent_run_failed` with `"Sandbox expired."`), broadcast `{ type: "error", message: "Sandbox expired." }`, `updateDirectory({ room_state: "failed", sandbox_state: "failed", active_run_state: activeRunId ? "failed" : null })`. +- **Activity**: `recordActivity()` sets `meta.last_activity_at = new Date().toISOString(); saveMeta()`. Called in `handleAgentRequest` (top, after the empty-text check), in `finishRunAndDrainQueue` / run completion paths (`agent-run-coordinator.ts`), and in `fetchPreview` for every request. +- **Resume triggers**: `handleAgentRequest` calls `host.requestSandboxResume()` when `meta.sandbox_paused_at` is set (the run is queued because no socket is attached — existing behavior — and drains on reconnect). `requestSandboxResume()` = `this.ctx.waitUntil(resumeSandbox(this).catch(log))`. `fetchPreview` awaits `Promise.race([resumeSandbox(this), timeout(PREVIEW_RESUME_TIMEOUT_MS)])` and returns `new Response("Sandbox is resuming. Retry shortly.", { status: 503, headers: { "Retry-After": "2" } })` on timeout, then proxies via `proxyPreviewRequest(request, meta, token, await this.sandboxHandle())`. +- **Alarm**: after the existing `max_time` / reconnect / provisioning checks and before `drainQueuedAgentWorkIfReady`, call `await renewSandboxLeaseIfDue(this, now)` then `await pauseIdleSandbox(this, now)`. `armNextAlarm` passes `idlePauseAt` (only when provider supports pause, not paused, state `ready`) and `leaseRenewAt` (only when not paused and ref set). +- **Teardown**: `terminateSandbox` already calls `handle.destroy(reason)` (Task 1); ensure it runs when paused too (do not short-circuit on `sandbox_paused_at`) and clears `sandbox_paused_at`. Add `terminateSandbox` to the generic `failed` paths that currently skip it: `failLostSandbox` does not call destroy for not-found; resume-exhausted failure calls `terminateSandbox("resume failed")`. +- **Reconnect mode**: no change to `sandboxConnectionMode`. Pause closes the DO's sandbox sockets with `expected_close = true`, so `webSocketClose` ignores the close and the resumed agent connects with `attachedSandboxCount === 0` → `"resume"` → `completeSandboxReconnect` + `drainQueuedAgentWorkIfReady`. + +- [ ] **Step 1: Failing tests** — `packages/worker/test/sandbox-resume.test.mjs`. Extend `createFakeHost` with: `options.handle` (a fake handle recording `pause`, `writeFile`, `renewLease`, `destroy`), `options.provider` (`{ name: "e2b", capabilities: { pauseResume: true, workspaceCache: false }, connect }`), a `sandboxSockets` array returned by `ctx.getWebSockets("sandbox")`, `closeSandboxSockets` that empties it, and `issueSandboxWebSocketToken` returning `"fresh_token"`. + +```js +import assert from "node:assert/strict"; +import test from "node:test"; +import { createFakeHost } from "./helpers/fake-host.mjs"; +import { pauseIdleSandbox, resumeSandbox, renewSandboxLeaseIfDue } from "../dist/orchestrator/sandbox-resume.js"; +import { SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; + +const T0 = Date.parse("2026-10-01T00:00:00.000Z"); +const idleMeta = { + state: "ready", sandbox_provider: "e2b", sandbox_ref: { provider: "e2b", id: "sbx_1" }, + max_idle_time: "10m", last_activity_at: "2026-10-01T00:00:00.000Z", created_at: "2026-10-01T00:00:00.000Z", max_time: "2h", +}; + +test("pauses after max_idle_time, closes sandbox sockets, and marks the sandbox paused", async () => { + const { host, handle } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + assert.equal(await pauseIdleSandbox(host, T0 + 600_000), true); + assert.deepEqual(handle.calls.map(([name]) => name), ["pause"]); + assert.equal(host.meta.expected_close, true); + assert.ok(host.meta.sandbox_paused_at); + assert.equal(host.ctx.getWebSockets("sandbox").length, 0); + assert.deepEqual(host.directoryPatches.at(-1), { sandbox_state: "paused" }); +}); + +test("does not pause during an active run", async () => { + const { host, handle } = createFakeHost({ ...idleMeta, active_run: { id: "run_1", state: "thinking" } }, { e2b: true, sandboxConnected: true }); + assert.equal(await pauseIdleSandbox(host, T0 + 3_600_000), false); + assert.equal(handle.calls.length, 0); +}); + +test("a pause failure leaves the sandbox running and clears expected_close", async () => { + const { host } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true, pauseError: new Error("boom") }); + assert.equal(await pauseIdleSandbox(host, T0 + 600_000), false); + assert.equal(host.meta.expected_close, false); + assert.equal(host.meta.sandbox_paused_at, undefined); +}); + +test("resume connects once for concurrent triggers and writes a fresh ws token", async () => { + const { host, handle, provider } = createFakeHost({ ...idleMeta, sandbox_paused_at: "2026-10-01T00:10:00.000Z", expected_close: true }, { e2b: true }); + await Promise.all([resumeSandbox(host), resumeSandbox(host)]); + assert.equal(provider.connectCalls, 1); + assert.deepEqual(handle.calls.find(([name]) => name === "writeFile"), ["writeFile", "/run/codevil/ws-token", "fresh_token", { mode: 0o600, owner: "codevil" }]); + assert.equal(host.meta.sandbox_paused_at, undefined); + assert.equal(host.meta.expected_close, false); +}); + +test("resume of a vanished sandbox fails the session with Sandbox expired", async () => { + const { host } = createFakeHost({ ...idleMeta, sandbox_paused_at: "2026-10-01T00:10:00.000Z" }, { e2b: true, connectError: new SandboxNotFoundError() }); + await resumeSandbox(host); + assert.equal(host.meta.state, "failed"); + assert.ok(host.broadcasts.some((event) => event.type === "error" && event.message === "Sandbox expired.")); +}); + +test("resume retries three times then fails and cancels queued runs", async () => { + const queued = [{ id: "run_q", state: "queued", text: "t", actor: { id: "u", name: "U" }, created_at: "t" }]; + const { host, provider } = createFakeHost({ ...idleMeta, sandbox_paused_at: "x", queued_runs: queued }, { e2b: true, connectError: new Error("503") }); + await resumeSandbox(host); + assert.equal(provider.connectCalls, 3); + assert.equal(host.meta.state, "failed"); + assert.ok(host.broadcasts.some((event) => event.type === "agent_run_failed" && event.run_id === "run_q")); +}); + +test("lease renewal runs on schedule and not while paused", async () => { + const { host, handle } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + await renewSandboxLeaseIfDue(host, T0 + 5 * 60_000); + assert.equal(handle.calls.filter(([name]) => name === "renewLease").length, 1); + host.meta.sandbox_paused_at = "x"; + await renewSandboxLeaseIfDue(host, T0 + 20 * 60_000); + assert.equal(handle.calls.filter(([name]) => name === "renewLease").length, 1); +}); +``` + +Also add, in the same file, Orchestrator-level tests through existing harness patterns (see `sandbox-handlers.test.mjs` / `cli-handlers.test.mjs`) for: +- `handleAgentRequest` on a paused session queues the run and calls `requestSandboxResume` exactly once; `last_activity_at` updates. +- Max-time on a paused session calls `handle.destroy` (use the `terminateSandbox` path through the alarm; if the Orchestrator class is not instantiable in tests, extract the max-time branch into a helper in `sandbox-resume.ts` named `expireSessionAtMaxTime(host, now)` and test that). +- `sandboxConnectionMode("ready", undefined, 0) === "resume"` after a pause (guards Review Focus 3). + +- [ ] **Step 2:** Run `pnpm --filter @codevil/worker test` → FAIL. +- [ ] **Step 3:** Implement `sandbox-resume.ts`, the host additions, and the Orchestrator wiring described above. Provisioning (`provisionSessionSandbox`) now computes `leaseMs` with `sandboxLeaseMs({ now, createdAt, maxTimeMs, providerMaxMs: sandboxProviderMaxLeaseMs(env) })` and sets `sandbox_lease_renewed_at` after create. +- [ ] **Step 4:** Run `pnpm --filter @codevil/worker typecheck && pnpm --filter @codevil/worker test` → PASS. +- [ ] **Step 5: Commit** — `git commit -m "feat(sandbox): pause idle E2B sandboxes and resume on demand"` + +--- + +### Task 8: Shared sandbox image and E2B template publishing + +**Precondition:** Task 2 findings name the template publishing command; use it. + +**Files:** +- Modify: `Dockerfile.sandbox`, `packages/sandbox-image/package.json` (script `"e2b:template": "node scripts/e2b-template.mjs"`), `packages/sandbox-image/test/dockerfile.test.mjs` +- Create: `packages/sandbox-image/scripts/e2b-template.mjs` +- Modify docs: `README.md` deployment/config section (or the existing deployment doc that documents `wrangler.toml` vars) — document `SANDBOX_PROVIDER`, `E2B_API_KEY`, `E2B_TEMPLATE_ID`, `E2B_MAX_SANDBOX_SECONDS`, and `pnpm --filter @codevil/sandbox-image e2b:template`. + +**Interfaces:** +- Produces: `Dockerfile.sandbox` with `ARG SANDBOX_BASE=docker.io/cloudflare/sandbox:0.12.7` and `FROM --platform=$CODEVIL_SANDBOX_PLATFORM ${SANDBOX_BASE}` for the runtime stage; template `codevil-sandbox` with 2 vCPU / 4096 MiB. + +- [ ] **Step 1: Failing test** — extend `dockerfile.test.mjs`: + +```js +test("runtime stage base is configurable and defaults to the Cloudflare sandbox image", () => { + const dockerfile = readFileSync(new URL("../../../Dockerfile.sandbox", import.meta.url), "utf8"); + assert.match(dockerfile, /^ARG SANDBOX_BASE=docker\.io\/cloudflare\/sandbox:0\.12\.7$/m); + assert.match(dockerfile, /^FROM --platform=\$CODEVIL_SANDBOX_PLATFORM \$\{SANDBOX_BASE\}$/m); +}); + +test("runtime stage installs bun when the base image lacks it", () => { + const dockerfile = readFileSync(new URL("../../../Dockerfile.sandbox", import.meta.url), "utf8"); + assert.match(dockerfile, /command -v bun >\/dev\/null \|\| npm install -g bun/); +}); + +test("runtime stage prepares the agent log and token directories", () => { + const dockerfile = readFileSync(new URL("../../../Dockerfile.sandbox", import.meta.url), "utf8"); + assert.match(dockerfile, /mkdir -p \/workspace \/var\/log\/codevil \/run\/codevil/); +}); +``` + +(Match the existing test file's import style; it already reads the Dockerfile.) + +- [ ] **Step 2:** Run `pnpm --filter @codevil/sandbox-image test` → FAIL. +- [ ] **Step 3:** Edit `Dockerfile.sandbox`: declare `ARG SANDBOX_BASE=docker.io/cloudflare/sandbox:0.12.7` at the top (before the first `FROM`), change the runtime `FROM` to use it, change the apt/npm `RUN` so it runs `command -v bun >/dev/null || npm install -g bun` before `bun --version`, and change the workspace `RUN` to `mkdir -p /workspace /var/log/codevil /run/codevil && chown codevil:codevil /workspace /run/codevil`. Keep every other line identical. Existing dockerfile tests must still pass. +- [ ] **Step 4:** Write `scripts/e2b-template.mjs` using the Task 2 command: build with `docker build --build-arg SANDBOX_BASE=node:22-slim -f Dockerfile.sandbox -t codevil-sandbox-e2b .` from the repo root, then publish as template `process.env.E2B_TEMPLATE_ID ?? "codevil-sandbox"` with `cpuCount: 2`, `memoryMB: 4096`. Exit non-zero with a clear message when `E2B_API_KEY` is missing. Print the template ID on success. +- [ ] **Step 5:** Run `pnpm --filter @codevil/sandbox-image test` → PASS. If `E2B_API_KEY` and Docker are available, run `pnpm --filter @codevil/sandbox-image e2b:template` and record the template ID in the commit message; otherwise state that it was not run. +- [ ] **Step 6: Commit** — `git commit -m "feat(sandbox-image): share Dockerfile across providers and publish E2B template"` + +--- + +### Task 9: Full verification and manual end-to-end checklist + +**Files:** +- Modify: `docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md` — append `## Verification Results` + +- [ ] **Step 1:** Run `pnpm verify` from the repo root. Expected: typecheck and all package tests pass. Paste the summary lines into the spec section. +- [ ] **Step 2:** If `E2B_API_KEY` and a published template exist, run `pnpm --filter @codevil/worker dev` with `SANDBOX_PROVIDER=e2b` and walk the spec's manual checklist (create Session on a real repo → clone + `npm install`; preview with HMR; idle past `max_idle_time` (set `max_idle_time` to `2m` when creating the Session to save time) → paused; resume via prompt; pause again; resume via preview; stop Session → sandbox gone in the E2B dashboard). Record each step as pass/fail with notes. If credentials are not available, record "manual E2E not run — needs E2B_API_KEY + template" and do not claim it passed. +- [ ] **Step 3: Commit** — `git commit -m "docs(sandbox): record E2B provider verification results"` diff --git a/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md b/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md index 2615463..c4b3473 100644 --- a/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md +++ b/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md @@ -212,14 +212,14 @@ E2B error messages pass through `redactEvent` before logging, like Cloudflare er ## Feasibility Gate -Implementation task 1 is a throwaway probe. Nothing else starts until it passes. It runs in `wrangler dev` with `nodejs_compat` and a real `E2B_API_KEY`, and answers: +The feasibility gate is a throwaway probe that runs after the provider-neutral refactor (which does not depend on E2B and is reused by any fallback provider). No E2B-specific task starts until it passes. It runs in `wrangler dev` with `nodejs_compat` and a real `E2B_API_KEY`, and answers: 1. Does the E2B JS SDK run in workerd for create, `commands.run` (foreground and background), file write and read, `pause`, `connect` (resume), `setTimeout`, and `kill`? 2. Does a Worker `fetch` to `https://{port}-{sandboxId}.e2b.app` with `e2b-traffic-access-token` work for HTTP and for a WebSocket upgrade? 3. Does resuming a paused sandbox reset the Hobby one-hour session clock? 4. Which tool publishes a template from our multi-stage Dockerfile? -If (1) fails, implementation stops and the design is revisited (a Node control service or E2B's REST API are the fallbacks). Answers to (2)–(4) are recorded in this spec before task 2. +If (1) fails, implementation stops and the design is revisited (a Node control service or E2B's REST API are the fallbacks). Answers to (2)–(4) are recorded in this spec before any E2B-specific task starts. ## Testing From 6277cda0dcd07910bddee94c732db4388b6fa4c9 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 20:13:54 +0530 Subject: [PATCH 03/32] refactor(sandbox): route sandbox access through a provider interface Co-Authored-By: Claude --- packages/shared/src/session-meta-schema.ts | 2 + packages/worker/src/http-handlers.ts | 25 +--- packages/worker/src/orchestrator.ts | 106 ++++++++++++--- packages/worker/src/orchestrator/host.ts | 4 + packages/worker/src/orchestrator/preview.ts | 15 +-- .../src/orchestrator/sandbox-handlers.ts | 44 +++--- packages/worker/src/orchestrator/types.ts | 2 + .../src/orchestrator/workspace-cache-job.ts | 6 +- .../worker/src/sandbox-provider/cloudflare.ts | 126 ++++++++++++++++++ packages/worker/src/sandbox-provider/index.ts | 35 +++++ packages/worker/src/sandbox-provider/types.ts | 44 ++++++ packages/worker/src/sandbox.ts | 79 ++--------- packages/worker/src/session-service.ts | 2 + packages/worker/src/worker-env.ts | 1 + packages/worker/src/workspace-cache.ts | 21 +-- packages/worker/test/helpers/fake-host.mjs | 42 ++++++ packages/worker/test/preview.test.mjs | 44 ++++++ .../worker/test/sandbox-handlers.test.mjs | 85 +++++++++++- .../test/sandbox-provider-cloudflare.test.mjs | 118 ++++++++++++++++ packages/worker/test/sandbox.test.mjs | 25 ++-- packages/worker/test/session-service.test.mjs | 1 + .../worker/test/workspace-cache-job.test.mjs | 17 +++ 22 files changed, 682 insertions(+), 162 deletions(-) create mode 100644 packages/worker/src/sandbox-provider/cloudflare.ts create mode 100644 packages/worker/src/sandbox-provider/index.ts create mode 100644 packages/worker/src/sandbox-provider/types.ts create mode 100644 packages/worker/test/sandbox-provider-cloudflare.test.mjs diff --git a/packages/shared/src/session-meta-schema.ts b/packages/shared/src/session-meta-schema.ts index 30b4b32..54904fc 100644 --- a/packages/shared/src/session-meta-schema.ts +++ b/packages/shared/src/session-meta-schema.ts @@ -37,6 +37,8 @@ export const SessionMetaSchema = z.object({ expected_close: z.boolean().optional(), sandbox_disconnected_at: z.string().optional(), workspace_cache_restored: z.boolean().optional(), + sandbox_provider: z.enum(["e2b", "cloudflare"]).optional(), + sandbox_ref: z.object({ provider: z.enum(["e2b", "cloudflare"]), id: z.string() }).optional(), last_decision: LastDecisionSchema.optional(), }); diff --git a/packages/worker/src/http-handlers.ts b/packages/worker/src/http-handlers.ts index 18bad4d..c8f4cef 100644 --- a/packages/worker/src/http-handlers.ts +++ b/packages/worker/src/http-handlers.ts @@ -49,10 +49,7 @@ import { import { configuredWebOrigins, missingAuthConfigKeys } from "./auth-config.js"; import { createEmailProvider } from "./email.js"; import { can, type AuthAction, CreateInvitationRequestSchema, SetupClaimRequestSchema } from "@codevil/shared"; -import { - getCodevilSandbox, - readSandboxDiagnostics, -} from "./sandbox.js"; +import { configuredSandboxProviderName } from "./sandbox-provider/index.js"; import { redactEvent } from "./redaction.js"; import { collectWorkerSecretValues } from "./worker-env.js"; import type { Env } from "./worker-env.js"; @@ -598,6 +595,7 @@ export async function handleCreateSession( exec_model: normalized.exec_model, max_time: normalized.max_session_time, created_by: { id: auth.userId, name: auth.name }, + sandbox_provider: configuredSandboxProviderName(env), }); } catch (error) { const failedAt = new Date().toISOString(); @@ -724,14 +722,7 @@ export async function handleSessionPreview( export async function handleLogs(env: Env, sessionId: string): Promise { try { - const { getSandbox } = await import("@cloudflare/sandbox"); - const sandbox = getCodevilSandbox( - getSandbox, - env.Sandbox as unknown as Parameters[0], - sessionId, - ); - const logs = await sandbox.getProcessLogs("codevil-agent"); - return json(redactSandboxDiagnosticResponse(logs, env), 200); + return await env.ORCHESTRATOR.get(env.ORCHESTRATOR.idFromName(sessionId)).readSandboxLogs(); } catch { return json({ error: "Failed to read sandbox logs" }, 500); } @@ -739,15 +730,7 @@ export async function handleLogs(env: Env, sessionId: string): Promise export async function handleDiagnostics(env: Env, sessionId: string): Promise { try { - return json(redactSandboxDiagnosticResponse( - await readSandboxDiagnostics( - env.Sandbox, - sessionId, - "codevil-agent", - collectWorkerSecretValues(env), - ), - env, - ), 200); + return await env.ORCHESTRATOR.get(env.ORCHESTRATOR.idFromName(sessionId)).readSandboxDiagnosticsResponse(); } catch { return json({ error: "Failed to read sandbox diagnostics" }, 500); } diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index 5b267a0..b52ded6 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -21,13 +21,17 @@ import { type Span, type Tracer, } from "@codevil/shared"; -import type { Sandbox } from "@cloudflare/sandbox"; import { buildSandboxDisconnectLogPayload, - readProcessLogs, - readSandboxDiagnostics, - setCodevilSandboxKeepAlive, + collectSandboxDiagnostics, + type SandboxDiagnostics, + type SandboxLifecycleSnapshot, } from "./sandbox.js"; +import { + sandboxProviderForMeta, + type SandboxHandle, + type SandboxProvider, +} from "./sandbox-provider/index.js"; import { SANDBOX_RECONNECT_GRACE_MS, sandboxConnectionMode, @@ -260,6 +264,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost active_run: null, queued_runs: [], created_by: options.created_by, + sandbox_provider: options.sandbox_provider ?? "cloudflare", created_at: new Date().toISOString(), }; this.saveMeta(); @@ -323,12 +328,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost } if (this.meta.state === "provisioning_sandbox" && now >= createdAt + 60_000) { - const logs = await readProcessLogs( - this.workerEnv.Sandbox, - this.meta.session_id, - "codevil-agent", - this.redactionSecrets, - ); + const logs = await this.readRedactedAgentLogs(); this.getTracer()?.log("ERROR", "sandbox.timeout", { stdout: logs?.stdout ?? "(none)", stderr: logs?.stderr ?? "(none)", @@ -751,15 +751,83 @@ export class Orchestrator extends DurableObject implements OrchestratorHost this.armNextAlarmSafe(Date.now() - 1); } + sandboxProvider(): SandboxProvider { + return sandboxProviderForMeta(this.workerEnv, this.meta ?? {}); + } + + async sandboxHandle(): Promise { + // Legacy Cloudflare Sessions predate `sandbox_ref`; their id is the Session id. + const ref = this.meta?.sandbox_ref + ?? (this.meta && this.sandboxProvider().name === "cloudflare" + ? { provider: "cloudflare" as const, id: this.meta.session_id } + : undefined); + if (!ref) return null; + const secret = await this.ctx.storage.get("codevil:sandbox_secret"); + return this.sandboxProvider().connect(ref, secret ? { secret } : undefined); + } + + /** Agent process logs, redacted; null when the sandbox cannot be read. */ + private async readRedactedAgentLogs(): Promise<{ stdout: string; stderr: string } | null> { + try { + const handle = await this.sandboxHandle(); + if (!handle) return null; + return redactEvent(await handle.readProcessLogs("codevil-agent"), this.redactionSecrets); + } catch { + return null; + } + } + + private async collectDiagnostics(): Promise { + const handle = await this.requireSandboxHandle(); + return collectSandboxDiagnostics( + { + getProcessLogs: (processId) => handle.readProcessLogs(processId), + ...(handle.readLifecycle + ? { getCodevilLifecycleSnapshot: () => handle.readLifecycle!() as Promise } + : {}), + }, + "codevil-agent", + this.redactionSecrets, + ); + } + + /** RPC for `GET /sessions/:id/logs`. */ + async readSandboxLogs(): Promise { + try { + const handle = await this.requireSandboxHandle(); + const logs = await handle.readProcessLogs("codevil-agent"); + return Response.json(redactEvent(logs, this.redactionSecrets), { status: 200 }); + } catch { + return Response.json({ error: "Failed to read sandbox logs" }, { status: 500 }); + } + } + + /** RPC for `GET /sessions/:id/diagnostics`. */ + async readSandboxDiagnosticsResponse(): Promise { + try { + return Response.json( + redactEvent(await this.collectDiagnostics(), this.redactionSecrets), + { status: 200 }, + ); + } catch { + return Response.json({ error: "Failed to read sandbox diagnostics" }, { status: 500 }); + } + } + + private async requireSandboxHandle(): Promise { + this.loadMeta(); + const handle = await this.sandboxHandle(); + if (!handle) throw new Error("Sandbox not found"); + return handle; + } + private async terminateSandbox(reason: string): Promise { if (!this.meta) return; this.meta.expected_close = true; this.saveMeta(); try { - const { getSandbox } = await import("@cloudflare/sandbox"); - const sandbox = getSandbox(this.workerEnv.Sandbox, this.meta.session_id); - await setCodevilSandboxKeepAlive(sandbox, false, reason); - await sandbox.stop(); + const handle = await this.sandboxHandle(); + await handle?.destroy(reason); } catch (error) { this.getTracer()?.log("ERROR", "sandbox.stop.failed", { ...redactEvent(safeExceptionAttributes(error), this.redactionSecrets), @@ -1047,12 +1115,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost state: SessionState; }): Promise { try { - const diagnostics = await readSandboxDiagnostics( - this.workerEnv.Sandbox, - options.sessionId, - "codevil-agent", - this.redactionSecrets, - ); + const diagnostics = await this.collectDiagnostics(); const payload = buildSandboxDisconnectLogPayload({ sessionId: options.sessionId, closeCode: options.closeCode, @@ -1108,7 +1171,8 @@ export class Orchestrator extends DurableObject implements OrchestratorHost return new Response("Preview is not active.", { status: 404 }); } - return proxyPreviewRequest(request, this.meta, token, this.workerEnv.Sandbox); + const handle = this.meta.preview_active ? await this.sandboxHandle() : null; + return proxyPreviewRequest(request, this.meta, token, handle); } submitAgentRequest(args: { diff --git a/packages/worker/src/orchestrator/host.ts b/packages/worker/src/orchestrator/host.ts index a042189..5afa8e5 100644 --- a/packages/worker/src/orchestrator/host.ts +++ b/packages/worker/src/orchestrator/host.ts @@ -8,6 +8,7 @@ import type { import type { Span, Tracer } from "@codevil/shared"; import type { AgentRun } from "../agent-runs.js"; import type { LastDecision } from "../multiplayer.js"; +import type { SandboxHandle, SandboxProvider } from "../sandbox-provider/types.js"; import type { Env, SessionMeta } from "./types.js"; export interface OrchestratorHost { @@ -48,4 +49,7 @@ export interface OrchestratorHost { fallbackMessage: string, ): { type: "error"; message: string; actor?: string }; armNextAlarm(now?: number): Promise; + sandboxProvider(): SandboxProvider; + /** Null when the Session has no sandbox yet (`meta.sandbox_ref` unset). */ + sandboxHandle(): Promise; } diff --git a/packages/worker/src/orchestrator/preview.ts b/packages/worker/src/orchestrator/preview.ts index 5a600ee..1a81cc9 100644 --- a/packages/worker/src/orchestrator/preview.ts +++ b/packages/worker/src/orchestrator/preview.ts @@ -1,6 +1,6 @@ -import type { Sandbox } from "@cloudflare/sandbox"; import { isTerminalState } from "@codevil/shared"; +import type { SandboxHandle } from "../sandbox-provider/types.js"; import type { SessionMeta } from "./types.js"; export function createPreviewToken(sessionId: string): string { @@ -61,7 +61,7 @@ export async function proxyPreviewRequest( request: Request, meta: SessionMeta, token: string, - sandboxNamespace: DurableObjectNamespace, + handle: SandboxHandle | null, ): Promise { const blocked = validatePreviewAccess(meta); if (blocked) return blocked; @@ -71,6 +71,8 @@ export async function proxyPreviewRequest( return new Response("Unknown preview token.", { status: 404 }); } + if (!handle) return new Response("Preview is not active.", { status: 404 }); + const originalUrl = new URL(request.url); const prefix = `/sessions/${meta.session_id}/preview/${token}`; const isPathBasedPreview = originalUrl.pathname.startsWith(prefix); @@ -82,17 +84,14 @@ export async function proxyPreviewRequest( const proxyRequest = new Request(proxyUrl, request); - const { getSandbox } = await import("@cloudflare/sandbox"); - const sandbox = getSandbox(sandboxNamespace, meta.session_id); const previewPort = meta.preview_port!; const portedHeaders = rewriteHeadersForSandboxDevServer(proxyRequest.headers, { port: previewPort, publicHost: originalUrl.host, publicProto: originalUrl.protocol.replace(/:$/, ""), }); - portedHeaders.set("cf-container-target-port", String(previewPort)); const portedRequest = new Request(proxyRequest, { headers: portedHeaders }); - const response = await fetchPreviewWithRetries(sandbox, portedRequest); + const response = await fetchPreviewWithRetries((req) => handle.fetchPort(previewPort, req), portedRequest); if (response.status === 101) return response; @@ -245,7 +244,7 @@ const PREVIEW_PROXY_RETRY_BACKOFF_MS = [0, 200, 500]; const PREVIEW_PROXY_MAX_ATTEMPTS = 3; async function fetchPreviewWithRetries( - sandbox: Sandbox, + fetchOnce: (request: Request) => Promise, request: Request, ): Promise { let lastError: unknown; @@ -256,7 +255,7 @@ async function fetchPreviewWithRetries( } try { - const response = await sandbox.fetch(request); + const response = await fetchOnce(request); if (response.status === 101 || !isRetryablePreviewStatus(response.status)) { return response; } diff --git a/packages/worker/src/orchestrator/sandbox-handlers.ts b/packages/worker/src/orchestrator/sandbox-handlers.ts index c7bb627..2b2bfe5 100644 --- a/packages/worker/src/orchestrator/sandbox-handlers.ts +++ b/packages/worker/src/orchestrator/sandbox-handlers.ts @@ -7,7 +7,7 @@ import { SandboxToDOMessageSchema, clientValidationErrorMessage, getProviderDefi import { redactEvent } from "../redaction.js"; import { buildSandboxWebSocketUrl, - provisionSandbox, + startAgentOnHandle, } from "../sandbox.js"; import { restoreLatestWorkspaceCache, @@ -30,7 +30,7 @@ import { createPreviewToken, hashPreviewToken, } from "./preview.js"; -import { slugify } from "./session-guards.js"; +import { parseMaxTimeMs, slugify } from "./session-guards.js"; import type { OrchestratorHost } from "./host.js"; import { completeActiveRun, @@ -69,24 +69,34 @@ export async function provisionSessionSandbox(host: OrchestratorHost): Promise - provisionSandbox({ - binding: host.workerEnv.Sandbox, - sessionId: host.meta!.session_id, + provision: async () => { + const meta = host.meta!; + const provider = host.sandboxProvider(); + const handle = await provider.create({ + sessionId: meta.session_id, + leaseMs: parseMaxTimeMs(meta.max_time) ?? 3_600_000, + }); + meta.sandbox_ref = handle.ref; + host.saveMeta(); + if (handle.secret) { + await host.ctx.storage.put("codevil:sandbox_secret", handle.secret); + } + if (provider.capabilities.workspaceCache && handle.workspaceCache) { + const restored = await restoreWorkspaceCacheBeforeStart(host, handle.workspaceCache); + if (host.meta) { + host.meta.workspace_cache_restored = restored; + host.saveMeta(); + } + } + await startAgentOnHandle(handle, { wsUrl, wsToken, - provider: host.meta!.provider, + provider: meta.provider, providerConfig, - proxyBase: host.meta!.worker_url, + proxyBase: meta.worker_url, proxyTokens, - beforeStart: async (sandbox) => { - const restored = await restoreWorkspaceCacheBeforeStart(host, sandbox as WorkspaceCacheSandbox); - if (host.meta) { - host.meta.workspace_cache_restored = restored; - host.saveMeta(); - } - }, - }), + }); + }, }); host.appendAndBroadcast({ type: "status", message: "Sandbox process started." }); } catch (error) { @@ -335,7 +345,7 @@ export function handleSandboxCloneComplete( host.appendAndBroadcast({ type: "room_ready", repo: host.meta.repo }); // Backups can outlive the socket message invocation. Leave the work in // durable job state and let the alarm run it so a DO restart can resume it. - enqueueWorkspaceCacheJob(host); + if (host.sandboxProvider().capabilities.workspaceCache) enqueueWorkspaceCacheJob(host); // Requests queued while cloning (e.g. Slack messages) must start now — // without this they would wait for the alarm, which claims the backup // first and only drains queued work after the upload finishes. diff --git a/packages/worker/src/orchestrator/types.ts b/packages/worker/src/orchestrator/types.ts index 7770d2f..abe0a33 100644 --- a/packages/worker/src/orchestrator/types.ts +++ b/packages/worker/src/orchestrator/types.ts @@ -20,6 +20,7 @@ export interface Env extends WorkerSecretEnv { CODEVIL_PREVIEW_ORIGIN?: string; CODEVIL_WEB_ORIGIN?: string; SLACK_BOT_TOKEN?: string; + SANDBOX_PROVIDER?: string; } export interface InitOptions { @@ -29,6 +30,7 @@ export interface InitOptions { exec_model?: string; max_time?: string; created_by?: ParticipantIdentity; + sandbox_provider?: "e2b" | "cloudflare"; } export const SNAPSHOT_TERMINAL_EVENT_TYPES: ReadonlySet = new Set([ diff --git a/packages/worker/src/orchestrator/workspace-cache-job.ts b/packages/worker/src/orchestrator/workspace-cache-job.ts index 54ec16b..ade39c2 100644 --- a/packages/worker/src/orchestrator/workspace-cache-job.ts +++ b/packages/worker/src/orchestrator/workspace-cache-job.ts @@ -1,5 +1,4 @@ import { safeExceptionAttributes, type SessionState } from "@codevil/shared"; -import type { Sandbox } from "@cloudflare/sandbox"; import { redactEvent } from "../redaction.js"; import { @@ -8,6 +7,7 @@ import { WORKSPACE_CACHE_TTL_SECONDS, WORKSPACE_CACHE_VERSION, type WorkspaceCacheCreateResult, + type WorkspaceCacheSandbox, } from "../workspace-cache.js"; import type { OrchestratorHost } from "./host.js"; @@ -41,7 +41,7 @@ export interface WorkspaceCacheJobRow { type CacheJobResult = "ready" | "failed" | "exhausted" | "interrupted" | "deferred" | "missing"; export type CreateSnapshot = (input: { db: D1Database; - binding: DurableObjectNamespace; + sandbox: WorkspaceCacheSandbox | undefined; sessionId: string; repo: string; }) => Promise; @@ -153,7 +153,7 @@ export async function processWorkspaceCacheJob( try { result = await createSnapshot({ db: host.workerEnv.DB, - binding: host.workerEnv.Sandbox, + sandbox: (await host.sandboxHandle())?.workspaceCache, sessionId: job.source_session_id, repo: job.repo, }); diff --git a/packages/worker/src/sandbox-provider/cloudflare.ts b/packages/worker/src/sandbox-provider/cloudflare.ts new file mode 100644 index 0000000..50d2745 --- /dev/null +++ b/packages/worker/src/sandbox-provider/cloudflare.ts @@ -0,0 +1,126 @@ +import { + CODEVIL_SANDBOX_OPTIONS, + getCodevilSandbox, + retrySandboxOperation, + setCodevilSandboxKeepAlive, +} from "../sandbox.js"; +import type { WorkspaceCacheSandbox } from "../workspace-cache.js"; +import type { + SandboxHandle, + SandboxLifecycleView, + SandboxProvider, + SandboxRef, + ShellResult, +} from "./types.js"; + +/** The slice of `@cloudflare/sandbox`'s Sandbox the adapter relies on. */ +export interface CloudflareSandboxLike { + exec(command: string, options?: { + cwd?: string; + env?: Record; + timeout?: number; + }): Promise<{ stdout: string; stderr: string; exitCode: number }>; + writeFile(path: string, content: string): Promise; + startProcess(command: string, options: { + cwd: string; + env: Record; + processId: string; + autoCleanup: boolean; + }): Promise; + getProcessLogs(processId: string): Promise<{ stdout: string; stderr: string }>; + getCodevilLifecycleSnapshot?: () => Promise; + fetch(request: Request): Promise; + stop(): Promise; +} + +export type GetCloudflareSandbox = ( + binding: unknown, + sandboxId: string, + options?: typeof CODEVIL_SANDBOX_OPTIONS, +) => unknown; + +export interface CloudflareSandboxProviderOptions { + binding: unknown; + /** Injected in tests; production lazily imports `@cloudflare/sandbox`. */ + getSandbox?: GetCloudflareSandbox; +} + +const OWNER_IDS = { codevil: "10001:10001", root: "0:0" } as const; + +export function createCloudflareSandboxProvider( + providerOptions: CloudflareSandboxProviderOptions, +): SandboxProvider { + const lookup = async (sandboxId: string): Promise => { + const getSandbox = providerOptions.getSandbox + ?? (await import("@cloudflare/sandbox")).getSandbox as unknown as GetCloudflareSandbox; + return getCodevilSandbox(getSandbox, providerOptions.binding, sandboxId) as CloudflareSandboxLike; + }; + + return { + name: "cloudflare", + capabilities: { pauseResume: false, workspaceCache: true }, + async create({ sessionId }) { + const sandbox = await lookup(sessionId); + await retrySandboxOperation(() => + setCodevilSandboxKeepAlive(sandbox, true, "session provisioning"), + ); + return cloudflareHandle(sandbox, { provider: "cloudflare", id: sessionId }); + }, + async connect(ref: SandboxRef) { + return cloudflareHandle(await lookup(ref.id), ref); + }, + }; +} + +function cloudflareHandle(sandbox: CloudflareSandboxLike, ref: SandboxRef): SandboxHandle { + const exec: SandboxHandle["exec"] = async (command, options) => { + const result = await sandbox.exec(command, { + ...(options?.cwd !== undefined ? { cwd: options.cwd } : {}), + ...(options?.env !== undefined ? { env: options.env } : {}), + ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), + }); + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + } satisfies ShellResult; + }; + + return { + ref, + exec, + async writeFile(path, content, options) { + await sandbox.writeFile(path, content); + const steps: string[] = []; + if (options?.mode !== undefined) steps.push(`chmod ${options.mode.toString(8)} ${shellQuote(path)}`); + if (options?.owner !== undefined) steps.push(`chown ${OWNER_IDS[options.owner]} ${shellQuote(path)}`); + if (steps.length > 0) await exec(steps.join(" && ")); + }, + async startProcess(command, { processId, cwd, env }) { + await retrySandboxOperation(() => + sandbox.startProcess(command, { cwd, env, processId, autoCleanup: true }), + ); + }, + readProcessLogs: (processId) => sandbox.getProcessLogs(processId), + async readLifecycle() { + return (await sandbox.getCodevilLifecycleSnapshot?.()) ?? null; + }, + fetchPort(port, request) { + const headers = new Headers(request.headers); + headers.set("cf-container-target-port", String(port)); + return sandbox.fetch(new Request(request, { headers })); + }, + async renewLease() { + // Cloudflare keepAlive replaces timed leases. + }, + async destroy(reason) { + await setCodevilSandboxKeepAlive(sandbox, false, reason); + await sandbox.stop(); + }, + workspaceCache: sandbox as unknown as WorkspaceCacheSandbox, + }; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/packages/worker/src/sandbox-provider/index.ts b/packages/worker/src/sandbox-provider/index.ts new file mode 100644 index 0000000..0760eb7 --- /dev/null +++ b/packages/worker/src/sandbox-provider/index.ts @@ -0,0 +1,35 @@ +import { createCloudflareSandboxProvider } from "./cloudflare.js"; +import { parseSandboxProviderName, type SandboxProvider, type SandboxProviderName } from "./types.js"; + +export type { + SandboxHandle, + SandboxLifecycleView, + SandboxProvider, + SandboxProviderName, + SandboxRef, + ShellResult, +} from "./types.js"; +export { SandboxNotFoundError, parseSandboxProviderName } from "./types.js"; + +/** The slice of the Worker environment provider resolution needs. */ +export interface SandboxProviderEnv { + Sandbox: unknown; + SANDBOX_PROVIDER?: string; +} + +export function configuredSandboxProviderName(env: Pick): SandboxProviderName { + const raw = env.SANDBOX_PROVIDER?.trim(); + if (!raw) return "cloudflare"; + const name = parseSandboxProviderName(raw); + if (!name) throw new Error("Unsupported SANDBOX_PROVIDER"); + return name; +} + +export function resolveSandboxProvider(env: SandboxProviderEnv, name: SandboxProviderName): SandboxProvider { + if (name === "cloudflare") return createCloudflareSandboxProvider({ binding: env.Sandbox }); + throw new Error("E2B sandbox provider is not available"); +} + +export function sandboxProviderForMeta(env: SandboxProviderEnv, meta: { sandbox_provider?: string }): SandboxProvider { + return resolveSandboxProvider(env, parseSandboxProviderName(meta.sandbox_provider) ?? "cloudflare"); +} diff --git a/packages/worker/src/sandbox-provider/types.ts b/packages/worker/src/sandbox-provider/types.ts new file mode 100644 index 0000000..ab38398 --- /dev/null +++ b/packages/worker/src/sandbox-provider/types.ts @@ -0,0 +1,44 @@ +export type SandboxProviderName = "e2b" | "cloudflare"; +export const SANDBOX_PROVIDER_NAMES: readonly SandboxProviderName[] = ["e2b", "cloudflare"]; + +export interface SandboxRef { provider: SandboxProviderName; id: string } +export interface ShellResult { stdout: string; stderr: string; exitCode: number } +export interface SandboxLifecycleView { + keepAlive?: { active: boolean; reason?: string; updated_at?: string }; + lastEvent?: { type: string; at: string; exit_code?: number; reason?: string; error?: string }; +} + +export interface SandboxProvider { + readonly name: SandboxProviderName; + readonly capabilities: { pauseResume: boolean; workspaceCache: boolean }; + create(options: { sessionId: string; leaseMs: number }): Promise; + /** `secret` is the opaque value returned as `handle.secret` at create time. */ + connect(ref: SandboxRef, options?: { leaseMs?: number; secret?: string }): Promise; +} + +export interface SandboxHandle { + readonly ref: SandboxRef; + /** Provider-specific secret the Orchestrator must persist and redact (E2B traffic token). */ + readonly secret?: string; + exec(command: string, options?: { cwd?: string; env?: Record; timeoutMs?: number; user?: "root" | "codevil" }): Promise; + writeFile(path: string, content: string, options?: { mode?: number; owner?: "root" | "codevil" }): Promise; + startProcess(command: string, options: { processId: string; cwd: string; env: Record }): Promise; + readProcessLogs(processId: string): Promise<{ stdout: string; stderr: string }>; + readLifecycle?(): Promise; + fetchPort(port: number, request: Request): Promise; + renewLease(ms: number): Promise; + pause?(): Promise; + destroy(reason: string): Promise; + /** Present only when provider.capabilities.workspaceCache is true. */ + readonly workspaceCache?: import("../workspace-cache.js").WorkspaceCacheSandbox; +} + +export class SandboxNotFoundError extends Error { + constructor(message = "Sandbox not found") { super(message); this.name = "SandboxNotFoundError"; } +} + +export function parseSandboxProviderName(value: unknown): SandboxProviderName | undefined { + return typeof value === "string" && (SANDBOX_PROVIDER_NAMES as readonly string[]).includes(value) + ? value as SandboxProviderName + : undefined; +} diff --git a/packages/worker/src/sandbox.ts b/packages/worker/src/sandbox.ts index ae1cd85..7dccd69 100644 --- a/packages/worker/src/sandbox.ts +++ b/packages/worker/src/sandbox.ts @@ -1,6 +1,6 @@ -import type { Sandbox } from "@cloudflare/sandbox"; import { safeExceptionAttributes, safeOwnDataProperty, type ProviderPublicConfig } from "@codevil/shared"; import { redactEvent } from "./redaction.js"; +import type { SandboxHandle } from "./sandbox-provider/types.js"; export interface SandboxProcessEnvOptions { wsUrl: string; @@ -13,12 +13,6 @@ export interface SandboxProcessEnvOptions { proxyTokens?: Partial>; } -export interface ProvisionSandboxOptions extends SandboxProcessEnvOptions { - binding: DurableObjectNamespace; - sessionId: string; - beforeStart?: (sandbox: Sandbox) => Promise; -} - export interface SandboxRetryOptions { attempts?: number; baseDelayMs?: number; @@ -127,7 +121,7 @@ export interface CodevilKeepAliveSandbox { } export interface CodevilLifecycleSandbox { - getCodevilLifecycleSnapshot?: () => Promise; + getCodevilLifecycleSnapshot?: () => Promise; } export interface SandboxLogReader { @@ -208,33 +202,18 @@ export function shouldDeferSandboxActivityExpiry(state: SandboxKeepAliveState | return state?.active === true; } -export async function provisionSandbox(options: ProvisionSandboxOptions): Promise { - const { getSandbox } = await import("@cloudflare/sandbox"); - const sandbox = getCodevilSandbox(getSandbox, options.binding, options.sessionId); - await provisionSandboxOnInstance(sandbox, options); -} +export const AGENT_COMMAND = + "chown 10001:10001 /workspace && chmod u+rwx /workspace && exec setpriv --reuid=10001 --regid=10001 --clear-groups -- node /app/packages/sandbox-image/dist/index.js"; -export async function provisionSandboxOnInstance( - sandbox: Sandbox, - options: Omit, +export async function startAgentOnHandle( + handle: SandboxHandle, + options: SandboxProcessEnvOptions, ): Promise { - await retrySandboxOperation(() => - setCodevilSandboxKeepAlive(sandbox as CodevilKeepAliveSandbox, true, "session provisioning"), - ); - - const env = sandboxProcessEnv(options); - - await options.beforeStart?.(sandbox); - - await retrySandboxOperation(() => sandbox.startProcess( - "chown 10001:10001 /workspace && chmod u+rwx /workspace && exec setpriv --reuid=10001 --regid=10001 --clear-groups -- node /app/packages/sandbox-image/dist/index.js", - { - cwd: "/workspace", - env, - processId: "codevil-agent", - autoCleanup: true, - }, - )); + await handle.startProcess(AGENT_COMMAND, { + processId: "codevil-agent", + cwd: "/workspace", + env: sandboxProcessEnv(options), + }); } export async function retrySandboxOperation( @@ -284,40 +263,6 @@ function sleepFor(delayMs: number): Promise { return new Promise((resolve) => setTimeout(resolve, delayMs)); } -export async function readProcessLogs( - binding: DurableObjectNamespace, - sessionId: string, - processId: string, - secrets: readonly string[], -): Promise<{ stdout: string; stderr: string } | null> { - try { - const { getSandbox } = await import("@cloudflare/sandbox"); - const sandbox = getCodevilSandbox(getSandbox, binding, sessionId); - return redactEvent(await sandbox.getProcessLogs(processId), secrets); - } catch { - return null; - } -} - -export async function readSandboxDiagnostics( - binding: Binding, - sessionId: string, - processId: string, - secrets: readonly string[], -): Promise { - const { getSandbox } = await import("@cloudflare/sandbox"); - const sandbox = getCodevilSandbox( - getSandbox as unknown as ( - binding: Binding, - sessionId: string, - options?: typeof CODEVIL_SANDBOX_OPTIONS, - ) => SandboxLogReader & CodevilLifecycleSandbox, - binding, - sessionId, - ); - return collectSandboxDiagnostics(sandbox as SandboxLogReader & CodevilLifecycleSandbox, processId, secrets); -} - export async function collectSandboxDiagnostics( sandbox: SandboxLogReader & CodevilLifecycleSandbox, processId: string, diff --git a/packages/worker/src/session-service.ts b/packages/worker/src/session-service.ts index 44bcdc3..e8faf6f 100644 --- a/packages/worker/src/session-service.ts +++ b/packages/worker/src/session-service.ts @@ -8,6 +8,7 @@ import { type NormalizedCreateSession, type SessionDirectoryRow, } from "./session-directory.js"; +import { configuredSandboxProviderName } from "./sandbox-provider/index.js"; import type { Env } from "./worker-env.js"; export interface SessionCreator { @@ -72,6 +73,7 @@ export async function createSession( exec_model: normalized.exec_model, max_time: normalized.max_session_time, created_by: { id: createdBy.id, name: createdBy.name }, + sandbox_provider: configuredSandboxProviderName(env), }); } catch (error) { const failedAt = new Date().toISOString(); diff --git a/packages/worker/src/worker-env.ts b/packages/worker/src/worker-env.ts index 525ce35..fc2468b 100644 --- a/packages/worker/src/worker-env.ts +++ b/packages/worker/src/worker-env.ts @@ -73,4 +73,5 @@ export interface Env extends WorkerSecretEnv { SLACK_BOT_TOKEN?: string; SLACK_SIGNING_SECRET?: string; CODEVIL_SLACK_BOT_USER_ID?: string; + SANDBOX_PROVIDER?: string; } diff --git a/packages/worker/src/workspace-cache.ts b/packages/worker/src/workspace-cache.ts index bcad393..d58af25 100644 --- a/packages/worker/src/workspace-cache.ts +++ b/packages/worker/src/workspace-cache.ts @@ -1,9 +1,6 @@ -import type { DirectoryBackup, Sandbox } from "@cloudflare/sandbox"; +import type { DirectoryBackup } from "@cloudflare/sandbox"; -import { - getCodevilSandbox, - retrySandboxOperation, -} from "./sandbox.js"; +import { retrySandboxOperation } from "./sandbox.js"; import type { SqlStatement } from "./sql.js"; export const WORKSPACE_CACHE_VERSION = "workspace-cache-v4"; @@ -218,15 +215,21 @@ export async function createWorkspaceCacheSnapshot(input: { export async function createWorkspaceCacheSnapshotForSandbox(input: { db: D1Database; - binding: DurableObjectNamespace; + /** Absent when the Session's sandbox provider has no workspace cache. */ + sandbox: WorkspaceCacheSandbox | undefined; sessionId: string; repo: string; }): Promise { - const { getSandbox } = await import("@cloudflare/sandbox"); - const sandbox = getCodevilSandbox(getSandbox, input.binding, input.sessionId) as WorkspaceCacheSandbox; + if (!input.sandbox) { + return { + created: false, + phase: "backup", + reason: "workspace cache is not supported by this sandbox provider", + }; + } return createWorkspaceCacheSnapshot({ db: input.db, - sandbox, + sandbox: input.sandbox, repo: input.repo, sourceSessionId: input.sessionId, }); diff --git a/packages/worker/test/helpers/fake-host.mjs b/packages/worker/test/helpers/fake-host.mjs index fa2339c..6a9e968 100644 --- a/packages/worker/test/helpers/fake-host.mjs +++ b/packages/worker/test/helpers/fake-host.mjs @@ -61,6 +61,35 @@ export function createFakeSql(initial = {}) { }; } +/** A Cloudflare-like fake sandbox handle; override any member per test. */ +export function createFakeSandboxHandle(overrides = {}) { + return { + ref: { provider: "cloudflare", id: "ses_test" }, + exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + writeFile: async () => {}, + startProcess: async () => {}, + readProcessLogs: async () => ({ stdout: "", stderr: "" }), + fetchPort: async () => new Response("ok"), + renewLease: async () => {}, + destroy: async () => {}, + workspaceCache: { + restoreBackup: async () => ({}), + createBackup: async () => ({ id: "backup_test", dir: "/workspace" }), + }, + ...overrides, + }; +} + +export function createFakeSandboxProvider(overrides = {}) { + return { + name: "cloudflare", + capabilities: { pauseResume: false, workspaceCache: true }, + create: async () => createFakeSandboxHandle(), + connect: async () => createFakeSandboxHandle(), + ...overrides, + }; +} + export function createFakeTracer() { return { trace_id: "trace_test", @@ -78,6 +107,7 @@ export function createFakeHost(metaOverrides = {}, options = {}) { const backgroundWork = []; let saveMetaCalls = 0; let previewRevoked = false; + const storage = new Map(); const host = { meta, @@ -88,6 +118,10 @@ export function createFakeHost(metaOverrides = {}, options = {}) { DB: {}, }, ctx: { + storage: { + put: async (key, value) => { storage.set(key, value); }, + get: async (key) => storage.get(key), + }, waitUntil(promise) { backgroundWork.push(Promise.resolve(promise).catch(() => {})); }, @@ -165,6 +199,13 @@ export function createFakeHost(metaOverrides = {}, options = {}) { return { type: "error", message: fallbackMessage }; }, armNextAlarm: async () => {}, + sandboxProvider() { + return options.sandboxProvider ?? createFakeSandboxProvider(); + }, + async sandboxHandle() { + if (options.sandboxHandle !== undefined) return options.sandboxHandle; + return meta.sandbox_ref ? createFakeSandboxHandle({ ref: meta.sandbox_ref }) : null; + }, }; return { @@ -174,6 +215,7 @@ export function createFakeHost(metaOverrides = {}, options = {}) { transitions, sandboxMessages, directoryPatches, + storage, get saveMetaCalls() { return saveMetaCalls; }, diff --git a/packages/worker/test/preview.test.mjs b/packages/worker/test/preview.test.mjs index 7ae6062..6dc87fb 100644 --- a/packages/worker/test/preview.test.mjs +++ b/packages/worker/test/preview.test.mjs @@ -3,9 +3,11 @@ import test from "node:test"; import { buildPreviewUrl, + hashPreviewToken, injectPreviewBaseHref, isRetryablePreviewStatus, previewPathPrefix, + proxyPreviewRequest, rewriteHeadersForSandboxDevServer, } from "../dist/orchestrator/preview.js"; @@ -106,3 +108,45 @@ test("isRetryablePreviewStatus matches transient upstream failures", () => { assert.equal(isRetryablePreviewStatus(500), false); assert.equal(isRetryablePreviewStatus(404), false); }); + +async function activePreviewMeta(token) { + return { + session_id: "ses_abc", + state: "ready", + preview_active: true, + preview_port: 5173, + preview_token_hash: await hashPreviewToken(token), + }; +} + +test("proxyPreviewRequest forwards through the handle's port and keeps the path", async () => { + const fetched = []; + const handle = { + fetchPort: async (port, request) => { + fetched.push([port, new URL(request.url).pathname + new URL(request.url).search, request.headers.get("host")]); + return new Response("body", { headers: { "content-type": "text/plain" } }); + }, + }; + const request = new Request("https://worker.example/sessions/ses_abc/preview/tok/app.js?x=1"); + + const response = await proxyPreviewRequest(request, await activePreviewMeta("tok"), "tok", handle); + + assert.equal(await response.text(), "body"); + assert.equal(response.headers.get("Cache-Control"), "no-store"); + assert.deepEqual(fetched, [[5173, "/app.js?x=1", "localhost:5173"]]); +}); + +test("proxyPreviewRequest answers 404 when the Session has no sandbox handle", async () => { + const request = new Request("https://worker.example/sessions/ses_abc/preview/tok/"); + const response = await proxyPreviewRequest(request, await activePreviewMeta("tok"), "tok", null); + assert.equal(response.status, 404); + assert.equal(await response.text(), "Preview is not active."); +}); + +test("proxyPreviewRequest still rejects unknown tokens before using the handle", async () => { + const handle = { fetchPort: async () => assert.fail("must not reach the sandbox") }; + const request = new Request("https://worker.example/sessions/ses_abc/preview/nope/"); + const response = await proxyPreviewRequest(request, await activePreviewMeta("tok"), "nope", handle); + assert.equal(response.status, 404); + assert.equal(await response.text(), "Unknown preview token."); +}); diff --git a/packages/worker/test/sandbox-handlers.test.mjs b/packages/worker/test/sandbox-handlers.test.mjs index 332e343..dc79adf 100644 --- a/packages/worker/test/sandbox-handlers.test.mjs +++ b/packages/worker/test/sandbox-handlers.test.mjs @@ -13,7 +13,13 @@ import { } from "../dist/orchestrator/sandbox-handlers.js"; import { processWorkspaceCacheJob } from "../dist/orchestrator/workspace-cache-job.js"; import { handleSandboxProxy } from "../dist/sandbox-proxy.js"; -import { actor, createFakeHost, createFakeTracer } from "./helpers/fake-host.mjs"; +import { + actor, + createFakeHost, + createFakeSandboxHandle, + createFakeSandboxProvider, + createFakeTracer, +} from "./helpers/fake-host.mjs"; function createCacheJobSql() { let row = null; @@ -463,3 +469,80 @@ test("provisionSessionSandbox failure transitions to failed and patches director ]); assert.ok(broadcasts.some((e) => e.type === "error")); }); + +function provisioningEnv() { + const cacheMiss = { prepare: () => ({ bind: () => ({ first: async () => null, run: async () => ({}) }) }) }; + return { + OPENAI_API_KEY: "sk-test", + CODEVIL_API_KEY: "test-key", + CODEVIL_PROXY_SIGNING_SECRET: "test-signing-secret", + Sandbox: {}, + DB: cacheMiss, + }; +} + +test("provisionSessionSandbox creates through the provider, records the ref, restores, then starts the agent", async () => { + const calls = []; + const handle = createFakeSandboxHandle({ + ref: { provider: "cloudflare", id: "ses_test" }, + secret: "handle-secret", + startProcess: async (command, options) => calls.push(["startProcess", options.processId, options.cwd]), + }); + const provider = createFakeSandboxProvider({ + create: async (options) => { calls.push(["create", options]); return handle; }, + }); + const { host } = createFakeHost( + { state: "initializing", max_time: "30m" }, + { tracer: createFakeTracer(), sandboxProvider: provider, workerEnv: provisioningEnv() }, + ); + + await provisionSessionSandbox(host); + + assert.equal(host.meta.state, "provisioning_sandbox"); + assert.deepEqual(host.meta.sandbox_ref, { provider: "cloudflare", id: "ses_test" }); + assert.equal(host.meta.workspace_cache_restored, false); + assert.equal(await host.ctx.storage.get("codevil:sandbox_secret"), "handle-secret"); + assert.deepEqual(calls, [ + ["create", { sessionId: "ses_test", leaseMs: 30 * 60_000 }], + ["startProcess", "codevil-agent", "/workspace"], + ]); +}); + +test("provisionSessionSandbox skips the workspace cache when the provider has none", async () => { + let restored = false; + const handle = createFakeSandboxHandle({ + workspaceCache: { restoreBackup: async () => { restored = true; }, createBackup: async () => ({}) }, + }); + const provider = createFakeSandboxProvider({ + capabilities: { pauseResume: false, workspaceCache: false }, + create: async () => handle, + }); + const { host } = createFakeHost( + { state: "initializing" }, + { tracer: createFakeTracer(), sandboxProvider: provider, workerEnv: provisioningEnv() }, + ); + + await provisionSessionSandbox(host); + + assert.equal(restored, false); + assert.equal(host.meta.workspace_cache_restored, undefined); + assert.equal(await host.ctx.storage.get("codevil:sandbox_secret"), undefined); +}); + +test("handleSandboxCloneComplete does not enqueue cache work for a provider without a workspace cache", () => { + const sql = createCacheJobSql(); + const fixture = createFakeHost( + { state: "cloning_repo" }, + { + sql, + sandboxProvider: createFakeSandboxProvider({ + capabilities: { pauseResume: false, workspaceCache: false }, + }), + }, + ); + + handleSandboxCloneComplete(fixture.host); + + assert.equal(sql.row, null); + assert.equal(fixture.host.meta.state, "ready"); +}); diff --git a/packages/worker/test/sandbox-provider-cloudflare.test.mjs b/packages/worker/test/sandbox-provider-cloudflare.test.mjs new file mode 100644 index 0000000..44e8a43 --- /dev/null +++ b/packages/worker/test/sandbox-provider-cloudflare.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createCloudflareSandboxProvider } from "../dist/sandbox-provider/cloudflare.js"; +import { parseSandboxProviderName, SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; +import { sandboxProviderForMeta, configuredSandboxProviderName } from "../dist/sandbox-provider/index.js"; + +function fakeCloudflareSandbox() { + const calls = []; + return { + calls, + setKeepAlive: async (active) => { calls.push(["setKeepAlive", active]); }, + setCodevilKeepAlive: async (active, reason) => { calls.push(["setCodevilKeepAlive", active, reason]); }, + startProcess: async (command, options) => { calls.push(["startProcess", command, options]); }, + getProcessLogs: async (id) => ({ stdout: `out:${id}`, stderr: "" }), + getCodevilLifecycleSnapshot: async () => ({ lastEvent: { type: "start", at: "t" } }), + fetch: async (request) => { calls.push(["fetch", request.headers.get("cf-container-target-port")]); return new Response("ok"); }, + stop: async () => { calls.push(["stop"]); }, + exec: async (command, options) => { calls.push(["exec", command, options]); return { success: true, exitCode: 0, stdout: "o", stderr: "e" }; }, + writeFile: async (path, content) => { calls.push(["writeFile", path, content]); }, + }; +} + +function providerWith(sandbox) { + const seen = []; + const provider = createCloudflareSandboxProvider({ + binding: {}, + getSandbox: (_binding, id, options) => { seen.push([id, options]); return sandbox; }, + }); + return { provider, seen }; +} + +test("cloudflare create enables keepalive and uses the session id as the ref", async () => { + const sandbox = fakeCloudflareSandbox(); + const { provider, seen } = providerWith(sandbox); + const handle = await provider.create({ sessionId: "ses_1", leaseMs: 60_000 }); + assert.deepEqual(handle.ref, { provider: "cloudflare", id: "ses_1" }); + assert.deepEqual(seen, [["ses_1", { keepAlive: true }]]); + assert.deepEqual(sandbox.calls, [["setKeepAlive", true], ["setCodevilKeepAlive", true, "session provisioning"]]); + assert.deepEqual(provider.capabilities, { pauseResume: false, workspaceCache: true }); + assert.equal(handle.pause, undefined); + assert.equal(handle.workspaceCache, sandbox); +}); + +test("cloudflare startProcess keeps processId and autoCleanup", async () => { + const sandbox = fakeCloudflareSandbox(); + const { provider } = providerWith(sandbox); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + await handle.startProcess("node x", { processId: "codevil-agent", cwd: "/workspace", env: { A: "1" } }); + assert.deepEqual(sandbox.calls, [["startProcess", "node x", { cwd: "/workspace", env: { A: "1" }, processId: "codevil-agent", autoCleanup: true }]]); +}); + +test("cloudflare fetchPort routes by the container target-port header", async () => { + const sandbox = fakeCloudflareSandbox(); + const { provider } = providerWith(sandbox); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + const response = await handle.fetchPort(5173, new Request("http://localhost/")); + assert.equal(await response.text(), "ok"); + assert.deepEqual(sandbox.calls, [["fetch", "5173"]]); +}); + +test("cloudflare destroy clears keepalive before stopping", async () => { + const sandbox = fakeCloudflareSandbox(); + const { provider } = providerWith(sandbox); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + await handle.destroy("stopped by user"); + assert.deepEqual(sandbox.calls, [["setKeepAlive", false], ["setCodevilKeepAlive", false, "stopped by user"], ["stop"]]); +}); + +test("cloudflare readProcessLogs and readLifecycle pass through", async () => { + const { provider } = providerWith(fakeCloudflareSandbox()); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + assert.deepEqual(await handle.readProcessLogs("codevil-agent"), { stdout: "out:codevil-agent", stderr: "" }); + assert.deepEqual(await handle.readLifecycle(), { lastEvent: { type: "start", at: "t" } }); +}); + +test("cloudflare exec maps options and results", async () => { + const sandbox = fakeCloudflareSandbox(); + const { provider } = providerWith(sandbox); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + const result = await handle.exec("ls", { cwd: "/workspace", env: { A: "1" }, timeoutMs: 5_000 }); + assert.deepEqual(result, { stdout: "o", stderr: "e", exitCode: 0 }); + assert.deepEqual(sandbox.calls, [["exec", "ls", { cwd: "/workspace", env: { A: "1" }, timeout: 5_000 }]]); +}); + +test("cloudflare writeFile applies mode and owner only when requested", async () => { + const sandbox = fakeCloudflareSandbox(); + const { provider } = providerWith(sandbox); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + await handle.writeFile("/a b", "x"); + await handle.writeFile("/etc/key", "y", { mode: 0o600, owner: "codevil" }); + await handle.writeFile("/etc/root", "z", { owner: "root" }); + assert.deepEqual(sandbox.calls, [ + ["writeFile", "/a b", "x"], + ["writeFile", "/etc/key", "y"], + ["exec", "chmod 600 '/etc/key' && chown 10001:10001 '/etc/key'", {}], + ["writeFile", "/etc/root", "z"], + ["exec", "chown 0:0 '/etc/root'", {}], + ]); +}); + +test("cloudflare renewLease is a no-op", async () => { + const sandbox = fakeCloudflareSandbox(); + const { provider } = providerWith(sandbox); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + await handle.renewLease(1_000); + assert.deepEqual(sandbox.calls, []); +}); + +test("provider name parsing and legacy meta default", () => { + assert.equal(parseSandboxProviderName("e2b"), "e2b"); + assert.equal(parseSandboxProviderName("modal"), undefined); + assert.equal(new SandboxNotFoundError().name, "SandboxNotFoundError"); + assert.equal(configuredSandboxProviderName({}), "cloudflare"); + assert.throws(() => configuredSandboxProviderName({ SANDBOX_PROVIDER: "modal" }), /Unsupported SANDBOX_PROVIDER/); + const provider = sandboxProviderForMeta({ Sandbox: {} }, {}); + assert.equal(provider.name, "cloudflare"); +}); diff --git a/packages/worker/test/sandbox.test.mjs b/packages/worker/test/sandbox.test.mjs index f09a2f2..99d819b 100644 --- a/packages/worker/test/sandbox.test.mjs +++ b/packages/worker/test/sandbox.test.mjs @@ -2,19 +2,20 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + AGENT_COMMAND, buildSandboxWebSocketUrl, buildSandboxDisconnectLogPayload, CODEVIL_SANDBOX_OPTIONS, collectSandboxDiagnostics, getCodevilSandbox, lifecycleErrorEvent, - provisionSandboxOnInstance, recordSandboxLifecycleEvent, redactSandboxKeepAliveState, retrySandboxOperation, SANDBOX_LIFECYCLE_EVENT_KEY, sandboxProcessEnv, setCodevilSandboxKeepAlive, + startAgentOnHandle, shouldDeferSandboxActivityExpiry, stopDiagnostics, } from "../dist/sandbox.js"; @@ -58,31 +59,26 @@ test("sandbox process environment never receives the deployment API key or provi assert.doesNotMatch(JSON.stringify(env), /sk-ant-real-provider-key/); }); -test("repairs restored workspace access after beforeStart and provisions the agent as uid 10001", async () => { +test("starts the agent on a handle as uid 10001 with the session-bound environment", async () => { const calls = []; - const sandbox = { - setKeepAlive: async (active) => calls.push(["setKeepAlive", active]), - setCodevilKeepAlive: async (active, reason) => calls.push(["setCodevilKeepAlive", active, reason]), - mkdir: async () => assert.fail("provisioning must not create a secrets directory"), - writeFile: async () => assert.fail("provisioning must not write an environment file"), + const handle = { startProcess: async (command, options) => calls.push(["startProcess", command, options]), }; - await provisionSandboxOnInstance(sandbox, { - sessionId: "ses_123", + await startAgentOnHandle(handle, { wsUrl: "wss://codevil.example.com/sessions/ses_123/sandbox/ws", wsToken: "session-bound-ws-capability", provider: "anthropic", proxyBase: "https://codevil.example.com", proxyTokens: { "anthropic-messages": "proxy-capability" }, - beforeStart: async () => calls.push(["beforeStart"]), }); + assert.equal( + AGENT_COMMAND, + "chown 10001:10001 /workspace && chmod u+rwx /workspace && exec setpriv --reuid=10001 --regid=10001 --clear-groups -- node /app/packages/sandbox-image/dist/index.js", + ); assert.deepEqual(calls, [ - ["setKeepAlive", true], - ["setCodevilKeepAlive", true, "session provisioning"], - ["beforeStart"], - ["startProcess", "chown 10001:10001 /workspace && chmod u+rwx /workspace && exec setpriv --reuid=10001 --regid=10001 --clear-groups -- node /app/packages/sandbox-image/dist/index.js", { + ["startProcess", AGENT_COMMAND, { cwd: "/workspace", env: { HOME: "/home/codevil", @@ -96,7 +92,6 @@ test("repairs restored workspace access after beforeStart and provisions the age CODEVIL_PROXY_TOKENS: '{"anthropic-messages":"proxy-capability"}', }, processId: "codevil-agent", - autoCleanup: true, }], ]); }); diff --git a/packages/worker/test/session-service.test.mjs b/packages/worker/test/session-service.test.mjs index bf4c4e8..d202a09 100644 --- a/packages/worker/test/session-service.test.mjs +++ b/packages/worker/test/session-service.test.mjs @@ -80,6 +80,7 @@ test("createSession inserts a session, initializes the orchestrator, and returns exec_model: normalized.exec_model, max_time: normalized.max_session_time, created_by: { id: "usr_123", name: "Alice" }, + sandbox_provider: "cloudflare", }, ]); }); diff --git a/packages/worker/test/workspace-cache-job.test.mjs b/packages/worker/test/workspace-cache-job.test.mjs index 7ea6794..36ff811 100644 --- a/packages/worker/test/workspace-cache-job.test.mjs +++ b/packages/worker/test/workspace-cache-job.test.mjs @@ -132,6 +132,8 @@ function createSql() { }; } +const workspaceCacheSandbox = { restoreBackup: async () => ({}), createBackup: async () => ({}) }; + function createHost(sql, overrides = {}) { const logs = []; const alarms = []; @@ -146,6 +148,7 @@ function createHost(sql, overrides = {}) { }, workerEnv: { DB: {}, Sandbox: {} }, redactionSecrets: [], + sandboxHandle: async () => ({ workspaceCache: workspaceCacheSandbox }), ctx: { storage: { setAlarm: async (when) => alarms.push(when), @@ -345,3 +348,17 @@ test("terminal cache jobs arm queued-work processing after a DO restart", () => assert.deepEqual(host.alarms, [2_000]); } }); + +test("a Session sandbox without a workspace cache ends the job without a snapshot", async () => { + const sql = createSql(); + const host = createHost(sql); + host.sandboxHandle = async () => ({}); + enqueueWorkspaceCacheJob(host, 1_000); + + const result = await processWorkspaceCacheJob(host, 1_000); + + assert.equal(result, "failed"); + assert.equal(getWorkspaceCacheJob(sql).status, "failed"); + assert.equal(getWorkspaceCacheJob(sql).snapshot_id, null); + assert.match(getWorkspaceCacheJob(sql).last_error, /not supported/); +}); From 7fbc01c56dc93e2759f42888ebf3e1cf48b2e297 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 20:22:21 +0530 Subject: [PATCH 04/32] fix(sandbox): gate preview sandbox access behind auth and tighten provider plumbing Co-Authored-By: Claude --- packages/worker/src/http-handlers.ts | 17 +- packages/worker/src/orchestrator.ts | 89 +++-------- packages/worker/src/orchestrator/preview.ts | 4 +- .../worker/src/orchestrator/sandbox-access.ts | 113 +++++++++++++ .../src/orchestrator/workspace-cache-job.ts | 5 +- .../worker/src/sandbox-provider/cloudflare.ts | 11 +- packages/worker/src/sandbox-provider/types.ts | 7 +- packages/worker/src/session-service.ts | 4 +- packages/worker/src/workspace-cache.ts | 4 +- packages/worker/test/helpers/fake-host.mjs | 4 +- .../worker/test/logging-redaction.test.mjs | 20 --- packages/worker/test/preview.test.mjs | 30 +++- packages/worker/test/sandbox-access.test.mjs | 149 ++++++++++++++++++ .../worker/test/sandbox-handlers.test.mjs | 39 +++++ .../test/sandbox-provider-cloudflare.test.mjs | 58 +++++++ packages/worker/test/session-service.test.mjs | 53 +++++++ .../worker/test/workspace-cache-job.test.mjs | 2 + 17 files changed, 497 insertions(+), 112 deletions(-) create mode 100644 packages/worker/src/orchestrator/sandbox-access.ts create mode 100644 packages/worker/test/sandbox-access.test.mjs diff --git a/packages/worker/src/http-handlers.ts b/packages/worker/src/http-handlers.ts index c8f4cef..2045a2a 100644 --- a/packages/worker/src/http-handlers.ts +++ b/packages/worker/src/http-handlers.ts @@ -50,8 +50,6 @@ import { configuredWebOrigins, missingAuthConfigKeys } from "./auth-config.js"; import { createEmailProvider } from "./email.js"; import { can, type AuthAction, CreateInvitationRequestSchema, SetupClaimRequestSchema } from "@codevil/shared"; import { configuredSandboxProviderName } from "./sandbox-provider/index.js"; -import { redactEvent } from "./redaction.js"; -import { collectWorkerSecretValues } from "./worker-env.js"; import type { Env } from "./worker-env.js"; import type { SocketAuthContext } from "./ws-authorization.js"; import { createSocketAuthToken } from "./ws-token.js"; @@ -523,6 +521,14 @@ export async function handleCreateSession( }, 400); } + // Resolve before touching D1 so a bad deployment setting leaves no Session row behind. + let sandboxProvider: ReturnType; + try { + sandboxProvider = configuredSandboxProviderName(env); + } catch { + return json({ error: "Sandbox provider is not configured" }, 500); + } + const sessionId = `ses_${crypto.randomUUID().replace(/-/g, "")}`; const now = new Date().toISOString(); const legacyGuards = legacyDirectoryGuardColumns(); @@ -595,7 +601,7 @@ export async function handleCreateSession( exec_model: normalized.exec_model, max_time: normalized.max_session_time, created_by: { id: auth.userId, name: auth.name }, - sandbox_provider: configuredSandboxProviderName(env), + sandbox_provider: sandboxProvider, }); } catch (error) { const failedAt = new Date().toISOString(); @@ -736,11 +742,6 @@ export async function handleDiagnostics(env: Env, sessionId: string): Promise implements OrchestratorHost } if (this.meta.state === "provisioning_sandbox" && now >= createdAt + 60_000) { - const logs = await this.readRedactedAgentLogs(); + const logs = await readRedactedAgentLogs(this.sandboxResolver, this.redactionSecrets); this.getTracer()?.log("ERROR", "sandbox.timeout", { stdout: logs?.stdout ?? "(none)", stderr: logs?.stderr ?? "(none)", @@ -756,83 +759,36 @@ export class Orchestrator extends DurableObject implements OrchestratorHost } async sandboxHandle(): Promise { - // Legacy Cloudflare Sessions predate `sandbox_ref`; their id is the Session id. - const ref = this.meta?.sandbox_ref - ?? (this.meta && this.sandboxProvider().name === "cloudflare" - ? { provider: "cloudflare" as const, id: this.meta.session_id } - : undefined); - if (!ref) return null; - const secret = await this.ctx.storage.get("codevil:sandbox_secret"); - return this.sandboxProvider().connect(ref, secret ? { secret } : undefined); - } - - /** Agent process logs, redacted; null when the sandbox cannot be read. */ - private async readRedactedAgentLogs(): Promise<{ stdout: string; stderr: string } | null> { - try { - const handle = await this.sandboxHandle(); - if (!handle) return null; - return redactEvent(await handle.readProcessLogs("codevil-agent"), this.redactionSecrets); - } catch { - return null; - } + return connectSandboxHandle({ + meta: this.meta, + provider: this.sandboxProvider(), + readSecret: () => this.ctx.storage.get("codevil:sandbox_secret"), + }); } - private async collectDiagnostics(): Promise { - const handle = await this.requireSandboxHandle(); - return collectSandboxDiagnostics( - { - getProcessLogs: (processId) => handle.readProcessLogs(processId), - ...(handle.readLifecycle - ? { getCodevilLifecycleSnapshot: () => handle.readLifecycle!() as Promise } - : {}), - }, - "codevil-agent", - this.redactionSecrets, - ); - } + private readonly sandboxResolver = (): Promise => this.sandboxHandle(); /** RPC for `GET /sessions/:id/logs`. */ async readSandboxLogs(): Promise { - try { - const handle = await this.requireSandboxHandle(); - const logs = await handle.readProcessLogs("codevil-agent"); - return Response.json(redactEvent(logs, this.redactionSecrets), { status: 200 }); - } catch { - return Response.json({ error: "Failed to read sandbox logs" }, { status: 500 }); - } + this.loadMeta(); + return sandboxLogsResponse(this.sandboxResolver, this.redactionSecrets); } /** RPC for `GET /sessions/:id/diagnostics`. */ async readSandboxDiagnosticsResponse(): Promise { - try { - return Response.json( - redactEvent(await this.collectDiagnostics(), this.redactionSecrets), - { status: 200 }, - ); - } catch { - return Response.json({ error: "Failed to read sandbox diagnostics" }, { status: 500 }); - } - } - - private async requireSandboxHandle(): Promise { this.loadMeta(); - const handle = await this.sandboxHandle(); - if (!handle) throw new Error("Sandbox not found"); - return handle; + return sandboxDiagnosticsResponse(this.sandboxResolver, this.redactionSecrets); } private async terminateSandbox(reason: string): Promise { if (!this.meta) return; this.meta.expected_close = true; this.saveMeta(); - try { - const handle = await this.sandboxHandle(); - await handle?.destroy(reason); - } catch (error) { + await destroySandbox(this.sandboxResolver, reason, (error) => { this.getTracer()?.log("ERROR", "sandbox.stop.failed", { ...redactEvent(safeExceptionAttributes(error), this.redactionSecrets), }); - } + }); this.closeSandboxSockets(reason); } @@ -1115,7 +1071,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost state: SessionState; }): Promise { try { - const diagnostics = await this.collectDiagnostics(); + const diagnostics = await collectAgentDiagnostics(this.sandboxResolver, this.redactionSecrets); const payload = buildSandboxDisconnectLogPayload({ sessionId: options.sessionId, closeCode: options.closeCode, @@ -1171,8 +1127,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost return new Response("Preview is not active.", { status: 404 }); } - const handle = this.meta.preview_active ? await this.sandboxHandle() : null; - return proxyPreviewRequest(request, this.meta, token, handle); + return proxyPreviewRequest(request, this.meta, token, () => this.sandboxHandle()); } submitAgentRequest(args: { diff --git a/packages/worker/src/orchestrator/preview.ts b/packages/worker/src/orchestrator/preview.ts index 1a81cc9..43a155a 100644 --- a/packages/worker/src/orchestrator/preview.ts +++ b/packages/worker/src/orchestrator/preview.ts @@ -61,7 +61,8 @@ export async function proxyPreviewRequest( request: Request, meta: SessionMeta, token: string, - handle: SandboxHandle | null, + /** Resolved only after access and token checks pass, so unauthenticated requests never reach the sandbox. */ + resolveHandle: () => Promise, ): Promise { const blocked = validatePreviewAccess(meta); if (blocked) return blocked; @@ -71,6 +72,7 @@ export async function proxyPreviewRequest( return new Response("Unknown preview token.", { status: 404 }); } + const handle = await resolveHandle(); if (!handle) return new Response("Preview is not active.", { status: 404 }); const originalUrl = new URL(request.url); diff --git a/packages/worker/src/orchestrator/sandbox-access.ts b/packages/worker/src/orchestrator/sandbox-access.ts new file mode 100644 index 0000000..2496d83 --- /dev/null +++ b/packages/worker/src/orchestrator/sandbox-access.ts @@ -0,0 +1,113 @@ +import { redactEvent } from "../redaction.js"; +import { + collectSandboxDiagnostics, + type SandboxDiagnostics, +} from "../sandbox.js"; +import type { + SandboxHandle, + SandboxProvider, + SandboxRef, +} from "../sandbox-provider/types.js"; + +export const AGENT_PROCESS_ID = "codevil-agent"; + +export type SandboxHandleResolver = () => Promise; + +/** + * The sandbox a Session talks to. Legacy Cloudflare Sessions predate + * `sandbox_ref`; their sandbox id is the Session id. Any other provider + * without a ref has no sandbox yet. + */ +export function sandboxRefForMeta( + meta: { session_id: string; sandbox_ref?: SandboxRef } | null, + provider: Pick, +): SandboxRef | null { + if (!meta) return null; + if (meta.sandbox_ref) return meta.sandbox_ref; + return provider.name === "cloudflare" + ? { provider: "cloudflare", id: meta.session_id } + : null; +} + +export async function connectSandboxHandle(options: { + meta: { session_id: string; sandbox_ref?: SandboxRef } | null; + provider: SandboxProvider; + readSecret: () => Promise; +}): Promise { + const ref = sandboxRefForMeta(options.meta, options.provider); + if (!ref) return null; + const secret = await options.readSecret(); + return options.provider.connect(ref, secret ? { secret } : undefined); +} + +/** Destroy the Session's sandbox; failures are reported, never thrown. */ +export async function destroySandbox( + resolveHandle: SandboxHandleResolver, + reason: string, + onError: (error: unknown) => void, +): Promise { + try { + const handle = await resolveHandle(); + await handle?.destroy(reason); + } catch (error) { + onError(error); + } +} + +/** Agent process logs, redacted; null when the sandbox cannot be read. */ +export async function readRedactedAgentLogs( + resolveHandle: SandboxHandleResolver, + secrets: readonly string[], +): Promise<{ stdout: string; stderr: string } | null> { + try { + const handle = await resolveHandle(); + if (!handle) return null; + return redactEvent(await handle.readProcessLogs(AGENT_PROCESS_ID), secrets); + } catch { + return null; + } +} + +export async function collectAgentDiagnostics( + resolveHandle: SandboxHandleResolver, + secrets: readonly string[], +): Promise { + const handle = await resolveHandle(); + if (!handle) throw new Error("Sandbox not found"); + return collectSandboxDiagnostics( + { + getProcessLogs: (processId) => handle.readProcessLogs(processId), + ...(handle.readLifecycle ? { getCodevilLifecycleSnapshot: () => handle.readLifecycle!() } : {}), + }, + AGENT_PROCESS_ID, + secrets, + ); +} + +/** Body and status for `GET /sessions/:id/logs`. Redacted at this boundary. */ +export async function sandboxLogsResponse( + resolveHandle: SandboxHandleResolver, + secrets: readonly string[], +): Promise { + try { + const handle = await resolveHandle(); + if (!handle) throw new Error("Sandbox not found"); + const logs = await handle.readProcessLogs(AGENT_PROCESS_ID); + return Response.json(redactEvent(logs, secrets), { status: 200 }); + } catch { + return Response.json({ error: "Failed to read sandbox logs" }, { status: 500 }); + } +} + +/** Body and status for `GET /sessions/:id/diagnostics`. Redacted at this boundary. */ +export async function sandboxDiagnosticsResponse( + resolveHandle: SandboxHandleResolver, + secrets: readonly string[], +): Promise { + try { + const diagnostics = await collectAgentDiagnostics(resolveHandle, secrets); + return Response.json(redactEvent(diagnostics, secrets), { status: 200 }); + } catch { + return Response.json({ error: "Failed to read sandbox diagnostics" }, { status: 500 }); + } +} diff --git a/packages/worker/src/orchestrator/workspace-cache-job.ts b/packages/worker/src/orchestrator/workspace-cache-job.ts index ade39c2..8e815aa 100644 --- a/packages/worker/src/orchestrator/workspace-cache-job.ts +++ b/packages/worker/src/orchestrator/workspace-cache-job.ts @@ -5,6 +5,7 @@ import { createWorkspaceCacheSnapshotForSandbox, isRetryableWorkspaceCacheError, WORKSPACE_CACHE_TTL_SECONDS, + WORKSPACE_CACHE_UNSUPPORTED_REASON, WORKSPACE_CACHE_VERSION, type WorkspaceCacheCreateResult, type WorkspaceCacheSandbox, @@ -227,7 +228,9 @@ export async function processWorkspaceCacheJob( new Date().toISOString(), WORKSPACE_CACHE_JOB_ID, ); - host.getTracer()?.log("ERROR", "workspace_cache.create.failed", { + // A provider without a workspace cache is a skip, not a failure. + const level = result.reason === WORKSPACE_CACHE_UNSUPPORTED_REASON ? "INFO" : "ERROR"; + host.getTracer()?.log(level, "workspace_cache.create.failed", { phase: result.phase ?? "unknown", reason, repo: job.repo, diff --git a/packages/worker/src/sandbox-provider/cloudflare.ts b/packages/worker/src/sandbox-provider/cloudflare.ts index 50d2745..95705fc 100644 --- a/packages/worker/src/sandbox-provider/cloudflare.ts +++ b/packages/worker/src/sandbox-provider/cloudflare.ts @@ -14,7 +14,7 @@ import type { } from "./types.js"; /** The slice of `@cloudflare/sandbox`'s Sandbox the adapter relies on. */ -export interface CloudflareSandboxLike { +export interface CloudflareSandboxLike extends WorkspaceCacheSandbox { exec(command: string, options?: { cwd?: string; env?: Record; @@ -94,7 +94,12 @@ function cloudflareHandle(sandbox: CloudflareSandboxLike, ref: SandboxRef): Sand const steps: string[] = []; if (options?.mode !== undefined) steps.push(`chmod ${options.mode.toString(8)} ${shellQuote(path)}`); if (options?.owner !== undefined) steps.push(`chown ${OWNER_IDS[options.owner]} ${shellQuote(path)}`); - if (steps.length > 0) await exec(steps.join(" && ")); + if (steps.length > 0) { + const result = await exec(steps.join(" && ")); + if (result.exitCode !== 0) { + throw new Error(`Failed to set permissions on ${path} (exit ${result.exitCode}): ${result.stderr}`); + } + } }, async startProcess(command, { processId, cwd, env }) { await retrySandboxOperation(() => @@ -117,7 +122,7 @@ function cloudflareHandle(sandbox: CloudflareSandboxLike, ref: SandboxRef): Sand await setCodevilSandboxKeepAlive(sandbox, false, reason); await sandbox.stop(); }, - workspaceCache: sandbox as unknown as WorkspaceCacheSandbox, + workspaceCache: sandbox, }; } diff --git a/packages/worker/src/sandbox-provider/types.ts b/packages/worker/src/sandbox-provider/types.ts index ab38398..edc566c 100644 --- a/packages/worker/src/sandbox-provider/types.ts +++ b/packages/worker/src/sandbox-provider/types.ts @@ -1,12 +1,11 @@ +import type { SandboxLifecycleSnapshot } from "../sandbox.js"; + export type SandboxProviderName = "e2b" | "cloudflare"; export const SANDBOX_PROVIDER_NAMES: readonly SandboxProviderName[] = ["e2b", "cloudflare"]; export interface SandboxRef { provider: SandboxProviderName; id: string } export interface ShellResult { stdout: string; stderr: string; exitCode: number } -export interface SandboxLifecycleView { - keepAlive?: { active: boolean; reason?: string; updated_at?: string }; - lastEvent?: { type: string; at: string; exit_code?: number; reason?: string; error?: string }; -} +export type SandboxLifecycleView = SandboxLifecycleSnapshot; export interface SandboxProvider { readonly name: SandboxProviderName; diff --git a/packages/worker/src/session-service.ts b/packages/worker/src/session-service.ts index e8faf6f..e8bf85c 100644 --- a/packages/worker/src/session-service.ts +++ b/packages/worker/src/session-service.ts @@ -34,6 +34,8 @@ export async function createSession( const normalized = isNormalizedCreateSession(input) ? input : normalizeCreateSessionBody(input); + // Resolve before touching D1 so a bad deployment setting leaves no Session row behind. + const sandboxProvider = configuredSandboxProviderName(env); const sessionId = `ses_${crypto.randomUUID().replace(/-/g, "")}`; const now = new Date().toISOString(); const origin = new URL(requestUrlOrOrigin).origin; @@ -73,7 +75,7 @@ export async function createSession( exec_model: normalized.exec_model, max_time: normalized.max_session_time, created_by: { id: createdBy.id, name: createdBy.name }, - sandbox_provider: configuredSandboxProviderName(env), + sandbox_provider: sandboxProvider, }); } catch (error) { const failedAt = new Date().toISOString(); diff --git a/packages/worker/src/workspace-cache.ts b/packages/worker/src/workspace-cache.ts index d58af25..b0a5064 100644 --- a/packages/worker/src/workspace-cache.ts +++ b/packages/worker/src/workspace-cache.ts @@ -213,6 +213,8 @@ export async function createWorkspaceCacheSnapshot(input: { } } +export const WORKSPACE_CACHE_UNSUPPORTED_REASON = "workspace cache is not supported by this sandbox provider"; + export async function createWorkspaceCacheSnapshotForSandbox(input: { db: D1Database; /** Absent when the Session's sandbox provider has no workspace cache. */ @@ -224,7 +226,7 @@ export async function createWorkspaceCacheSnapshotForSandbox(input: { return { created: false, phase: "backup", - reason: "workspace cache is not supported by this sandbox provider", + reason: WORKSPACE_CACHE_UNSUPPORTED_REASON, }; } return createWorkspaceCacheSnapshot({ diff --git a/packages/worker/test/helpers/fake-host.mjs b/packages/worker/test/helpers/fake-host.mjs index 6a9e968..b5ffbea 100644 --- a/packages/worker/test/helpers/fake-host.mjs +++ b/packages/worker/test/helpers/fake-host.mjs @@ -204,7 +204,9 @@ export function createFakeHost(metaOverrides = {}, options = {}) { }, async sandboxHandle() { if (options.sandboxHandle !== undefined) return options.sandboxHandle; - return meta.sandbox_ref ? createFakeSandboxHandle({ ref: meta.sandbox_ref }) : null; + return createFakeSandboxHandle({ + ref: meta.sandbox_ref ?? { provider: "cloudflare", id: meta.session_id }, + }); }, }; diff --git a/packages/worker/test/logging-redaction.test.mjs b/packages/worker/test/logging-redaction.test.mjs index 3666329..1121816 100644 --- a/packages/worker/test/logging-redaction.test.mjs +++ b/packages/worker/test/logging-redaction.test.mjs @@ -9,7 +9,6 @@ import { sandboxLifecycleLogger, } from "../dist/logging.js"; import { handleCreateSession } from "../dist/http-handlers.js"; -import { redactSandboxDiagnosticResponse } from "../dist/http-handlers.js"; test("worker logging redacts configured secrets from exceptions and nested sandbox output", () => { const lines = []; @@ -145,22 +144,3 @@ test("final logging boundary never coerces hostile Error fields", () => { assert.doesNotThrow(() => workerLog("ERROR", "hostile.error", { error }, [])); }); -test("sandbox diagnostics responses redact deployment secrets and tolerate hostile errors", () => { - const secret = "diagnostics-response-secret"; - const hostile = {}; - Object.defineProperty(hostile, "message", { - get() { - throw new Error("must not read hostile error"); - }, - }); - - const response = redactSandboxDiagnosticResponse({ - logs: { stdout: `stdout ${secret}`, stderr: `stderr ${secret}` }, - lifecycle: { lastEvent: { type: "error", at: "2026-07-10", error: secret } }, - errors: { logs: hostile }, - }, { CODEVIL_API_KEY: secret }); - - const serialized = JSON.stringify(response); - assert.doesNotMatch(serialized, new RegExp(secret)); - assert.match(serialized, /\[REDACTED\]/); -}); diff --git a/packages/worker/test/preview.test.mjs b/packages/worker/test/preview.test.mjs index 6dc87fb..e7c09df 100644 --- a/packages/worker/test/preview.test.mjs +++ b/packages/worker/test/preview.test.mjs @@ -129,7 +129,7 @@ test("proxyPreviewRequest forwards through the handle's port and keeps the path" }; const request = new Request("https://worker.example/sessions/ses_abc/preview/tok/app.js?x=1"); - const response = await proxyPreviewRequest(request, await activePreviewMeta("tok"), "tok", handle); + const response = await proxyPreviewRequest(request, await activePreviewMeta("tok"), "tok", async () => handle); assert.equal(await response.text(), "body"); assert.equal(response.headers.get("Cache-Control"), "no-store"); @@ -138,15 +138,35 @@ test("proxyPreviewRequest forwards through the handle's port and keeps the path" test("proxyPreviewRequest answers 404 when the Session has no sandbox handle", async () => { const request = new Request("https://worker.example/sessions/ses_abc/preview/tok/"); - const response = await proxyPreviewRequest(request, await activePreviewMeta("tok"), "tok", null); + const response = await proxyPreviewRequest(request, await activePreviewMeta("tok"), "tok", async () => null); assert.equal(response.status, 404); assert.equal(await response.text(), "Preview is not active."); }); -test("proxyPreviewRequest still rejects unknown tokens before using the handle", async () => { - const handle = { fetchPort: async () => assert.fail("must not reach the sandbox") }; +test("proxyPreviewRequest still rejects unknown tokens before resolving the sandbox", async () => { const request = new Request("https://worker.example/sessions/ses_abc/preview/nope/"); - const response = await proxyPreviewRequest(request, await activePreviewMeta("tok"), "nope", handle); + const response = await proxyPreviewRequest(request, await activePreviewMeta("tok"), "nope", async () => assert.fail("must not resolve the sandbox")); assert.equal(response.status, 404); assert.equal(await response.text(), "Unknown preview token."); }); + +test("proxyPreviewRequest never resolves the sandbox for ended or inactive previews", async () => { + const resolve = async () => assert.fail("must not resolve the sandbox"); + const request = new Request("https://worker.example/sessions/ses_abc/preview/tok/"); + + const ended = await proxyPreviewRequest( + request, + { ...(await activePreviewMeta("tok")), state: "completed" }, + "tok", + resolve, + ); + assert.equal(ended.status, 410); + + const inactive = await proxyPreviewRequest( + request, + { ...(await activePreviewMeta("tok")), preview_active: false }, + "tok", + resolve, + ); + assert.equal(inactive.status, 404); +}); diff --git a/packages/worker/test/sandbox-access.test.mjs b/packages/worker/test/sandbox-access.test.mjs new file mode 100644 index 0000000..518a6da --- /dev/null +++ b/packages/worker/test/sandbox-access.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + connectSandboxHandle, + destroySandbox, + readRedactedAgentLogs, + sandboxDiagnosticsResponse, + sandboxLogsResponse, + sandboxRefForMeta, +} from "../dist/orchestrator/sandbox-access.js"; +import { createFakeSandboxHandle } from "./helpers/fake-host.mjs"; + +const secret = "diagnostics-response-secret"; + +function recordingProvider(name) { + const connects = []; + return { + connects, + provider: { + name, + capabilities: { pauseResume: false, workspaceCache: false }, + create: async () => assert.fail("create must not be called"), + connect: async (ref, options) => { + connects.push([ref, options]); + return createFakeSandboxHandle({ ref }); + }, + }, + }; +} + +test("a legacy Cloudflare Session without sandbox_ref uses its Session id", async () => { + const { provider, connects } = recordingProvider("cloudflare"); + const handle = await connectSandboxHandle({ + meta: { session_id: "ses_legacy" }, + provider, + readSecret: async () => undefined, + }); + assert.deepEqual(handle.ref, { provider: "cloudflare", id: "ses_legacy" }); + assert.deepEqual(connects, [[{ provider: "cloudflare", id: "ses_legacy" }, undefined]]); +}); + +test("an e2b Session without sandbox_ref has no sandbox yet", async () => { + const { provider, connects } = recordingProvider("e2b"); + assert.equal(sandboxRefForMeta({ session_id: "ses_1" }, provider), null); + const handle = await connectSandboxHandle({ + meta: { session_id: "ses_1" }, + provider, + readSecret: async () => assert.fail("must not read the secret without a ref"), + }); + assert.equal(handle, null); + assert.deepEqual(connects, []); +}); + +test("a Session without meta has no sandbox", () => { + assert.equal(sandboxRefForMeta(null, { name: "cloudflare" }), null); +}); + +test("an explicit sandbox_ref wins and the stored secret is passed to connect", async () => { + const { provider, connects } = recordingProvider("e2b"); + const ref = { provider: "e2b", id: "sbx_1" }; + await connectSandboxHandle({ + meta: { session_id: "ses_1", sandbox_ref: ref }, + provider, + readSecret: async () => "traffic-token", + }); + assert.deepEqual(connects, [[ref, { secret: "traffic-token" }]]); +}); + +test("destroySandbox destroys the resolved handle with the reason", async () => { + const reasons = []; + const handle = createFakeSandboxHandle({ destroy: async (reason) => { reasons.push(reason); } }); + await destroySandbox(async () => handle, "stopped by user", () => assert.fail("no error expected")); + assert.deepEqual(reasons, ["stopped by user"]); +}); + +test("destroySandbox is a no-op without a handle and reports failures instead of throwing", async () => { + await destroySandbox(async () => null, "x", () => assert.fail("no error expected")); + + const errors = []; + const handle = createFakeSandboxHandle({ destroy: async () => { throw new Error("stop failed"); } }); + await destroySandbox(async () => handle, "x", (error) => errors.push(error.message)); + await destroySandbox(async () => { throw new Error("connect failed"); }, "x", (error) => errors.push(error.message)); + assert.deepEqual(errors, ["stop failed", "connect failed"]); +}); + +test("readRedactedAgentLogs redacts and returns null on failure", async () => { + const handle = createFakeSandboxHandle({ + readProcessLogs: async (id) => ({ stdout: `${id} ${secret}`, stderr: "" }), + }); + assert.deepEqual( + await readRedactedAgentLogs(async () => handle, [secret]), + { stdout: "codevil-agent [REDACTED]", stderr: "" }, + ); + assert.equal(await readRedactedAgentLogs(async () => null, [secret]), null); + assert.equal(await readRedactedAgentLogs(async () => { throw new Error("x"); }, [secret]), null); +}); + +test("logs response redacts deployment secrets with status 200", async () => { + const handle = createFakeSandboxHandle({ + readProcessLogs: async () => ({ stdout: `stdout ${secret}`, stderr: `stderr ${secret}` }), + }); + const response = await sandboxLogsResponse(async () => handle, [secret]); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(body, { stdout: "stdout [REDACTED]", stderr: "stderr [REDACTED]" }); +}); + +test("logs response is a 500 with the legacy body when no sandbox or read fails", async () => { + for (const resolve of [ + async () => null, + async () => createFakeSandboxHandle({ readProcessLogs: async () => { throw new Error(secret); } }), + ]) { + const response = await sandboxLogsResponse(resolve, [secret]); + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "Failed to read sandbox logs" }); + } +}); + +test("diagnostics response redacts secrets and tolerates hostile errors", async () => { + const hostile = {}; + Object.defineProperty(hostile, "message", { + get() { + throw new Error("must not read hostile error"); + }, + }); + const handle = createFakeSandboxHandle({ + readProcessLogs: async () => ({ stdout: `stdout ${secret}`, stderr: `stderr ${secret}` }), + readLifecycle: async () => ({ lastEvent: { type: "error", at: "2026-07-10", error: secret } }), + }); + const response = await sandboxDiagnosticsResponse(async () => handle, [secret]); + assert.equal(response.status, 200); + const body = await response.json(); + assert.doesNotMatch(JSON.stringify(body), new RegExp(secret)); + assert.match(JSON.stringify(body), /\[REDACTED\]/); + assert.equal(body.logs.stdout, "stdout [REDACTED]"); + assert.equal(body.lifecycle.lastEvent.error, "[REDACTED]"); + + const failing = createFakeSandboxHandle({ readProcessLogs: async () => { throw hostile; } }); + const partial = await sandboxDiagnosticsResponse(async () => failing, [secret]); + assert.equal(partial.status, 200); + assert.equal((await partial.json()).logs, null); +}); + +test("diagnostics response is a 500 with the legacy body when no sandbox exists", async () => { + const response = await sandboxDiagnosticsResponse(async () => null, [secret]); + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "Failed to read sandbox diagnostics" }); +}); diff --git a/packages/worker/test/sandbox-handlers.test.mjs b/packages/worker/test/sandbox-handlers.test.mjs index dc79adf..a367186 100644 --- a/packages/worker/test/sandbox-handlers.test.mjs +++ b/packages/worker/test/sandbox-handlers.test.mjs @@ -12,6 +12,7 @@ import { provisionSessionSandbox, } from "../dist/orchestrator/sandbox-handlers.js"; import { processWorkspaceCacheJob } from "../dist/orchestrator/workspace-cache-job.js"; +import { createCloudflareSandboxProvider } from "../dist/sandbox-provider/cloudflare.js"; import { handleSandboxProxy } from "../dist/sandbox-proxy.js"; import { actor, @@ -508,6 +509,44 @@ test("provisionSessionSandbox creates through the provider, records the ref, res ]); }); +test("provisionSessionSandbox through the Cloudflare adapter keeps keepalive, then restore, then agent start", async () => { + const calls = []; + const sandbox = { + setKeepAlive: async (active) => calls.push(["setKeepAlive", active]), + setCodevilKeepAlive: async (active, reason) => calls.push(["setCodevilKeepAlive", active, reason]), + restoreBackup: async (backup) => calls.push(["restoreBackup", backup]), + createBackup: async () => ({}), + startProcess: async (_command, options) => calls.push(["startProcess", options.processId, options.autoCleanup]), + }; + const provider = createCloudflareSandboxProvider({ binding: {}, getSandbox: () => sandbox }); + const snapshotRow = { + id: "wsc_1", + backup_id: "backup_1", + backup_dir: "/workspace", + backup_local_bucket: 0, + }; + const env = { + ...provisioningEnv(), + DB: { prepare: () => ({ bind: () => ({ first: async () => snapshotRow, run: async () => ({}) }) }) }, + }; + const { host } = createFakeHost( + { state: "initializing" }, + { tracer: createFakeTracer(), sandboxProvider: provider, workerEnv: env }, + ); + + await provisionSessionSandbox(host); + + assert.equal(host.meta.state, "provisioning_sandbox"); + assert.equal(host.meta.workspace_cache_restored, true); + assert.deepEqual(host.meta.sandbox_ref, { provider: "cloudflare", id: "ses_test" }); + assert.deepEqual(calls, [ + ["setKeepAlive", true], + ["setCodevilKeepAlive", true, "session provisioning"], + ["restoreBackup", { id: "backup_1", dir: "/workspace" }], + ["startProcess", "codevil-agent", true], + ]); +}); + test("provisionSessionSandbox skips the workspace cache when the provider has none", async () => { let restored = false; const handle = createFakeSandboxHandle({ diff --git a/packages/worker/test/sandbox-provider-cloudflare.test.mjs b/packages/worker/test/sandbox-provider-cloudflare.test.mjs index 44e8a43..dab67b1 100644 --- a/packages/worker/test/sandbox-provider-cloudflare.test.mjs +++ b/packages/worker/test/sandbox-provider-cloudflare.test.mjs @@ -99,6 +99,64 @@ test("cloudflare writeFile applies mode and owner only when requested", async () ]); }); +test("cloudflare writeFile fails loudly when chmod/chown fails", async () => { + const sandbox = fakeCloudflareSandbox(); + sandbox.exec = async () => ({ success: false, exitCode: 1, stdout: "", stderr: "chown: invalid user" }); + const { provider } = providerWith(sandbox); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + await assert.rejects( + () => handle.writeFile("/etc/key", "y", { mode: 0o600, owner: "codevil" }), + /Failed to set permissions on \/etc\/key \(exit 1\): chown: invalid user/, + ); +}); + +async function withImmediateTimers(fn) { + const original = globalThis.setTimeout; + globalThis.setTimeout = (callback) => original(callback, 0); + try { + return await fn(); + } finally { + globalThis.setTimeout = original; + } +} + +test("cloudflare create retries transient keepalive failures", async () => { + const sandbox = fakeCloudflareSandbox(); + let attempts = 0; + sandbox.setKeepAlive = async (active) => { + attempts += 1; + if (attempts === 1) throw new Error("503 temporarily unavailable"); + sandbox.calls.push(["setKeepAlive", active]); + }; + const { provider } = providerWith(sandbox); + await withImmediateTimers(() => provider.create({ sessionId: "ses_1", leaseMs: 1 })); + assert.equal(attempts, 2); + assert.deepEqual(sandbox.calls, [["setKeepAlive", true], ["setCodevilKeepAlive", true, "session provisioning"]]); +}); + +test("cloudflare startProcess retries transient failures but not permanent ones", async () => { + const sandbox = fakeCloudflareSandbox(); + let attempts = 0; + sandbox.startProcess = async (command, options) => { + attempts += 1; + if (attempts === 1) throw new Error("no container instance available"); + sandbox.calls.push(["startProcess", command, options]); + }; + const { provider } = providerWith(sandbox); + const handle = await provider.connect({ provider: "cloudflare", id: "ses_1" }); + await withImmediateTimers(() => handle.startProcess("node x", { processId: "p", cwd: "/workspace", env: {} })); + assert.equal(attempts, 2); + assert.equal(sandbox.calls.length, 1); + + sandbox.startProcess = async () => { attempts += 1; throw new Error("syntax error"); }; + attempts = 0; + await assert.rejects( + () => handle.startProcess("node x", { processId: "p", cwd: "/workspace", env: {} }), + /syntax error/, + ); + assert.equal(attempts, 1); +}); + test("cloudflare renewLease is a no-op", async () => { const sandbox = fakeCloudflareSandbox(); const { provider } = providerWith(sandbox); diff --git a/packages/worker/test/session-service.test.mjs b/packages/worker/test/session-service.test.mjs index d202a09..53a690b 100644 --- a/packages/worker/test/session-service.test.mjs +++ b/packages/worker/test/session-service.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { handleCreateSession } from "../dist/http-handlers.js"; import { createSession } from "../dist/session-service.js"; import { normalizeCreateSessionBody } from "../dist/session-directory.js"; @@ -119,6 +120,58 @@ test("createSession marks the session failed when orchestrator init throws", asy assert.equal(failedRow.sandbox_state, "failed"); }); +test("createSession rejects a bad SANDBOX_PROVIDER before writing a Session row", async () => { + const db = createFakeDb(); + const env = { + DB: db, + SANDBOX_PROVIDER: "modal", + ORCHESTRATOR: { + idFromName: () => assert.fail("must not reach the orchestrator"), + get: () => assert.fail("must not reach the orchestrator"), + }, + }; + + await assert.rejects( + () => createSession( + env, + "https://codevil.example.workers.dev", + { repo: "github.com/acme/app" }, + { id: "usr_123", name: "Alice" }, + ), + /Unsupported SANDBOX_PROVIDER/, + ); + + assert.equal(db.rows.size, 0); +}); + +test("handleCreateSession fails on a bad SANDBOX_PROVIDER without touching D1 writes", async () => { + const writes = []; + const env = { + SANDBOX_PROVIDER: "modal", + DB: { + prepare: (sql) => ({ bind: () => { writes.push(sql); return { run: async () => ({}) }; } }), + batch: async () => { writes.push("batch"); }, + }, + ORCHESTRATOR: { + idFromName: () => assert.fail("must not reach the orchestrator"), + get: () => assert.fail("must not reach the orchestrator"), + }, + }; + + const response = await handleCreateSession( + new Request("https://codevil.example/sessions", { + method: "POST", + body: JSON.stringify({ repo: "github.com/acme/app" }), + }), + env, + { userId: "usr_123", email: "owner@example.com", name: "Owner", role: "owner" }, + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "Sandbox provider is not configured" }); + assert.deepEqual(writes, []); +}); + function createFakeDb() { const rows = new Map(); return { diff --git a/packages/worker/test/workspace-cache-job.test.mjs b/packages/worker/test/workspace-cache-job.test.mjs index 36ff811..874877b 100644 --- a/packages/worker/test/workspace-cache-job.test.mjs +++ b/packages/worker/test/workspace-cache-job.test.mjs @@ -361,4 +361,6 @@ test("a Session sandbox without a workspace cache ends the job without a snapsho assert.equal(getWorkspaceCacheJob(sql).status, "failed"); assert.equal(getWorkspaceCacheJob(sql).snapshot_id, null); assert.match(getWorkspaceCacheJob(sql).last_error, /not supported/); + const skipLog = host.logs.find(([, event]) => event === "workspace_cache.create.failed"); + assert.equal(skipLog[0], "INFO"); }); From fb5bc61a74ac917e10b68ef42b712ac63b76d9d1 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 20:24:34 +0530 Subject: [PATCH 05/32] feat(sandbox): add idle-pause session fields and paused sandbox state --- packages/shared/src/room.ts | 1 + packages/shared/src/session-meta-schema.ts | 4 +++ .../shared/test/session-meta-schema.test.mjs | 35 ++++++++++++++++++- packages/worker/src/http-handlers.ts | 1 + packages/worker/src/orchestrator.ts | 5 ++- packages/worker/src/orchestrator/types.ts | 1 + packages/worker/src/session-service.ts | 1 + packages/worker/test/session-service.test.mjs | 2 ++ 8 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/room.ts b/packages/shared/src/room.ts index 2d62e8c..fe0676a 100644 --- a/packages/shared/src/room.ts +++ b/packages/shared/src/room.ts @@ -16,6 +16,7 @@ export const SandboxStateSchema = z.enum([ "ready", "stopping", "stopped", + "paused", "timed_out", "failed", ]); diff --git a/packages/shared/src/session-meta-schema.ts b/packages/shared/src/session-meta-schema.ts index 54904fc..aeb52f6 100644 --- a/packages/shared/src/session-meta-schema.ts +++ b/packages/shared/src/session-meta-schema.ts @@ -39,6 +39,10 @@ export const SessionMetaSchema = z.object({ workspace_cache_restored: z.boolean().optional(), sandbox_provider: z.enum(["e2b", "cloudflare"]).optional(), sandbox_ref: z.object({ provider: z.enum(["e2b", "cloudflare"]), id: z.string() }).optional(), + max_idle_time: z.string().optional(), + last_activity_at: z.string().optional(), + sandbox_paused_at: z.string().optional(), + sandbox_lease_renewed_at: z.string().optional(), last_decision: LastDecisionSchema.optional(), }); diff --git a/packages/shared/test/session-meta-schema.test.mjs b/packages/shared/test/session-meta-schema.test.mjs index 48b6f3d..3f201de 100644 --- a/packages/shared/test/session-meta-schema.test.mjs +++ b/packages/shared/test/session-meta-schema.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { SessionMetaSchema } from "../dist/index.js"; +import { SandboxStateSchema, SessionMetaSchema } from "../dist/index.js"; test("SessionMetaSchema: accepts a minimal valid meta blob", () => { const parsed = SessionMetaSchema.parse({ @@ -41,3 +41,36 @@ test("SessionMetaSchema: rejects invalid session state", () => { created_at: "2026-01-01T00:00:00.000Z", })); }); + +test("SessionMetaSchema: accepts idle-pause and provider fields", () => { + const parsed = SessionMetaSchema.parse({ + session_id: "ses_1", + prompt: "", + repo: "r", + worker_url: "https://w", + provider: "openai", + plan_model: "m", + exec_model: "m", + max_time: "30m", + state: "ready", + refinement_round: 0, + verification_attempts: 0, + cost_total_usd: 0, + created_at: "2026-10-01T00:00:00.000Z", + sandbox_provider: "e2b", + sandbox_ref: { provider: "e2b", id: "sbx_1" }, + max_idle_time: "10m", + last_activity_at: "2026-10-01T00:00:00.000Z", + sandbox_paused_at: "2026-10-01T00:10:00.000Z", + sandbox_lease_renewed_at: "2026-10-01T00:05:00.000Z", + }); + assert.equal(parsed.sandbox_ref.id, "sbx_1"); + assert.equal(parsed.max_idle_time, "10m"); + assert.equal(parsed.last_activity_at, "2026-10-01T00:00:00.000Z"); + assert.equal(parsed.sandbox_paused_at, "2026-10-01T00:10:00.000Z"); + assert.equal(parsed.sandbox_lease_renewed_at, "2026-10-01T00:05:00.000Z"); +}); + +test("SandboxStateSchema: includes paused", () => { + assert.equal(SandboxStateSchema.parse("paused"), "paused"); +}); diff --git a/packages/worker/src/http-handlers.ts b/packages/worker/src/http-handlers.ts index 2045a2a..fe48f59 100644 --- a/packages/worker/src/http-handlers.ts +++ b/packages/worker/src/http-handlers.ts @@ -602,6 +602,7 @@ export async function handleCreateSession( max_time: normalized.max_session_time, created_by: { id: auth.userId, name: auth.name }, sandbox_provider: sandboxProvider, + max_idle_time: normalized.max_idle_time, }); } catch (error) { const failedAt = new Date().toISOString(); diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index e2f5f0b..d5f9407 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -251,6 +251,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost } async init(sessionId: string, prompt: string, repo: string, options: InitOptions): Promise { + const createdAt = new Date().toISOString(); this.meta = { session_id: sessionId, prompt, @@ -268,7 +269,9 @@ export class Orchestrator extends DurableObject implements OrchestratorHost queued_runs: [], created_by: options.created_by, sandbox_provider: options.sandbox_provider ?? "cloudflare", - created_at: new Date().toISOString(), + max_idle_time: options.max_idle_time ?? "10m", + last_activity_at: createdAt, + created_at: createdAt, }; this.saveMeta(); diff --git a/packages/worker/src/orchestrator/types.ts b/packages/worker/src/orchestrator/types.ts index abe0a33..5b00b2f 100644 --- a/packages/worker/src/orchestrator/types.ts +++ b/packages/worker/src/orchestrator/types.ts @@ -31,6 +31,7 @@ export interface InitOptions { max_time?: string; created_by?: ParticipantIdentity; sandbox_provider?: "e2b" | "cloudflare"; + max_idle_time?: string; } export const SNAPSHOT_TERMINAL_EVENT_TYPES: ReadonlySet = new Set([ diff --git a/packages/worker/src/session-service.ts b/packages/worker/src/session-service.ts index e8bf85c..414740b 100644 --- a/packages/worker/src/session-service.ts +++ b/packages/worker/src/session-service.ts @@ -76,6 +76,7 @@ export async function createSession( max_time: normalized.max_session_time, created_by: { id: createdBy.id, name: createdBy.name }, sandbox_provider: sandboxProvider, + max_idle_time: normalized.max_idle_time, }); } catch (error) { const failedAt = new Date().toISOString(); diff --git a/packages/worker/test/session-service.test.mjs b/packages/worker/test/session-service.test.mjs index 53a690b..d9de6fe 100644 --- a/packages/worker/test/session-service.test.mjs +++ b/packages/worker/test/session-service.test.mjs @@ -82,8 +82,10 @@ test("createSession inserts a session, initializes the orchestrator, and returns max_time: normalized.max_session_time, created_by: { id: "usr_123", name: "Alice" }, sandbox_provider: "cloudflare", + max_idle_time: normalized.max_idle_time, }, ]); + assert.equal(initCall[3].max_idle_time, "10m"); }); test("createSession marks the session failed when orchestrator init throws", async () => { From cbd9d740b0a885530714b7b42ec9a1cef2928caf Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 20:26:19 +0530 Subject: [PATCH 06/32] test(sandbox): cover idle-pause init defaults and create-session forwarding --- packages/shared/test/room.test.mjs | 5 +++ .../shared/test/session-meta-schema.test.mjs | 6 +-- packages/worker/src/orchestrator.ts | 25 +----------- .../worker/src/orchestrator/initial-meta.ts | 36 +++++++++++++++++ packages/worker/src/session-directory.ts | 2 +- packages/worker/test/initial-meta.test.mjs | 36 +++++++++++++++++ packages/worker/test/session-service.test.mjs | 40 ++++++++++++++++++- 7 files changed, 120 insertions(+), 30 deletions(-) create mode 100644 packages/worker/src/orchestrator/initial-meta.ts create mode 100644 packages/worker/test/initial-meta.test.mjs diff --git a/packages/shared/test/room.test.mjs b/packages/shared/test/room.test.mjs index 92b2ac4..4f3729d 100644 --- a/packages/shared/test/room.test.mjs +++ b/packages/shared/test/room.test.mjs @@ -5,6 +5,7 @@ import { CreateSessionRequestSchema, GetSessionResponseSchema, ListSessionsResponseSchema, + SandboxStateSchema, } from "../dist/room.js"; test("CreateSessionRequestSchema accepts repo-only session creation", () => { @@ -85,3 +86,7 @@ test("GetSessionResponseSchema includes join websocket URL", () => { assert.equal(parsed.ws_url, "https://worker.example.com/sessions/ses_123/ws"); }); + +test("SandboxStateSchema includes paused", () => { + assert.equal(SandboxStateSchema.parse("paused"), "paused"); +}); diff --git a/packages/shared/test/session-meta-schema.test.mjs b/packages/shared/test/session-meta-schema.test.mjs index 3f201de..6b3e529 100644 --- a/packages/shared/test/session-meta-schema.test.mjs +++ b/packages/shared/test/session-meta-schema.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { SandboxStateSchema, SessionMetaSchema } from "../dist/index.js"; +import { SessionMetaSchema } from "../dist/index.js"; test("SessionMetaSchema: accepts a minimal valid meta blob", () => { const parsed = SessionMetaSchema.parse({ @@ -70,7 +70,3 @@ test("SessionMetaSchema: accepts idle-pause and provider fields", () => { assert.equal(parsed.sandbox_paused_at, "2026-10-01T00:10:00.000Z"); assert.equal(parsed.sandbox_lease_renewed_at, "2026-10-01T00:05:00.000Z"); }); - -test("SandboxStateSchema: includes paused", () => { - assert.equal(SandboxStateSchema.parse("paused"), "paused"); -}); diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index d5f9407..be99703 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -8,7 +8,6 @@ import type { AgentRunState, } from "@codevil/shared"; import { - DEFAULT_CONFIG, isValidTransition, isTerminalState, CLIToDOMessageSchema, @@ -79,6 +78,7 @@ import type { OrchestratorHost } from "./orchestrator/host.js"; import { SessionEventLog } from "./orchestrator/event-log.js"; import { notifyExternalConversation } from "./integrations/notify-external-conversation.js"; import { loadSessionMeta, saveSessionMeta } from "./orchestrator/session-meta.js"; +import { buildInitialSessionMeta } from "./orchestrator/initial-meta.js"; import { sessionWideEventGroup } from "./orchestrator/session-telemetry.js"; import { completeActiveRun as completeActiveRunFn, @@ -251,28 +251,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost } async init(sessionId: string, prompt: string, repo: string, options: InitOptions): Promise { - const createdAt = new Date().toISOString(); - this.meta = { - session_id: sessionId, - prompt, - repo, - worker_url: options.worker_url, - provider: options.provider ?? DEFAULT_CONFIG.provider, - plan_model: options.plan_model ?? DEFAULT_CONFIG.plan_model, - exec_model: options.exec_model ?? DEFAULT_CONFIG.exec_model, - max_time: options.max_time ?? DEFAULT_CONFIG.max_time, - state: "initializing", - refinement_round: 0, - verification_attempts: 0, - cost_total_usd: 0, - active_run: null, - queued_runs: [], - created_by: options.created_by, - sandbox_provider: options.sandbox_provider ?? "cloudflare", - max_idle_time: options.max_idle_time ?? "10m", - last_activity_at: createdAt, - created_at: createdAt, - }; + this.meta = buildInitialSessionMeta(sessionId, prompt, repo, options, new Date()); this.saveMeta(); this.appendAndBroadcast({ type: "session_created", session_id: sessionId }); diff --git a/packages/worker/src/orchestrator/initial-meta.ts b/packages/worker/src/orchestrator/initial-meta.ts new file mode 100644 index 0000000..3c8e94a --- /dev/null +++ b/packages/worker/src/orchestrator/initial-meta.ts @@ -0,0 +1,36 @@ +import { DEFAULT_CONFIG, type SessionMeta } from "@codevil/shared"; + +import { DEFAULT_MAX_IDLE_TIME } from "../session-directory.js"; +import type { InitOptions } from "./types.js"; + +/** The Session meta a freshly created Orchestrator persists in `init`. */ +export function buildInitialSessionMeta( + sessionId: string, + prompt: string, + repo: string, + options: InitOptions, + now: Date, +): SessionMeta { + const createdAt = now.toISOString(); + return { + session_id: sessionId, + prompt, + repo, + worker_url: options.worker_url, + provider: options.provider ?? DEFAULT_CONFIG.provider, + plan_model: options.plan_model ?? DEFAULT_CONFIG.plan_model, + exec_model: options.exec_model ?? DEFAULT_CONFIG.exec_model, + max_time: options.max_time ?? DEFAULT_CONFIG.max_time, + state: "initializing", + refinement_round: 0, + verification_attempts: 0, + cost_total_usd: 0, + active_run: null, + queued_runs: [], + created_by: options.created_by, + sandbox_provider: options.sandbox_provider ?? "cloudflare", + max_idle_time: options.max_idle_time ?? DEFAULT_MAX_IDLE_TIME, + last_activity_at: createdAt, + created_at: createdAt, + }; +} diff --git a/packages/worker/src/session-directory.ts b/packages/worker/src/session-directory.ts index 17b2b64..2583e29 100644 --- a/packages/worker/src/session-directory.ts +++ b/packages/worker/src/session-directory.ts @@ -46,7 +46,7 @@ export interface SessionDirectoryRow { last_event_at: string; } -const DEFAULT_MAX_IDLE_TIME = "10m"; +export const DEFAULT_MAX_IDLE_TIME = "10m"; export function normalizeCreateSessionBody(body: unknown): NormalizedCreateSession { const parsed = CreateSessionRequestSchema.parse(body); diff --git a/packages/worker/test/initial-meta.test.mjs b/packages/worker/test/initial-meta.test.mjs new file mode 100644 index 0000000..ea6ffbe --- /dev/null +++ b/packages/worker/test/initial-meta.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildInitialSessionMeta } from "../dist/orchestrator/initial-meta.js"; + +const NOW = new Date("2026-10-01T00:00:00.000Z"); + +test("buildInitialSessionMeta defaults max_idle_time and sandbox_provider", () => { + const meta = buildInitialSessionMeta("ses_1", "acme/app", "github.com/acme/app", { + worker_url: "https://w", + }, NOW); + + assert.equal(meta.max_idle_time, "10m"); + assert.equal(meta.sandbox_provider, "cloudflare"); + assert.equal(meta.state, "initializing"); +}); + +test("buildInitialSessionMeta preserves an explicit max_idle_time and sandbox_provider", () => { + const meta = buildInitialSessionMeta("ses_1", "acme/app", "github.com/acme/app", { + worker_url: "https://w", + max_idle_time: "45m", + sandbox_provider: "e2b", + }, NOW); + + assert.equal(meta.max_idle_time, "45m"); + assert.equal(meta.sandbox_provider, "e2b"); +}); + +test("buildInitialSessionMeta seeds last_activity_at from created_at", () => { + const meta = buildInitialSessionMeta("ses_1", "acme/app", "github.com/acme/app", { + worker_url: "https://w", + }, NOW); + + assert.equal(meta.created_at, "2026-10-01T00:00:00.000Z"); + assert.equal(meta.last_activity_at, meta.created_at); +}); diff --git a/packages/worker/test/session-service.test.mjs b/packages/worker/test/session-service.test.mjs index d9de6fe..3c30a25 100644 --- a/packages/worker/test/session-service.test.mjs +++ b/packages/worker/test/session-service.test.mjs @@ -85,7 +85,6 @@ test("createSession inserts a session, initializes the orchestrator, and returns max_idle_time: normalized.max_idle_time, }, ]); - assert.equal(initCall[3].max_idle_time, "10m"); }); test("createSession marks the session failed when orchestrator init throws", async () => { @@ -174,6 +173,45 @@ test("handleCreateSession fails on a bad SANDBOX_PROVIDER without touching D1 wr assert.deepEqual(writes, []); }); +test("handleCreateSession forwards a provided max_idle_time to the orchestrator", async () => { + const { response, initCall } = await createViaHandler({ repo: "github.com/acme/app", max_idle_time: "45m" }); + + assert.equal(response.status, 201); + assert.equal(initCall[3].max_idle_time, "45m"); + assert.equal(initCall[3].sandbox_provider, "cloudflare"); +}); + +test("handleCreateSession forwards the default max_idle_time when none is provided", async () => { + const { response, initCall } = await createViaHandler({ repo: "github.com/acme/app" }); + + assert.equal(response.status, 201); + assert.equal(initCall[3].max_idle_time, "10m"); +}); + +async function createViaHandler(body) { + let initCall = null; + const env = { + DB: { + prepare: () => ({ bind: () => ({ run: async () => ({}) }) }), + batch: async () => [], + }, + ORCHESTRATOR: { + idFromName: (name) => `do:${name}`, + get: () => ({ + async init(...args) { + initCall = args; + }, + }), + }, + }; + const response = await handleCreateSession( + new Request("https://codevil.example/api/sessions", { method: "POST", body: JSON.stringify(body) }), + env, + { userId: "usr_123", email: "owner@example.com", name: "Owner", role: "owner" }, + ); + return { response, initCall }; +} + function createFakeDb() { const rows = new Map(); return { From 95995267d498fa2d598a9d19aa77dfbea2acd3ab Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 22:38:05 +0530 Subject: [PATCH 07/32] feat(sandbox-image): reread sandbox WebSocket token file on reconnect --- packages/sandbox-image/src/entrypoint.ts | 20 +++++++++++++++++++ .../sandbox-image/test/ws-token-file.test.mjs | 18 +++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 packages/sandbox-image/test/ws-token-file.test.mjs diff --git a/packages/sandbox-image/src/entrypoint.ts b/packages/sandbox-image/src/entrypoint.ts index 6af414d..24fbbc4 100644 --- a/packages/sandbox-image/src/entrypoint.ts +++ b/packages/sandbox-image/src/entrypoint.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; + import WebSocket from "ws"; import type { DOToSandboxMessage, SandboxToDOMessage } from "@codevil/shared"; @@ -121,6 +123,9 @@ export async function startEntrypoint( connection = new ReconnectingWebSocketClient({ createSocket: () => { + // A resumed sandbox may have an expired in-memory token; the Orchestrator + // writes a fresh one to the token file before the agent reconnects. + wsUrl = currentSandboxWebSocketUrl(wsUrl, readTokenFile); sandboxLogger().log("INFO", "sandbox.ws.connecting", { target: wsUrlForLog(wsUrl) }); return new WebSocket(wsUrl); }, @@ -161,6 +166,21 @@ export async function startEntrypoint( connection.start(); } +export const SANDBOX_WS_TOKEN_FILE = "/run/codevil/ws-token"; + +export function currentSandboxWebSocketUrl(wsUrl: string, readToken: () => string | undefined): string { + const token = readToken()?.trim(); + return token ? withSandboxWebSocketToken(wsUrl, token) : wsUrl; +} + +function readTokenFile(): string | undefined { + try { + return readFileSync(SANDBOX_WS_TOKEN_FILE, "utf8"); + } catch { + return undefined; + } +} + function withSandboxWebSocketToken(wsUrl: string, token: string | undefined): string { if (!token) return wsUrl; const url = new URL(wsUrl); diff --git a/packages/sandbox-image/test/ws-token-file.test.mjs b/packages/sandbox-image/test/ws-token-file.test.mjs new file mode 100644 index 0000000..ddbc681 --- /dev/null +++ b/packages/sandbox-image/test/ws-token-file.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { currentSandboxWebSocketUrl, SANDBOX_WS_TOKEN_FILE } from "../dist/entrypoint.js"; + +test("uses the token file when present", () => { + const url = currentSandboxWebSocketUrl("wss://w/sessions/ses_1/sandbox/ws?sandbox_ws_token=old", () => "fresh\n"); + assert.equal(new URL(url).searchParams.get("sandbox_ws_token"), "fresh"); +}); + +test("keeps the in-memory token when the file is missing or empty", () => { + const base = "wss://w/sessions/ses_1/sandbox/ws?sandbox_ws_token=old"; + assert.equal(currentSandboxWebSocketUrl(base, () => undefined), base); + assert.equal(currentSandboxWebSocketUrl(base, () => " "), base); +}); + +test("token file path is fixed", () => { + assert.equal(SANDBOX_WS_TOKEN_FILE, "/run/codevil/ws-token"); +}); From 12028ac984520f15d2260f29d05e1123bbdf7012 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 22:41:07 +0530 Subject: [PATCH 08/32] fix(sandbox-image): prefer newer in-memory ws token over an unchanged token file --- packages/sandbox-image/src/entrypoint.ts | 43 +++++++++++---- .../sandbox-image/test/ws-token-file.test.mjs | 53 +++++++++++++++---- 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/packages/sandbox-image/src/entrypoint.ts b/packages/sandbox-image/src/entrypoint.ts index 24fbbc4..acfe015 100644 --- a/packages/sandbox-image/src/entrypoint.ts +++ b/packages/sandbox-image/src/entrypoint.ts @@ -99,6 +99,7 @@ export async function startEntrypoint( await configureDefaultGitIdentity(); + const wsTokenSource = createSandboxWsTokenSource(); let connection: ReconnectingWebSocketClient; let proxyCapabilityRefreshTimer: NodeJS.Timeout | undefined; @@ -125,7 +126,7 @@ export async function startEntrypoint( createSocket: () => { // A resumed sandbox may have an expired in-memory token; the Orchestrator // writes a fresh one to the token file before the agent reconnects. - wsUrl = currentSandboxWebSocketUrl(wsUrl, readTokenFile); + wsUrl = wsTokenSource.applyTo(wsUrl); sandboxLogger().log("INFO", "sandbox.ws.connecting", { target: wsUrlForLog(wsUrl) }); return new WebSocket(wsUrl); }, @@ -168,20 +169,42 @@ export async function startEntrypoint( export const SANDBOX_WS_TOKEN_FILE = "/run/codevil/ws-token"; -export function currentSandboxWebSocketUrl(wsUrl: string, readToken: () => string | undefined): string { - const token = readToken()?.trim(); - return token ? withSandboxWebSocketToken(wsUrl, token) : wsUrl; -} - -function readTokenFile(): string | undefined { +export function readTokenFile(path: string = SANDBOX_WS_TOKEN_FILE): string | undefined { try { - return readFileSync(SANDBOX_WS_TOKEN_FILE, "utf8"); - } catch { + return readFileSync(path, "utf8"); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== "ENOENT") { + sandboxLogger().log("WARN", "sandbox.ws_token_file.read_failed", { code: code ?? "unknown" }); + } return undefined; } } -function withSandboxWebSocketToken(wsUrl: string, token: string | undefined): string { +export interface SandboxWsTokenSource { + applyTo(wsUrl: string): string; +} + +/** + * Adopts a token from the token file only when it differs from the last file + * token this process consumed. An unchanged (possibly stale) file therefore + * never overrides a newer in-memory token delivered via proxy_capabilities. + */ +export function createSandboxWsTokenSource( + readToken: () => string | undefined = readTokenFile, +): SandboxWsTokenSource { + let lastFileToken: string | undefined; + return { + applyTo(wsUrl: string): string { + const token = readToken()?.trim(); + if (!token || token === lastFileToken) return wsUrl; + lastFileToken = token; + return withSandboxWebSocketToken(wsUrl, token); + }, + }; +} + +export function withSandboxWebSocketToken(wsUrl: string, token: string | undefined): string { if (!token) return wsUrl; const url = new URL(wsUrl); url.searchParams.set("sandbox_ws_token", token); diff --git a/packages/sandbox-image/test/ws-token-file.test.mjs b/packages/sandbox-image/test/ws-token-file.test.mjs index ddbc681..ed5b0b6 100644 --- a/packages/sandbox-image/test/ws-token-file.test.mjs +++ b/packages/sandbox-image/test/ws-token-file.test.mjs @@ -1,18 +1,49 @@ import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; -import { currentSandboxWebSocketUrl, SANDBOX_WS_TOKEN_FILE } from "../dist/entrypoint.js"; +import { + createSandboxWsTokenSource, + readTokenFile, + withSandboxWebSocketToken, +} from "../dist/entrypoint.js"; -test("uses the token file when present", () => { - const url = currentSandboxWebSocketUrl("wss://w/sessions/ses_1/sandbox/ws?sandbox_ws_token=old", () => "fresh\n"); - assert.equal(new URL(url).searchParams.get("sandbox_ws_token"), "fresh"); -}); +const BASE = "wss://w/sessions/ses_1/sandbox/ws?sandbox_ws_token=old"; +const tokenOf = (url) => new URL(url).searchParams.get("sandbox_ws_token"); + +test("token source follows file/in-memory precedence across reconnects", () => { + let fileContent; + const source = createSandboxWsTokenSource(() => fileContent); + + // (a) file absent -> unchanged + let wsUrl = source.applyTo(BASE); + assert.equal(wsUrl, BASE); + + // (b) first non-empty file value is adopted + fileContent = "file-1\n"; + wsUrl = source.applyTo(wsUrl); + assert.equal(tokenOf(wsUrl), "file-1"); + + // (c) newer in-memory token (proxy_capabilities) wins over an unchanged file + wsUrl = withSandboxWebSocketToken(wsUrl, "memory-2"); + wsUrl = source.applyTo(wsUrl); + assert.equal(tokenOf(wsUrl), "memory-2"); + + // (d) a changed file value is adopted again + fileContent = "file-3\n"; + wsUrl = source.applyTo(wsUrl); + assert.equal(tokenOf(wsUrl), "file-3"); -test("keeps the in-memory token when the file is missing or empty", () => { - const base = "wss://w/sessions/ses_1/sandbox/ws?sandbox_ws_token=old"; - assert.equal(currentSandboxWebSocketUrl(base, () => undefined), base); - assert.equal(currentSandboxWebSocketUrl(base, () => " "), base); + // (e) whitespace-only file is ignored + fileContent = " \n"; + assert.equal(source.applyTo(wsUrl), wsUrl); }); -test("token file path is fixed", () => { - assert.equal(SANDBOX_WS_TOKEN_FILE, "/run/codevil/ws-token"); +test("readTokenFile reads a file and returns undefined for a missing path", () => { + const dir = mkdtempSync(join(tmpdir(), "ws-token-")); + const path = join(dir, "ws-token"); + writeFileSync(path, "fresh\n"); + assert.equal(readTokenFile(path)?.trim(), "fresh"); + assert.equal(readTokenFile(join(dir, "missing")), undefined); }); From ecb446dc56620351ebb51a1d5ae7ae140b5e9a76 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 22:42:39 +0530 Subject: [PATCH 09/32] feat(sandbox): add idle-pause and lease scheduling helpers --- packages/worker/src/orchestrator/alarm.ts | 8 +++ .../src/orchestrator/sandbox-lifecycle.ts | 63 +++++++++++++++++++ .../worker/test/sandbox-lifecycle.test.mjs | 57 +++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 packages/worker/src/orchestrator/sandbox-lifecycle.ts create mode 100644 packages/worker/test/sandbox-lifecycle.test.mjs diff --git a/packages/worker/src/orchestrator/alarm.ts b/packages/worker/src/orchestrator/alarm.ts index b4a2f46..c4d9b24 100644 --- a/packages/worker/src/orchestrator/alarm.ts +++ b/packages/worker/src/orchestrator/alarm.ts @@ -9,6 +9,8 @@ export interface AlarmScheduleInput { sandboxDisconnectedAt?: string; presentationRetryAt?: number | null; workspaceCacheRetryAt?: number | null; + idlePauseAt?: number | null; + leaseRenewAt?: number | null; } export function nextAlarmDeadline(input: AlarmScheduleInput): number | undefined { @@ -19,6 +21,12 @@ export function nextAlarmDeadline(input: AlarmScheduleInput): number | undefined if (input.sandboxDisconnectedAt) { deadlines.push(sandboxReconnectDeadline(input.sandboxDisconnectedAt)); } + if (input.idlePauseAt !== null && input.idlePauseAt !== undefined) { + deadlines.push(input.idlePauseAt); + } + if (input.leaseRenewAt !== null && input.leaseRenewAt !== undefined) { + deadlines.push(input.leaseRenewAt); + } } if (input.presentationRetryAt !== null && input.presentationRetryAt !== undefined) { deadlines.push(input.presentationRetryAt); diff --git a/packages/worker/src/orchestrator/sandbox-lifecycle.ts b/packages/worker/src/orchestrator/sandbox-lifecycle.ts new file mode 100644 index 0000000..8ef0b1d --- /dev/null +++ b/packages/worker/src/orchestrator/sandbox-lifecycle.ts @@ -0,0 +1,63 @@ +import type { SessionState } from "@codevil/shared"; + +/** How often the orchestrator re-extends a provider sandbox lease (e.g. E2B). */ +export const SANDBOX_LEASE_RENEW_INTERVAL_MS = 5 * 60_000; +/** Default time a ready session may sit idle before its sandbox is paused. */ +export const DEFAULT_MAX_IDLE_MS = 10 * 60_000; + +const MIN_SANDBOX_LEASE_MS = 60_000; + +export interface IdlePauseInput { + now: number; + pauseSupported: boolean; + sessionState: SessionState; + paused: boolean; + sandboxConnected: boolean; + disconnectedAt?: string; + hasActiveRun: boolean; + queuedRuns: number; + openQuestions: number; + lastActivityAt: string; + maxIdleMs: number | null; +} + +export function idlePauseDeadline( + input: Pick, +): number | null { + if (input.maxIdleMs === null) return null; + return Date.parse(input.lastActivityAt) + input.maxIdleMs; +} + +export function shouldPauseSandbox(input: IdlePauseInput): boolean { + if ( + !input.pauseSupported || + input.paused || + input.sessionState !== "ready" || + !input.sandboxConnected || + input.disconnectedAt || + input.hasActiveRun || + input.queuedRuns !== 0 || + input.openQuestions !== 0 + ) { + return false; + } + const deadline = idlePauseDeadline(input); + return deadline !== null && input.now >= deadline; +} + +export function sandboxLeaseMs(input: { + now: number; + createdAt: number; + maxTimeMs: number | null; + providerMaxMs: number; +}): number { + const remainingMs = + input.maxTimeMs === null + ? input.providerMaxMs + : Math.max(MIN_SANDBOX_LEASE_MS, input.createdAt + input.maxTimeMs - input.now); + return Math.min(input.providerMaxMs, remainingMs); +} + +export function leaseRenewDeadline(input: { renewedAt?: string; createdAt: string }): number { + return Date.parse(input.renewedAt ?? input.createdAt) + SANDBOX_LEASE_RENEW_INTERVAL_MS; +} diff --git a/packages/worker/test/sandbox-lifecycle.test.mjs b/packages/worker/test/sandbox-lifecycle.test.mjs new file mode 100644 index 0000000..e7c18ab --- /dev/null +++ b/packages/worker/test/sandbox-lifecycle.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + shouldPauseSandbox, idlePauseDeadline, sandboxLeaseMs, leaseRenewDeadline, SANDBOX_LEASE_RENEW_INTERVAL_MS, +} from "../dist/orchestrator/sandbox-lifecycle.js"; +import { nextAlarmDeadline } from "../dist/orchestrator/alarm.js"; + +const T0 = Date.parse("2026-10-01T00:00:00.000Z"); +const idle = (overrides = {}) => ({ + now: T0 + 10 * 60_000, pauseSupported: true, sessionState: "ready", paused: false, sandboxConnected: true, + hasActiveRun: false, queuedRuns: 0, openQuestions: 0, lastActivityAt: "2026-10-01T00:00:00.000Z", maxIdleMs: 600_000, + ...overrides, +}); + +test("pauses an idle, connected, ready sandbox at the idle deadline", () => { + assert.equal(shouldPauseSandbox(idle()), true); + assert.equal(shouldPauseSandbox(idle({ now: T0 + 599_999 })), false); +}); + +for (const [name, overrides] of [ + ["provider without pause", { pauseSupported: false }], + ["already paused", { paused: true }], + ["cloning", { sessionState: "cloning_repo" }], + ["socket not attached", { sandboxConnected: false }], + ["mid-reconnect", { disconnectedAt: "2026-10-01T00:09:00.000Z" }], + ["active run", { hasActiveRun: true }], + ["queued run", { queuedRuns: 1 }], + ["open question", { openQuestions: 1 }], + ["idle disabled", { maxIdleMs: null }], +]) { + test(`never pauses when ${name}`, () => { + assert.equal(shouldPauseSandbox(idle(overrides)), false); + }); +} + +test("idle deadline follows the last activity", () => { + assert.equal(idlePauseDeadline({ lastActivityAt: "2026-10-01T00:00:00.000Z", maxIdleMs: 600_000 }), T0 + 600_000); + assert.equal(idlePauseDeadline({ lastActivityAt: "2026-10-01T00:00:00.000Z", maxIdleMs: null }), null); +}); + +test("lease is the shorter of remaining session time and the provider cap, at least one minute", () => { + assert.equal(sandboxLeaseMs({ now: T0, createdAt: T0, maxTimeMs: 15 * 60_000, providerMaxMs: 3_600_000 }), 15 * 60_000); + assert.equal(sandboxLeaseMs({ now: T0, createdAt: T0, maxTimeMs: 4 * 3_600_000, providerMaxMs: 3_600_000 }), 3_600_000); + assert.equal(sandboxLeaseMs({ now: T0 + 15 * 60_000, createdAt: T0, maxTimeMs: 15 * 60_000, providerMaxMs: 3_600_000 }), 60_000); + assert.equal(sandboxLeaseMs({ now: T0, createdAt: T0, maxTimeMs: null, providerMaxMs: 3_600_000 }), 3_600_000); +}); + +test("lease renewal deadline", () => { + assert.equal(leaseRenewDeadline({ createdAt: "2026-10-01T00:00:00.000Z" }), T0 + SANDBOX_LEASE_RENEW_INTERVAL_MS); +}); + +test("alarm includes idle pause and lease renewal deadlines", () => { + const base = { now: T0 + 120_000, state: "ready", createdAt: T0, maxTimeMs: null }; + assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: T0 + 200_000, leaseRenewAt: T0 + 300_000 }), T0 + 200_000); + assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: null, leaseRenewAt: T0 + 300_000 }), T0 + 300_000); + assert.equal(nextAlarmDeadline({ ...base, state: "failed", idlePauseAt: T0 + 200_000 }), undefined); +}); From 8d13135c8cdaf47766ffd9936d2e54dd0b280bac Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 22:44:57 +0530 Subject: [PATCH 10/32] fix(sandbox): retry past-due pause and lease deadlines and guard invalid timestamps --- packages/worker/src/orchestrator/alarm.ts | 13 +++-- .../src/orchestrator/sandbox-lifecycle.ts | 16 +++++- .../worker/test/orchestrator-alarm.test.mjs | 52 +++++++++++++++++++ .../worker/test/sandbox-lifecycle.test.mjs | 30 ++++++++--- 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/packages/worker/src/orchestrator/alarm.ts b/packages/worker/src/orchestrator/alarm.ts index c4d9b24..27b7b7c 100644 --- a/packages/worker/src/orchestrator/alarm.ts +++ b/packages/worker/src/orchestrator/alarm.ts @@ -1,4 +1,5 @@ import { isTerminalState, type SessionState } from "@codevil/shared"; +import { SANDBOX_PAST_DUE_RETRY_MS } from "./sandbox-lifecycle.js"; import { sandboxReconnectDeadline } from "../sandbox-connection.js"; export interface AlarmScheduleInput { @@ -21,11 +22,13 @@ export function nextAlarmDeadline(input: AlarmScheduleInput): number | undefined if (input.sandboxDisconnectedAt) { deadlines.push(sandboxReconnectDeadline(input.sandboxDisconnectedAt)); } - if (input.idlePauseAt !== null && input.idlePauseAt !== undefined) { - deadlines.push(input.idlePauseAt); - } - if (input.leaseRenewAt !== null && input.leaseRenewAt !== undefined) { - deadlines.push(input.leaseRenewAt); + // A past-due pause/renew deadline would be filtered out below and strand + // the work (or its retry after a failure); clamp so it re-fires after a + // bounded delay instead. Non-finite values are skipped. + for (const dueAt of [input.idlePauseAt, input.leaseRenewAt]) { + if (dueAt !== null && dueAt !== undefined && Number.isFinite(dueAt)) { + deadlines.push(Math.max(dueAt, input.now + SANDBOX_PAST_DUE_RETRY_MS)); + } } } if (input.presentationRetryAt !== null && input.presentationRetryAt !== undefined) { diff --git a/packages/worker/src/orchestrator/sandbox-lifecycle.ts b/packages/worker/src/orchestrator/sandbox-lifecycle.ts index 8ef0b1d..8c82fcd 100644 --- a/packages/worker/src/orchestrator/sandbox-lifecycle.ts +++ b/packages/worker/src/orchestrator/sandbox-lifecycle.ts @@ -5,6 +5,9 @@ export const SANDBOX_LEASE_RENEW_INTERVAL_MS = 5 * 60_000; /** Default time a ready session may sit idle before its sandbox is paused. */ export const DEFAULT_MAX_IDLE_MS = 10 * 60_000; +/** Retry delay for a pause/lease-renew deadline that is already due (bounds alarm re-fire rate). */ +export const SANDBOX_PAST_DUE_RETRY_MS = 30_000; + const MIN_SANDBOX_LEASE_MS = 60_000; export interface IdlePauseInput { @@ -25,7 +28,8 @@ export function idlePauseDeadline( input: Pick, ): number | null { if (input.maxIdleMs === null) return null; - return Date.parse(input.lastActivityAt) + input.maxIdleMs; + const deadline = Date.parse(input.lastActivityAt) + input.maxIdleMs; + return Number.isFinite(deadline) ? deadline : null; } export function shouldPauseSandbox(input: IdlePauseInput): boolean { @@ -59,5 +63,13 @@ export function sandboxLeaseMs(input: { } export function leaseRenewDeadline(input: { renewedAt?: string; createdAt: string }): number { - return Date.parse(input.renewedAt ?? input.createdAt) + SANDBOX_LEASE_RENEW_INTERVAL_MS; + // Unparseable timestamps fall back renewedAt -> createdAt -> 0 (renew immediately). + const renewedAtMs = input.renewedAt === undefined ? Number.NaN : Date.parse(input.renewedAt); + const baseMs = Number.isFinite(renewedAtMs) + ? renewedAtMs + : (() => { + const createdAtMs = Date.parse(input.createdAt); + return Number.isFinite(createdAtMs) ? createdAtMs : 0; + })(); + return baseMs + SANDBOX_LEASE_RENEW_INTERVAL_MS; } diff --git a/packages/worker/test/orchestrator-alarm.test.mjs b/packages/worker/test/orchestrator-alarm.test.mjs index a97b46b..b94ce77 100644 --- a/packages/worker/test/orchestrator-alarm.test.mjs +++ b/packages/worker/test/orchestrator-alarm.test.mjs @@ -67,3 +67,55 @@ test("propagates a replacement alarm persistence failure", async () => { /setAlarm failed/, ); }); + +test("alarm includes idle pause and lease renewal deadlines", async () => { + const { nextAlarmDeadline } = alarmModule; + const T0 = Date.parse("2026-10-01T00:00:00.000Z"); + const base = { now: T0 + 120_000, state: "ready", createdAt: T0, maxTimeMs: null }; + assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: T0 + 200_000, leaseRenewAt: T0 + 300_000 }), T0 + 200_000); + assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: null, leaseRenewAt: T0 + 300_000 }), T0 + 300_000); + assert.equal(nextAlarmDeadline({ ...base, state: "failed", idlePauseAt: T0 + 200_000 }), undefined); + assert.equal(nextAlarmDeadline({ ...base, state: "failed", leaseRenewAt: T0 + 200_000 }), undefined); +}); + +test("past-due idle pause and lease renewal deadlines retry after the past-due delay", async () => { + const { nextAlarmDeadline } = alarmModule; + const { SANDBOX_PAST_DUE_RETRY_MS } = await import("../dist/orchestrator/sandbox-lifecycle.js"); + assert.equal(SANDBOX_PAST_DUE_RETRY_MS, 30_000); + const T0 = Date.parse("2026-10-01T00:00:00.000Z"); + const base = { now: T0 + 120_000, state: "ready", createdAt: T0, maxTimeMs: null }; + assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: T0 + 1_000 }), base.now + 30_000); + assert.equal(nextAlarmDeadline({ ...base, leaseRenewAt: base.now }), base.now + 30_000); + // Future deadlines beyond the retry delay are unchanged. + assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: base.now + 90_000 }), base.now + 90_000); +}); + +test("non-finite idle pause and lease renewal deadlines are ignored", async () => { + const { nextAlarmDeadline } = alarmModule; + const T0 = Date.parse("2026-10-01T00:00:00.000Z"); + const base = { now: T0 + 120_000, state: "ready", createdAt: T0, maxTimeMs: null }; + assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: Number.NaN, leaseRenewAt: Number.NaN }), undefined); + assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: Number.POSITIVE_INFINITY }), undefined); + assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: Number.NaN, leaseRenewAt: base.now + 50_000 }), base.now + 50_000); +}); + +test("earlier existing deadlines still win over idle pause and lease renewal", async () => { + const { nextAlarmDeadline } = alarmModule; + const T0 = Date.parse("2026-10-01T00:00:00.000Z"); + const now = T0 + 120_000; + assert.equal( + nextAlarmDeadline({ now, state: "ready", createdAt: T0, maxTimeMs: 150_000, idlePauseAt: now - 1, leaseRenewAt: now + 100_000 }), + T0 + 150_000, + ); + const disconnectedAt = new Date(now).toISOString(); + const { sandboxReconnectDeadline } = await import("../dist/sandbox-connection.js"); + const reconnect = sandboxReconnectDeadline(disconnectedAt); + assert.ok(reconnect > now); + assert.equal( + nextAlarmDeadline({ + now, state: "ready", createdAt: T0, maxTimeMs: null, + sandboxDisconnectedAt: disconnectedAt, idlePauseAt: reconnect + 10_000, leaseRenewAt: reconnect + 20_000, + }), + reconnect, + ); +}); diff --git a/packages/worker/test/sandbox-lifecycle.test.mjs b/packages/worker/test/sandbox-lifecycle.test.mjs index e7c18ab..4e39795 100644 --- a/packages/worker/test/sandbox-lifecycle.test.mjs +++ b/packages/worker/test/sandbox-lifecycle.test.mjs @@ -3,7 +3,6 @@ import test from "node:test"; import { shouldPauseSandbox, idlePauseDeadline, sandboxLeaseMs, leaseRenewDeadline, SANDBOX_LEASE_RENEW_INTERVAL_MS, } from "../dist/orchestrator/sandbox-lifecycle.js"; -import { nextAlarmDeadline } from "../dist/orchestrator/alarm.js"; const T0 = Date.parse("2026-10-01T00:00:00.000Z"); const idle = (overrides = {}) => ({ @@ -15,12 +14,15 @@ const idle = (overrides = {}) => ({ test("pauses an idle, connected, ready sandbox at the idle deadline", () => { assert.equal(shouldPauseSandbox(idle()), true); assert.equal(shouldPauseSandbox(idle({ now: T0 + 599_999 })), false); + assert.equal(shouldPauseSandbox(idle({ now: T0 + 600_001 })), true); }); for (const [name, overrides] of [ ["provider without pause", { pauseSupported: false }], ["already paused", { paused: true }], ["cloning", { sessionState: "cloning_repo" }], + ["provisioning", { sessionState: "provisioning_sandbox" }], + ["invalid lastActivityAt", { lastActivityAt: "not-a-date" }], ["socket not attached", { sandboxConnected: false }], ["mid-reconnect", { disconnectedAt: "2026-10-01T00:09:00.000Z" }], ["active run", { hasActiveRun: true }], @@ -45,13 +47,29 @@ test("lease is the shorter of remaining session time and the provider cap, at le assert.equal(sandboxLeaseMs({ now: T0, createdAt: T0, maxTimeMs: null, providerMaxMs: 3_600_000 }), 3_600_000); }); +test("lease is capped at the provider max even when it is below the one-minute floor", () => { + assert.equal(sandboxLeaseMs({ now: T0, createdAt: T0, maxTimeMs: 15 * 60_000, providerMaxMs: 30_000 }), 30_000); + assert.equal(sandboxLeaseMs({ now: T0 + 15 * 60_000, createdAt: T0, maxTimeMs: 15 * 60_000, providerMaxMs: 30_000 }), 30_000); +}); + test("lease renewal deadline", () => { assert.equal(leaseRenewDeadline({ createdAt: "2026-10-01T00:00:00.000Z" }), T0 + SANDBOX_LEASE_RENEW_INTERVAL_MS); + assert.equal( + leaseRenewDeadline({ renewedAt: "2026-10-01T00:02:00.000Z", createdAt: "2026-10-01T00:00:00.000Z" }), + T0 + 120_000 + SANDBOX_LEASE_RENEW_INTERVAL_MS, + ); +}); + +test("lease renewal deadline guards invalid timestamps", () => { + assert.equal( + leaseRenewDeadline({ renewedAt: "garbage", createdAt: "2026-10-01T00:00:00.000Z" }), + T0 + SANDBOX_LEASE_RENEW_INTERVAL_MS, + ); + assert.equal(leaseRenewDeadline({ renewedAt: "garbage", createdAt: "also-garbage" }), SANDBOX_LEASE_RENEW_INTERVAL_MS); + assert.equal(leaseRenewDeadline({ createdAt: "also-garbage" }), SANDBOX_LEASE_RENEW_INTERVAL_MS); }); -test("alarm includes idle pause and lease renewal deadlines", () => { - const base = { now: T0 + 120_000, state: "ready", createdAt: T0, maxTimeMs: null }; - assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: T0 + 200_000, leaseRenewAt: T0 + 300_000 }), T0 + 200_000); - assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: null, leaseRenewAt: T0 + 300_000 }), T0 + 300_000); - assert.equal(nextAlarmDeadline({ ...base, state: "failed", idlePauseAt: T0 + 200_000 }), undefined); +test("invalid lastActivityAt yields no idle deadline", () => { + assert.equal(idlePauseDeadline({ lastActivityAt: "garbage", maxIdleMs: 600_000 }), null); + assert.equal(shouldPauseSandbox(idle({ lastActivityAt: "garbage" })), false); }); From ee3bd1ad8cb529183b1ae475362a0eae51f3cf08 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 22:46:00 +0530 Subject: [PATCH 11/32] test(sandbox): pin past-due clamp against competing alarm deadlines --- .../worker/test/orchestrator-alarm.test.mjs | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/worker/test/orchestrator-alarm.test.mjs b/packages/worker/test/orchestrator-alarm.test.mjs index b94ce77..002da39 100644 --- a/packages/worker/test/orchestrator-alarm.test.mjs +++ b/packages/worker/test/orchestrator-alarm.test.mjs @@ -99,23 +99,36 @@ test("non-finite idle pause and lease renewal deadlines are ignored", async () = assert.equal(nextAlarmDeadline({ ...base, idlePauseAt: Number.NaN, leaseRenewAt: base.now + 50_000 }), base.now + 50_000); }); -test("earlier existing deadlines still win over idle pause and lease renewal", async () => { +test("an earlier maxTime deadline wins over a clamped past-due idle pause", async () => { const { nextAlarmDeadline } = alarmModule; const T0 = Date.parse("2026-10-01T00:00:00.000Z"); const now = T0 + 120_000; + // maxTime deadline (now + 10s) is earlier than the clamped pause (now + 30s). assert.equal( - nextAlarmDeadline({ now, state: "ready", createdAt: T0, maxTimeMs: 150_000, idlePauseAt: now - 1, leaseRenewAt: now + 100_000 }), - T0 + 150_000, + nextAlarmDeadline({ now, state: "ready", createdAt: T0, maxTimeMs: 130_000, idlePauseAt: now - 1 }), + now + 10_000, ); - const disconnectedAt = new Date(now).toISOString(); +}); + +test("an earlier reconnect deadline wins over a clamped past-due lease renewal", async () => { + const { nextAlarmDeadline } = alarmModule; const { sandboxReconnectDeadline } = await import("../dist/sandbox-connection.js"); - const reconnect = sandboxReconnectDeadline(disconnectedAt); - assert.ok(reconnect > now); + const T0 = Date.parse("2026-10-01T00:00:00.000Z"); + const now = T0 + 120_000; + const sandboxDisconnectedAt = new Date(now - 55_000).toISOString(); + assert.equal(sandboxReconnectDeadline(sandboxDisconnectedAt), now + 5_000); assert.equal( - nextAlarmDeadline({ - now, state: "ready", createdAt: T0, maxTimeMs: null, - sandboxDisconnectedAt: disconnectedAt, idlePauseAt: reconnect + 10_000, leaseRenewAt: reconnect + 20_000, - }), - reconnect, + nextAlarmDeadline({ now, state: "ready", createdAt: T0, maxTimeMs: null, sandboxDisconnectedAt, leaseRenewAt: now - 1 }), + now + 5_000, + ); +}); + +test("a past-due idle pause with no earlier deadline arms exactly the clamped retry", async () => { + const { nextAlarmDeadline } = alarmModule; + const T0 = Date.parse("2026-10-01T00:00:00.000Z"); + const now = T0 + 120_000; + assert.equal( + nextAlarmDeadline({ now, state: "ready", createdAt: T0, maxTimeMs: null, idlePauseAt: now - 1 }), + now + 30_000, ); }); From 737b5b28a5a8e8d1ca4b2aef9c98f1b88c10265c Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 22:57:44 +0530 Subject: [PATCH 12/32] docs(sandbox): record E2B feasibility findings --- .../2026-10-01-e2b-sandbox-provider-design.md | 58 + packages/worker/package.json | 1 + pnpm-lock.yaml | 1897 +++-------------- 3 files changed, 297 insertions(+), 1659 deletions(-) diff --git a/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md b/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md index c4b3473..f268af4 100644 --- a/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md +++ b/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md @@ -242,3 +242,61 @@ If (1) fails, implementation stops and the design is revisited (a Node control s 3. Idle past `max_idle_time`; the sandbox pauses. 4. Resume once with a prompt and once by opening the preview; the dev server is still running and the agent continues. 5. Stop the Session; the sandbox is destroyed in the E2B dashboard. + +## Feasibility Findings + +Probe run on 2026-10-01 under `wrangler dev` 4.100.0 (workerd, `compatibility_date = "2026-04-07"`, `nodejs_compat`) with `e2b` **2.51.0** (`^2.51.0` in `packages/worker/package.json`). The probe Worker was throwaway and is not committed. Secrets are never recorded; only booleans, lengths and status codes. + +**Gate: PASSED.** The SDK bundles and runs in workerd with no polyfill and no alternate import path (`import { Sandbox } from "e2b"`). Steps 1-3 succeeded. + +### Answers + +1. **SDK in workerd:** Yes. `Sandbox.create`, `commands.run`, `files.write`, `pause`, `connect`, `setTimeout`, `getInfo`, `kill` and `Sandbox.list` all worked. `create` took ~0.5-1.4s. +2. **WebSocket upgrade through `getHost(port)` with token:** Yes. A Worker `fetch("https://" + sandbox.getHost(8001) + "/", { headers: { Upgrade: "websocket", "e2b-traffic-access-token": token } })` returned `status 101` with `response.webSocket` non-null. After `ws.accept()`, a sent `ping` came back as `echo:ping`. Without the token header the upgrade returns 403. (Echo server: a ~25-line stdlib `python3` script; `websockets`/`wscat` were not needed.) +3. **Option-name correction (important for Task 4):** In e2b 2.51.0 `secure` is deprecated and ignored (every sandbox secures envd). `allowPublicTraffic` is **not** a top-level create option; it lives under `network`: `Sandbox.create({ network: { allowPublicTraffic: false } })`. With the plan's original options (`secure: true, allowPublicTraffic: false` top-level) the sandbox was created with public traffic allowed: `trafficAccessToken` was absent and requests with and without the header both returned 200 (and the WS upgrade succeeded without a token). With `network: { allowPublicTraffic: false }`, `trafficAccessToken` is present (64 chars), the HTTP request with the token returns 200 and without it 403. +4. **`Sandbox.connect` repopulates `trafficAccessToken`:** Yes. After `pause()` then `Sandbox.connect(id, { apiKey, timeoutMs })`, the connected instance had `trafficAccessToken` present (64 chars) and it equals the one from `create`. The background `python3 -m http.server` from before the pause still answered 200 after resume, and `/tmp/x` survived. Pause took ~0.5s, resume ~0.4s. +5. **Hobby 1-hour clock after resume:** Docs (https://docs.e2b.dev/sandbox/persistence): continuous runtime max is 1 hour on Hobby (24 hours on Pro), and "the continuous runtime limit is reset" after pause then resume. Observed: after `connect`, `getInfo().startedAt` was reset to the resume time and `endAt = startedAt + timeoutMs`. Record: **resets** (each resume starts a fresh clock). `SDK` doc for `timeoutMs` also states max 1h Hobby / 24h Pro. Do not request a `timeoutMs` above 3_600_000 on Hobby. +6. **Killed sandbox:** `Sandbox.connect(killedId)` throws class `SandboxNotFoundError` (message "Paused sandbox not found"), which `extends NotFoundError extends SandboxError`. Catch `NotFoundError` (instanceof) to cover both; do not match on the literal class name `NotFoundError`. +7. **Template publishing:** + - The v2 Template SDK `Template().fromDockerfile(pathOrContent)` **rejects multi-stage Dockerfiles**: the converter throws `"Multi-stage Dockerfiles are not supported"` when a Dockerfile has more than one `FROM` (verified in `e2b/dist/index.mjs`). It also requires exactly one `FROM`. + - `Template().fromImage('registry/image:tag', { username, password })` accepts any registry image (also `fromTemplate`, and an ECR variant), so a multi-stage Dockerfile must be built and pushed with Docker first, then referenced via `fromImage`. Build with `Template.build(template, 'name', { cpuCount, memoryMB, onBuildLogs })`. + - CLI (`@e2b/cli` 2.21.0): `e2b template create -d -p [-c ] [--ready-cmd ] [--cpu-count N] [--memory-mb N]` "builds a Dockerfile as a sandbox template". `e2b template migrate` converts `e2b.Dockerfile`/`e2b.toml` to the SDK format; `e2b template publish` only makes an existing template public (it is not the build step). Multi-stage support in the CLI path was not verified (no template was built); assume unsupported and use the `fromImage` route. Not published, per instructions. + - Recommended command for Task 8: `docker build` + `docker push` the multi-stage image, then a small script calling `Template.build(Template().fromImage('/:', creds), '')`. + +### Probe results (redacted JSON) + +Run 1 (original plan options, `secure: true, allowPublicTraffic: false`; sandbox `idbq4u63mdmmsc6p3bbga`, killed): + +```json +{ + "step1_create": { "ok": true, "ms": 1444, "trafficAccessTokenPresent": false }, + "step2_echo": { "ok": true, "stdout": "hi\n", "exitCode": 0 }, + "step3_files": { "ok": true, "stdout": "y" }, + "step4_http": { "ok": false, "withTokenStatus": 200, "withoutTokenStatus": 200 }, + "step5_ws": { "status": 101, "webSocketNonNull": true, "echo": "echo:ping", "withoutTokenStatus": 101 }, + "step6_pause_resume": { "pauseMs": 517, "resumeMs": 351, "connectedTrafficAccessTokenPresent": false, "backgroundServerStatusAfterResume": 200, "fileSurvives": true }, + "step7_connectKilled": { "class": "SandboxNotFoundError" } +} +``` + +Run 2 (`network: { allowPublicTraffic: false }`; sandbox `ipv0doc3yywqgunnnhbva`, killed): + +```json +{ + "step1_create": { "ok": true, "ms": 475, "trafficAccessTokenPresent": true, "trafficAccessTokenLength": 64 }, + "step2_echo": { "ok": true, "stdout": "hi\n", "exitCode": 0 }, + "step3_files": { "ok": true, "stdout": "y" }, + "step4_http": { "ok": true, "withTokenStatus": 200, "withoutTokenStatus": 403, "hostShape": "8000-.e2b.app" }, + "step5_ws": { "ok": true, "status": 101, "webSocketNonNull": true, "echo": "echo:ping", "withoutTokenStatus": 403 }, + "step6_pause_resume": { + "pauseMs": 516, "resumeMs": 418, + "connectedTrafficAccessTokenPresent": true, "connectedTrafficAccessTokenLength": 64, "sameTokenAsCreate": true, + "backgroundServerStatusAfterResume": 200, "fileSurvives": true, + "startedAtAfterConnect": "2026-10-01T17:26:37Z (reset to resume time)", "endAtAfterConnect": "startedAt + 300s" + }, + "step7_timeout": { "ok": true, "setTimeout120sApplied": true }, + "step7_connectKilled": { "class": "SandboxNotFoundError", "message": "Paused sandbox not found" } +} +``` + +A final `Sandbox.list` for the key returned 0 running sandboxes. diff --git a/packages/worker/package.json b/packages/worker/package.json index 54a3cd2..1f8103a 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -15,6 +15,7 @@ "@cloudflare/sandbox": "0.12.7", "@codevil/shared": "workspace:*", "better-auth": "^1.6.14", + "e2b": "^2.51.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4aaf42..98af2cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,10 +85,10 @@ importers: dependencies: '@astrojs/react': specifier: ^4 - version: 4.4.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yaml@2.8.3) + version: 4.4.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yaml@2.9.0) astro: specifier: ^5 - version: 5.18.2(@types/node@24.12.2)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.2(@types/node@24.12.2)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@5.9.3)(yaml@2.9.0) react: specifier: ^19 version: 19.2.5 @@ -98,7 +98,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4 - version: 4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) + version: 4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) '@types/react': specifier: ^19 version: 19.2.14 @@ -110,7 +110,7 @@ importers: version: 4.2.4 vitest: specifier: ^3 - version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) packages/web: dependencies: @@ -165,13 +165,13 @@ importers: version: 0.5.19(tailwindcss@4.2.4) '@tailwindcss/vite': specifier: ^4 - version: 4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) + version: 4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) '@tanstack/react-router-devtools': specifier: ^1 version: 1.166.13(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.16)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/router-plugin': specifier: ^1 - version: 1.167.27(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) + version: 1.167.27(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) '@types/react': specifier: ^19 version: 19.2.14 @@ -180,7 +180,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4 - version: 4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) + version: 4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) tailwindcss: specifier: ^4 version: 4.2.4 @@ -189,10 +189,10 @@ importers: version: 5.9.3 vite: specifier: ^6 - version: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + version: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) vitest: specifier: ^3 - version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) packages/worker: dependencies: @@ -204,7 +204,10 @@ importers: version: link:../shared better-auth: specifier: ^1.6.14 - version: 1.6.14(@cloudflare/workers-types@4.20260702.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) + version: 1.6.14(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + e2b: + specifier: ^2.51.0 + version: 2.51.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -256,10 +259,6 @@ packages: resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} - '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} - '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -273,154 +272,66 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-bedrock-runtime@3.1033.0': - resolution: {integrity: sha512-CDI4njdtLEd3voxApQMI32IJN/HhpM3FtAh0quJ+aIWNmyDbW3cp2SwQ56Pnsrq1OV40Apw/O4yD822K4aK9HA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock-runtime@3.1048.0': resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.2': - resolution: {integrity: sha512-oav5AOAz+1XkwUfp6SrEm42UPDpUP5D4jNYXkDwFR1VfWqYX62+jpytdfzURmJ9McSoJIQwi0OJlC4oCi6t0VQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.26': resolution: {integrity: sha512-wRj7Pthvjk3anees97pUWlxlTa0DUjeGrEQU5fKDZVdWZV0ekaprbof0df2uaE9g8u67t035v2j+ne2AW2UMkA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.28': - resolution: {integrity: sha512-87GdRJ2OR0qR4VkMjXN/SZi66DZsunW2qQCbtw9rKw3Y7JurFi6tQWYKOSLY/gOADrU6OxGqFmdw3hKzZqDZOQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.52': resolution: {integrity: sha512-sxuaHZGHqOgKB8OdL3doXa1NJjqmO60FPfyTnYVKGjX9taRsIEGS9pd+2yALmo06hijZ8L94uSK0kfXZsRmVyA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.30': - resolution: {integrity: sha512-6quozmW2PKwBJTUQLb+lk1q8w5Pm45qaqhx4Tld9EIqYYQOVGj+MT0a8NRVS7QgWJj7rzGlB7rQu3KYBFHemJw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.54': resolution: {integrity: sha512-e6yz52nq3SpR1oPLcvfsDM7H7k2gIYk/NSn/rwsFqzGXEwr3g0mRMlPbLaKCPCGNZJMU/gZg6/64B3eSam+gBw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.32': - resolution: {integrity: sha512-Nkr+UKtczZlocUjc6g96WzQadZSIZO/HVXPki4qbfaVOZYSbfLQKWKfADtJ0kGYsCvSYOZrO66tSc9dkboUt/w==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.59': resolution: {integrity: sha512-9Um/UpruN76AdpiLnvwChVkJJwJ9Vx9ykk/2AeLxxSCM/YYRD8Kkq2towUk9fZQLV7dd9ATlsi87U7hKs0z/iQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.32': - resolution: {integrity: sha512-UxgwT1HmZz1QPXuBy5ZUPJNFXOSlhwdQL61eGhWRthF0xRrT02BCOVJ1p5Ejg5AXfnESTWoKPJ7v/sCkNUtB9g==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.58': resolution: {integrity: sha512-H3q96qF8/DJsPsXMVtMRqSWOc85K5O4zos32untdw+vE5vw0f3a6qJo1YqbND4BsEIKd4iZmzzVUq9kV4LjbHg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.33': - resolution: {integrity: sha512-6pGQnEdSeRvBViTQh/FwaRKB38a3Th+W2mVxuvqAd2Z1Ayo3e6eJ5QqJoZwEMwR6xoxkl3wz3qAfiB1xRhMC+w==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.61': resolution: {integrity: sha512-2U2KHMRCt1dlZoLU3KZR5g5EL4b0h2HHw96SkaUBK7qvEXPZj5rGRO/3ZTeJmh37dIYQuCnA2273rZOQvmsiHw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.28': - resolution: {integrity: sha512-CRAlD8u6oNBhjnX/3ekVGocarD+lFmEn/qeDzytgIdmwrmwMJGFPqS9lGwEfhOTihZKrQ0xSp3z6paX+iXJJhA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.52': resolution: {integrity: sha512-Aff9Ebs42lz+Ep1wkS+Nlwh5S0eahakpyskPsuKGjiBJ6ExOjNtxbfKJTKovQtQNgJ7oG1BH6esJwGrbs7qgSA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.32': - resolution: {integrity: sha512-whhmQghRYOt9mJxFyVMhX7eB8n0oA25OCvqoR7dzFAZjmioCkf7WVB22Bc6llM5cFpBXFX7s4Jv+xVq32VPGWg==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.58': resolution: {integrity: sha512-syloC58mXOacUqM2toPNfwd7X3jT+tWj0F/cN7qdW1FQyI0q41J0tPf6DIZ56BF0x82iS9j3ALP45MoBz79YuQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.32': - resolution: {integrity: sha512-Z0Y0LDaqyQDznlmr9gv6n4+eWKKWNgmi9j5L6RENr6wyOCguhO8FRPmqDbVLSw0DPdMqICKnA3PurJiS8bD6Cw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.58': resolution: {integrity: sha512-pTBImKzcGK+pcMKjL0fAJbnYzzYd1c0UDc7BSIOGNQhF9Nuk66vWlIXfYTYyzNSs+w8Q/vfbbNDDU8zdrouwLg==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.14': - resolution: {integrity: sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg==} - engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.25': resolution: {integrity: sha512-df7HN1ozwMrB9+59re9PM7tSLxLAcheMWc5u/KyfCPCAWtN/vP7y7RTUZOy48uT1K9MESisVeOPPzF3O1AW01A==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.10': - resolution: {integrity: sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.21': resolution: {integrity: sha512-HvLgDnxBLaHi9E5K++6Vuk+1+qqn7Pmn8zrlzd+NXH3jBzwujnuzZtAR9WHPkbUGPO92FkoQWj/M1IsdxTlBmQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.10': - resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-logger@3.972.10': - resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-recursion-detection@3.972.11': - resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-sdk-s3@3.972.31': - resolution: {integrity: sha512-5hS08Fp0Rm+59uGCmkWhZmveXiA7OUV7Wa+IARejdzf9JTZ1qAVeIOE9JoBpsLPvUgEjmsGNHBuFbtGmYyqiqQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-user-agent@3.972.32': - resolution: {integrity: sha512-HQ0x9DDKqLZOGhDiL2eicYXXkYT5dogE4mw0lAfHCpJ6t7MM0PNIsJl2TZzWKU9SpBzOMXHRa7K6ZLKUJu1y0w==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-websocket@3.972.16': - resolution: {integrity: sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg==} - engines: {node: '>= 14.0.0'} - '@aws-sdk/middleware-websocket@3.972.34': resolution: {integrity: sha512-8dxKLu5bC74SLwwoYV8RIiCD48jMbMt1Ccl3m+xtQJKet6QsZ4xzJlK6UDg7QNEzm/ZCUknJfGsBHmhkgOfuIQ==} engines: {node: '>= 14.0.0'} - '@aws-sdk/nested-clients@3.997.0': - resolution: {integrity: sha512-4bI5GHjUiY5R8N6PtchpG6tW2Dl8I2IcZNg3JwqwxHRXjfvQlPoo4VMknG4qkd5W0t3Y20rQ6C7pSR561YG5JQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.26': resolution: {integrity: sha512-Lwe3F6K7bs+jEubp1LbrvzeMBYb5fMazJ1IxV9TtKWPF8CSh67Fmwyq9fLz3NL/k55Dfpuph5Dimw76JFgr+SA==} engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.972.12': - resolution: {integrity: sha512-QQI43Mxd53nBij0pm8HXC+t4IOC6gnhhZfzxE0OATQyO6QfPV4e+aTIRRuAJKA6Nig/cR8eLwPryqYTX9ZrjAQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/signature-v4-multi-region@3.996.19': - resolution: {integrity: sha512-7Sy8+GhfwUi06NQNLplxuJuXMKJURDsNQfK8yTW6E9wN2J1B+8S5dWZG7vg3InvPPhaXqkcYTr8pzeE+dLjMbQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.38': resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1033.0': - resolution: {integrity: sha512-/TsXhqjyRAFb0xVgmbFAha3cJfZdWjnyn6ohJ3AB4E3peLgxNcmKfYr45hruHymyJAydiHoXC3N1a8qgl41cog==} - engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1048.0': resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} engines: {node: '>=20.0.0'} @@ -437,38 +348,10 @@ packages: resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-arn-parser@3.972.3': - resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.996.7': - resolution: {integrity: sha512-ty4LQxN1QC+YhUP28NfEgZDEGXkyqOQy+BDriBozqHsrYO4JMgiPhfizqOGF7P+euBTZ5Ez6SKlLAMCLo8tzmw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-format-url@3.972.10': - resolution: {integrity: sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.5': resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-user-agent-browser@3.972.10': - resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} - - '@aws-sdk/util-user-agent-node@3.973.18': - resolution: {integrity: sha512-Nh4YvAL0Mzv5jBvzXLFL0tLf7WPrRMnYZQ5jlFuyS0xiVJQsObMUKAkbYjmt/e04wpQqUaa+Is7k+mBr89A9yA==} - engines: {node: '>=20.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true - - '@aws-sdk/xml-builder@3.972.18': - resolution: {integrity: sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.33': resolution: {integrity: sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==} engines: {node: '>=20.0.0'} @@ -672,8 +555,8 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} - '@borewit/text-codec@0.2.2': - resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@bufbuild/protobuf@2.15.0': + resolution: {integrity: sha512-DAheWUkVr/SJTWCc+lg9dhY0eN4SaWlf4+bG1KzHeXbnqt0AfB/NX0Z+VunGlM1ki1B4zVvye27MpKh/svySUA==} '@capsizecss/unpack@4.0.0': resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} @@ -742,6 +625,17 @@ packages: '@cloudflare/workers-types@4.20260702.1': resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + '@connectrpc/connect-web@2.2.0': + resolution: {integrity: sha512-uuBUcBTYfb/r1o98LzCCZk1H90yx0vmZ5m21dpw/q4EUwsKwkCDGI8H0aEdjcZld+Q2jJ5A5ADvPqfb6lzQ/sg==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@connectrpc/connect': 2.2.0 + + '@connectrpc/connect@2.2.0': + resolution: {integrity: sha512-M63OQ1ZFcPUm85CuewJ9G9CMV8LjzGR9935xOnXNQGSazAbwMb3x2W7vXBYM1zqrP7XFD8uxp/dUAhirclgwnQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1094,15 +988,6 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - '@google/genai@1.50.1': - resolution: {integrity: sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@modelcontextprotocol/sdk': ^1.25.2 - peerDependenciesMeta: - '@modelcontextprotocol/sdk': - optional: true - '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -1265,6 +1150,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1352,9 +1241,6 @@ packages: resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} engines: {node: '>= 10'} - '@mistralai/mistralai@2.2.1': - resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} - '@mistralai/mistralai@2.2.6': resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} peerDependencies: @@ -2288,105 +2174,26 @@ packages: '@silvia-odwyer/photon-node@0.3.4': resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} - '@sinclair/typebox@0.34.49': - resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} - '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} - '@smithy/config-resolver@4.4.17': - resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} - engines: {node: '>=18.0.0'} - - '@smithy/core@3.23.16': - resolution: {integrity: sha512-JStomOrINQA1VqNEopLsgcdgwd42au7mykKqVr30XFw89wLt9sDxJDi4djVPRwQmmzyTGy/uOvTc2ultMpFi1w==} - engines: {node: '>=18.0.0'} - '@smithy/core@3.29.0': resolution: {integrity: sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.14': - resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} - engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.4.5': resolution: {integrity: sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.14': - resolution: {integrity: sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-browser@4.2.14': - resolution: {integrity: sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-config-resolver@4.3.14': - resolution: {integrity: sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-node@4.2.14': - resolution: {integrity: sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-universal@4.2.14': - resolution: {integrity: sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==} - engines: {node: '>=18.0.0'} - - '@smithy/fetch-http-handler@5.3.17': - resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} - engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.6.2': resolution: {integrity: sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ==} engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.14': - resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} - engines: {node: '>=18.0.0'} - - '@smithy/invalid-dependency@4.2.14': - resolution: {integrity: sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==} - engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@4.2.2': - resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-content-length@4.2.14': - resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-endpoint@4.4.31': - resolution: {integrity: sha512-KJPdCIN2kOE2aGmqZd7eUTr4WQwOGgtLWgUkswGJggs7rBcQYQjcZMEDa3C0DwbOiXS9L8/wDoQHkfxBYLfiLw==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-retry@4.5.4': - resolution: {integrity: sha512-/z7nIFK+ZRW3Ie/l3NEVGdy34LvmEOzBrtBAvgWZ/4PrKX0xP3kWm8pkfcwUk523SqxZhdbQP9JSXgjF77Uhpw==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-serde@4.2.19': - resolution: {integrity: sha512-Q6y+W9h3iYVMCKWDoVge+OC1LKFqbEKaq8SIWG2X2bWJRpd/6dDLyICcNLT6PbjH3Rr6bmg/SeDB25XFOFfeEw==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-stack@4.2.14': - resolution: {integrity: sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==} - engines: {node: '>=18.0.0'} - - '@smithy/node-config-provider@4.3.14': - resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} - engines: {node: '>=18.0.0'} - - '@smithy/node-http-handler@4.6.0': - resolution: {integrity: sha512-P734cAoTFtuGfWa/R3jgBnGlURt2w9bYEBwQNMKf58sRM9RShirB2mKwLsVP+jlG/wxpCu8abv8NxdUts8tdLA==} - engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.7.3': resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} engines: {node: '>=18.0.0'} @@ -2395,42 +2202,10 @@ packages: resolution: {integrity: sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.14': - resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} - engines: {node: '>=18.0.0'} - - '@smithy/protocol-http@5.3.14': - resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} - engines: {node: '>=18.0.0'} - - '@smithy/querystring-builder@4.2.14': - resolution: {integrity: sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==} - engines: {node: '>=18.0.0'} - - '@smithy/querystring-parser@4.2.14': - resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} - engines: {node: '>=18.0.0'} - - '@smithy/service-error-classification@4.3.0': - resolution: {integrity: sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==} - engines: {node: '>=18.0.0'} - - '@smithy/shared-ini-file-loader@4.4.9': - resolution: {integrity: sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==} - engines: {node: '>=18.0.0'} - - '@smithy/signature-v4@5.3.14': - resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} - engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.6.1': resolution: {integrity: sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.12.12': - resolution: {integrity: sha512-daO7SJn4eM6ArbmrEs+/BTbH7af8AEbSL3OMQdcRvvn8tuUcR5rU2n6DgxIV53aXMS42uwK8NgKKCh5XgqYOPQ==} - engines: {node: '>=18.0.0'} - '@smithy/types@4.14.1': resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} engines: {node: '>=18.0.0'} @@ -2439,78 +2214,14 @@ packages: resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.2.14': - resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-base64@4.3.2': - resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-browser@4.2.2': - resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-node@4.2.3': - resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} - engines: {node: '>=18.0.0'} - '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@4.2.2': - resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} - engines: {node: '>=18.0.0'} - - '@smithy/util-config-provider@4.2.2': - resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-browser@4.3.48': - resolution: {integrity: sha512-hxVRVPYaRDWa6YQdse1aWX1qrksmLsvNyGBKdc32q4jFzSjxYVNWfstknAfR228TnzS4tzgswXRuYIbhXBuXFQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-node@4.2.53': - resolution: {integrity: sha512-ybgCk+9JdBq8pYC8Y6U5fjyS8e4sboyAShetxPNL0rRBtaVl56GSFAxsolVBIea1tXR4LPIzL8i6xqmcf0+DCQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-endpoints@3.4.2': - resolution: {integrity: sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-hex-encoding@4.2.2': - resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-middleware@4.2.14': - resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-retry@4.3.3': - resolution: {integrity: sha512-idjUvd4M9Jj6rXkhqw4H4reHoweuK4ZxYWyOrEp4N2rOF5VtaOlQGLDQJva/8WanNXk9ScQtsAb7o5UHGvFm4A==} - engines: {node: '>=18.0.0'} - - '@smithy/util-stream@4.5.24': - resolution: {integrity: sha512-na5vv2mBSDzXewLEEoWGI7LQQkfpmFEomBsmOpzLFjqGctm0iMwXY5lAwesY9pIaErkccW0qzEOUcYP+WKneXg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-uri-escape@4.2.2': - resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} - engines: {node: '>=18.0.0'} - '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@4.2.2': - resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} - engines: {node: '>=18.0.0'} - - '@smithy/uuid@1.1.2': - resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} - engines: {node: '>=18.0.0'} - '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} @@ -2698,16 +2409,6 @@ packages: engines: {node: '>=20.19'} hasBin: true - '@tokenizer/inflate@0.4.1': - resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} - engines: {node: '>=18'} - - '@tokenizer/token@0.3.0': - resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2741,9 +2442,6 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - '@types/mime-types@2.1.4': - resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2773,9 +2471,6 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -2835,10 +2530,6 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -2847,9 +2538,6 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2872,10 +2560,6 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - astro@5.18.2: resolution: {integrity: sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} @@ -2909,10 +2593,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - basic-ftp@5.3.0: - resolution: {integrity: sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==} - engines: {node: '>=10.0.0'} - better-auth@1.6.14: resolution: {integrity: sha512-c0/DvTQGDpgfj1knekCpQrg6PSWGDtfAtP7Ou6FkAhoE3RNnnIxLB5qKj6tRg53a1xsq93G6T68cNxrUZ7ZVmw==} peerDependencies: @@ -3016,9 +2696,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -3043,10 +2720,6 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -3075,6 +2748,10 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -3086,25 +2763,10 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} - cli-highlight@2.1.11: - resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} - engines: {node: '>=8.0.0', npm: '>=5.0.0'} - hasBin: true - - cliui@7.0.4: - resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -3115,6 +2777,9 @@ packages: common-ancestor-path@1.0.1: resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -3166,10 +2831,6 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3189,10 +2850,6 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -3224,6 +2881,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dockerfile-ast@0.7.1: + resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -3241,6 +2901,10 @@ packages: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} + e2b@2.51.0: + resolution: {integrity: sha512-P+50lx1nOYpgCFyE1xuz6JVN/TqJivMUiH7v+RW0CUSfb4+NCu8IkC5KruT2sNPgp6aexddQQ77+OZKGpZZJ+Q==} + engines: {node: '>=20.18.1 <21 || >=22'} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -3253,9 +2917,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.21.0: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} @@ -3292,20 +2953,6 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} @@ -3315,10 +2962,6 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -3329,21 +2972,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - - fast-xml-builder@1.1.5: - resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} - - fast-xml-parser@5.5.8: - resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} - hasBin: true - - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3357,10 +2985,6 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} - file-type@21.3.4: - resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} - engines: {node: '>=20'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3397,10 +3021,6 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.5.0: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} @@ -3413,14 +3033,6 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - - get-uri@6.0.5: - resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} - engines: {node: '>= 14'} - github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -3451,10 +3063,6 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - hast-util-from-html@2.0.3: resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} @@ -3495,10 +3103,6 @@ packages: resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==} engines: {node: '>=16.9.0'} - hosted-git-info@9.0.2: - resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} - engines: {node: ^20.17.0 || >=22.9.0} - hosted-git-info@9.0.3: resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3523,9 +3127,6 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} @@ -3536,10 +3137,6 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -3652,9 +3249,6 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - koffi@2.16.1: - resolution: {integrity: sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==} - kysely@0.28.17: resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} engines: {node: '>=20.0.0'} @@ -3749,10 +3343,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - lucide-react@1.11.0: resolution: {integrity: sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==} peerDependencies: @@ -3767,11 +3357,6 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@15.0.12: - resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} - engines: {node: '>= 18'} - hasBin: true - marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} @@ -3915,14 +3500,6 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - miniflare@4.20260611.0: resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} engines: {node: '>=22.0.0'} @@ -3936,6 +3513,10 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -3943,9 +3524,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3959,10 +3537,6 @@ packages: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} - netmask@2.1.1: - resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} - engines: {node: '>= 0.4.0'} - nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} @@ -3991,19 +3565,12 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -4022,6 +3589,12 @@ packages: zod: optional: true + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + p-limit@6.2.0: resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} engines: {node: '>=18'} @@ -4038,14 +3611,6 @@ packages: resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} engines: {node: '>=14.16'} - pac-proxy-agent@7.2.0: - resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -4055,25 +3620,12 @@ packages: parse-latin@7.0.0: resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} - parse5-htmlparser2-tree-adapter@6.0.1: - resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} - - parse5@5.1.1: - resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} - - parse5@6.0.1: - resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} - parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} - path-expression-matcher@1.5.0: - resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} - engines: {node: '>=14.0.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4092,9 +3644,6 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -4109,6 +3658,9 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -4140,16 +3692,6 @@ packages: resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} engines: {node: '>=12.0.0'} - proxy-agent@6.5.0: - resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} - engines: {node: '>= 14'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} peerDependencies: @@ -4266,10 +3808,6 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - retext-latin@4.0.0: resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} @@ -4359,30 +3897,14 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -4414,13 +3936,6 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - strnum@2.2.3: - resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} - - strtok3@10.3.5: - resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} - engines: {node: '>=18'} - style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -4431,10 +3946,6 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - svgo@4.0.1: resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} @@ -4450,12 +3961,9 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -4490,10 +3998,6 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - token-types@6.1.2: - resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} - engines: {node: '>=14.16'} - trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4524,9 +4028,6 @@ packages: typebox@1.1.38: resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} - typebox@1.2.16: - resolution: {integrity: sha512-p85YWtR1RznP2kt/sftmj1leGiObfHVJwkK2hkDdEKhbtQONeUeBtbYdK0x9tLPh+5AaEPD62APv189Cy+/C2g==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -4535,10 +4036,6 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - uint8array-extras@1.5.0: - resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} - engines: {node: '>=18'} - ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} @@ -4552,6 +4049,14 @@ packages: resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} engines: {node: '>=20.18.1'} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + undici@8.5.0: resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} engines: {node: '>=22.19.0'} @@ -4692,10 +4197,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@14.0.0: - resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} - hasBin: true - vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -4786,6 +4287,12 @@ packages: jsdom: optional: true + vscode-languageserver-textdocument@1.0.14: + resolution: {integrity: sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==} + + vscode-languageserver-types@3.18.3: + resolution: {integrity: sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -4829,17 +4336,10 @@ packages: '@cloudflare/workers-types': optional: true - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -4867,38 +4367,22 @@ packages: xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} - engines: {node: '>= 14.6'} - hasBin: true + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true - yargs-parser@20.2.9: - resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} - engines: {node: '>=10'} - yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} - engines: {node: '>=10'} - - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -5003,15 +4487,15 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@4.4.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yaml@2.8.3)': + '@astrojs/react@4.4.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yaml@2.9.0)': dependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': 4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) + '@vitejs/plugin-react': 4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) ultrahtml: 1.6.0 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -5038,12 +4522,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@aws-crypto/crc32@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 - tslib: 2.8.1 - '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -5070,58 +4548,6 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.1033.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.2 - '@aws-sdk/credential-provider-node': 3.972.33 - '@aws-sdk/eventstream-handler-node': 3.972.14 - '@aws-sdk/middleware-eventstream': 3.972.10 - '@aws-sdk/middleware-host-header': 3.972.10 - '@aws-sdk/middleware-logger': 3.972.10 - '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.32 - '@aws-sdk/middleware-websocket': 3.972.16 - '@aws-sdk/region-config-resolver': 3.972.12 - '@aws-sdk/token-providers': 3.1033.0 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.7 - '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.18 - '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.16 - '@smithy/eventstream-serde-browser': 4.2.14 - '@smithy/eventstream-serde-config-resolver': 4.3.14 - '@smithy/eventstream-serde-node': 4.2.14 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/hash-node': 4.2.14 - '@smithy/invalid-dependency': 4.2.14 - '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.31 - '@smithy/middleware-retry': 4.5.4 - '@smithy/middleware-serde': 4.2.19 - '@smithy/middleware-stack': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.0 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.48 - '@smithy/util-defaults-mode-node': 4.2.53 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 - '@smithy/util-stream': 4.5.24 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/client-bedrock-runtime@3.1048.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -5139,22 +4565,6 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/core@3.974.2': - dependencies: - '@aws-sdk/types': 3.973.8 - '@aws-sdk/xml-builder': 3.972.18 - '@smithy/core': 3.23.16 - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - '@aws-sdk/core@3.974.26': dependencies: '@aws-sdk/types': 3.973.15 @@ -5166,14 +4576,6 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.28': - dependencies: - '@aws-sdk/core': 3.974.2 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.52': dependencies: '@aws-sdk/core': 3.974.26 @@ -5182,19 +4584,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.30': - dependencies: - '@aws-sdk/core': 3.974.2 - '@aws-sdk/types': 3.973.8 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.0 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.5.24 - tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.54': dependencies: '@aws-sdk/core': 3.974.26 @@ -5205,25 +4594,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.32': - dependencies: - '@aws-sdk/core': 3.974.2 - '@aws-sdk/credential-provider-env': 3.972.28 - '@aws-sdk/credential-provider-http': 3.972.30 - '@aws-sdk/credential-provider-login': 3.972.32 - '@aws-sdk/credential-provider-process': 3.972.28 - '@aws-sdk/credential-provider-sso': 3.972.32 - '@aws-sdk/credential-provider-web-identity': 3.972.32 - '@aws-sdk/nested-clients': 3.997.0 - '@aws-sdk/types': 3.973.8 - '@smithy/credential-provider-imds': 4.2.14 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-ini@3.972.59': dependencies: '@aws-sdk/core': 3.974.26 @@ -5240,19 +4610,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.32': - dependencies: - '@aws-sdk/core': 3.974.2 - '@aws-sdk/nested-clients': 3.997.0 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-login@3.972.58': dependencies: '@aws-sdk/core': 3.974.26 @@ -5262,23 +4619,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.33': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.28 - '@aws-sdk/credential-provider-http': 3.972.30 - '@aws-sdk/credential-provider-ini': 3.972.32 - '@aws-sdk/credential-provider-process': 3.972.28 - '@aws-sdk/credential-provider-sso': 3.972.32 - '@aws-sdk/credential-provider-web-identity': 3.972.32 - '@aws-sdk/types': 3.973.8 - '@smithy/credential-provider-imds': 4.2.14 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-node@3.972.61': dependencies: '@aws-sdk/credential-provider-env': 3.972.52 @@ -5293,15 +4633,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.28': - dependencies: - '@aws-sdk/core': 3.974.2 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.52': dependencies: '@aws-sdk/core': 3.974.26 @@ -5310,19 +4641,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.32': - dependencies: - '@aws-sdk/core': 3.974.2 - '@aws-sdk/nested-clients': 3.997.0 - '@aws-sdk/token-providers': 3.1033.0 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-sso@3.972.58': dependencies: '@aws-sdk/core': 3.974.26 @@ -5333,18 +4651,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.32': - dependencies: - '@aws-sdk/core': 3.974.2 - '@aws-sdk/nested-clients': 3.997.0 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.58': dependencies: '@aws-sdk/core': 3.974.26 @@ -5354,13 +4660,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/eventstream-handler-node@3.972.14': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/eventstream-codec': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - '@aws-sdk/eventstream-handler-node@3.972.25': dependencies: '@aws-sdk/types': 3.973.15 @@ -5368,13 +4667,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.21': dependencies: '@aws-sdk/types': 3.973.15 @@ -5382,70 +4674,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@aws-sdk/middleware-logger@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@aws-sdk/middleware-recursion-detection@3.972.11': - dependencies: - '@aws-sdk/types': 3.973.8 - '@aws/lambda-invoke-store': 0.2.4 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@aws-sdk/middleware-sdk-s3@3.972.31': - dependencies: - '@aws-sdk/core': 3.974.2 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-arn-parser': 3.972.3 - '@smithy/core': 3.23.16 - '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 - '@smithy/util-config-provider': 4.2.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.24 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@aws-sdk/middleware-user-agent@3.972.32': - dependencies: - '@aws-sdk/core': 3.974.2 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.7 - '@smithy/core': 3.23.16 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-retry': 4.3.3 - tslib: 2.8.1 - - '@aws-sdk/middleware-websocket@3.972.16': - dependencies: - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-format-url': 3.972.10 - '@smithy/eventstream-codec': 4.2.14 - '@smithy/eventstream-serde-browser': 4.2.14 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-hex-encoding': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.34': dependencies: '@aws-sdk/core': 3.974.26 @@ -5456,50 +4684,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.2 - '@aws-sdk/middleware-host-header': 3.972.10 - '@aws-sdk/middleware-logger': 3.972.10 - '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.32 - '@aws-sdk/region-config-resolver': 3.972.12 - '@aws-sdk/signature-v4-multi-region': 3.996.19 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.7 - '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.18 - '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.16 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/hash-node': 4.2.14 - '@smithy/invalid-dependency': 4.2.14 - '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.31 - '@smithy/middleware-retry': 4.5.4 - '@smithy/middleware-serde': 4.2.19 - '@smithy/middleware-stack': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.0 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.48 - '@smithy/util-defaults-mode-node': 4.2.53 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/nested-clients@3.997.26': dependencies: '@aws-sdk/core': 3.974.26 @@ -5511,23 +4695,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/region-config-resolver@3.972.12': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/config-resolver': 4.4.17 - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@aws-sdk/signature-v4-multi-region@3.996.19': - dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.31 - '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.38': dependencies: '@aws-sdk/types': 3.973.15 @@ -5535,18 +4702,6 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1033.0': - dependencies: - '@aws-sdk/core': 3.974.2 - '@aws-sdk/nested-clients': 3.997.0 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/token-providers@3.1048.0': dependencies: '@aws-sdk/core': 3.974.26 @@ -5575,51 +4730,10 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/util-arn-parser@3.972.3': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.996.7': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-endpoints': 3.4.2 - tslib: 2.8.1 - - '@aws-sdk/util-format-url@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.5': dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 - bowser: 2.14.1 - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-node@3.973.18': - dependencies: - '@aws-sdk/middleware-user-agent': 3.972.32 - '@aws-sdk/types': 3.973.8 - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-config-provider': 4.2.2 - tslib: 2.8.1 - - '@aws-sdk/xml-builder@3.972.18': - dependencies: - '@smithy/types': 4.14.1 - fast-xml-parser: 5.5.8 - tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.33': dependencies: '@smithy/types': 4.15.1 @@ -5764,7 +4878,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': + '@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.1.21 @@ -5777,37 +4891,38 @@ snapshots: zod: 4.3.6 optionalDependencies: '@cloudflare/workers-types': 4.20260702.1 + '@opentelemetry/api': 1.9.0 - '@better-auth/drizzle-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/drizzle-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - '@better-auth/kysely-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.28.17)': + '@better-auth/kysely-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.28.17)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 optionalDependencies: kysely: 0.28.17 - '@better-auth/memory-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/memory-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - '@better-auth/mongo-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/mongo-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - '@better-auth/prisma-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/prisma-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - '@better-auth/telemetry@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)': + '@better-auth/telemetry@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.1.21 @@ -5817,7 +4932,7 @@ snapshots: '@better-fetch/fetch@1.1.21': {} - '@borewit/text-codec@0.2.2': {} + '@bufbuild/protobuf@2.15.0': {} '@capsizecss/unpack@4.0.0': dependencies: @@ -5857,6 +4972,15 @@ snapshots: '@cloudflare/workers-types@4.20260702.1': {} + '@connectrpc/connect-web@2.2.0(@bufbuild/protobuf@2.15.0)(@connectrpc/connect@2.2.0(@bufbuild/protobuf@2.15.0))': + dependencies: + '@bufbuild/protobuf': 2.15.0 + '@connectrpc/connect': 2.2.0(@bufbuild/protobuf@2.15.0) + + '@connectrpc/connect@2.2.0(@bufbuild/protobuf@2.15.0)': + dependencies: + '@bufbuild/protobuf': 2.15.0 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -6130,17 +5254,6 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@google/genai@1.50.1': - dependencies: - google-auth-library: 10.6.2 - p-retry: 4.6.2 - protobufjs: 7.5.5 - ws: 8.18.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@google/genai@1.52.0': dependencies: google-auth-library: 10.6.2 @@ -6248,6 +5361,10 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6316,15 +5433,6 @@ snapshots: '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 optional: true - '@mistralai/mistralai@2.2.1': - dependencies: - ws: 8.18.0 - zod: 4.3.6 - zod-to-json-schema: 3.25.2(zod@4.3.6) - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/semantic-conventions': 1.41.1 @@ -7213,371 +6321,96 @@ snapshots: '@rollup/rollup-win32-x64-gnu@4.60.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.2': - optional: true - - '@shikijs/core@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - - '@shikijs/engine-javascript@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.6 - - '@shikijs/engine-oniguruma@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - - '@shikijs/langs@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - - '@shikijs/themes@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - - '@shikijs/types@3.23.0': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/vscode-textmate@10.0.2': {} - - '@silvia-odwyer/photon-node@0.3.4': {} - - '@sinclair/typebox@0.34.49': {} - - '@sindresorhus/is@7.2.0': {} - - '@smithy/config-resolver@4.4.17': - dependencies: - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-config-provider': 4.2.2 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - tslib: 2.8.1 - - '@smithy/core@3.23.16': - dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.24 - '@smithy/util-utf8': 4.2.2 - '@smithy/uuid': 1.1.2 - tslib: 2.8.1 - - '@smithy/core@3.29.0': - dependencies: - '@smithy/types': 4.15.1 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.2.14': - dependencies: - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.4.5': - dependencies: - '@smithy/core': 3.29.0 - '@smithy/types': 4.15.1 - tslib: 2.8.1 - - '@smithy/eventstream-codec@4.2.14': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.1 - '@smithy/util-hex-encoding': 4.2.2 - tslib: 2.8.1 - - '@smithy/eventstream-serde-browser@4.2.14': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/eventstream-serde-config-resolver@4.3.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/eventstream-serde-node@4.2.14': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/eventstream-serde-universal@4.2.14': - dependencies: - '@smithy/eventstream-codec': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@5.3.17': - dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@5.6.2': - dependencies: - '@smithy/core': 3.29.0 - '@smithy/types': 4.15.1 - tslib: 2.8.1 - - '@smithy/hash-node@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - '@smithy/util-buffer-from': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@smithy/invalid-dependency@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/is-array-buffer@2.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/is-array-buffer@4.2.2': - dependencies: - tslib: 2.8.1 - - '@smithy/middleware-content-length@4.2.14': - dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/middleware-endpoint@4.4.31': - dependencies: - '@smithy/core': 3.23.16 - '@smithy/middleware-serde': 4.2.19 - '@smithy/node-config-provider': 4.3.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-middleware': 4.2.14 - tslib: 2.8.1 - - '@smithy/middleware-retry@4.5.4': - dependencies: - '@smithy/core': 3.23.16 - '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/service-error-classification': 4.3.0 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 - '@smithy/uuid': 1.1.2 - tslib: 2.8.1 - - '@smithy/middleware-serde@4.2.19': - dependencies: - '@smithy/core': 3.23.16 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/middleware-stack@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/node-config-provider@4.3.14': - dependencies: - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.6.0': - dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.7.3': - dependencies: - '@smithy/core': 3.29.0 - '@smithy/types': 4.15.1 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.9.2': - dependencies: - '@smithy/core': 3.29.0 - '@smithy/types': 4.15.1 - tslib: 2.8.1 - - '@smithy/property-provider@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/protocol-http@5.3.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/querystring-builder@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - '@smithy/util-uri-escape': 4.2.2 - tslib: 2.8.1 - - '@smithy/querystring-parser@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/service-error-classification@4.3.0': - dependencies: - '@smithy/types': 4.14.1 - - '@smithy/shared-ini-file-loader@4.4.9': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/signature-v4@5.3.14': - dependencies: - '@smithy/is-array-buffer': 4.2.2 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-hex-encoding': 4.2.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-uri-escape': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@smithy/signature-v4@5.6.1': - dependencies: - '@smithy/core': 3.29.0 - '@smithy/types': 4.15.1 - tslib: 2.8.1 + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true - '@smithy/smithy-client@4.12.12': + '@shikijs/core@3.23.0': dependencies: - '@smithy/core': 3.23.16 - '@smithy/middleware-endpoint': 4.4.31 - '@smithy/middleware-stack': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.5.24 - tslib: 2.8.1 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 - '@smithy/types@4.14.1': + '@shikijs/engine-javascript@3.23.0': dependencies: - tslib: 2.8.1 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 - '@smithy/types@4.15.1': + '@shikijs/engine-oniguruma@3.23.0': dependencies: - tslib: 2.8.1 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 - '@smithy/url-parser@4.2.14': + '@shikijs/langs@3.23.0': dependencies: - '@smithy/querystring-parser': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 + '@shikijs/types': 3.23.0 - '@smithy/util-base64@4.3.2': + '@shikijs/themes@3.23.0': dependencies: - '@smithy/util-buffer-from': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 + '@shikijs/types': 3.23.0 - '@smithy/util-body-length-browser@4.2.2': + '@shikijs/types@3.23.0': dependencies: - tslib: 2.8.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 - '@smithy/util-body-length-node@4.2.3': - dependencies: - tslib: 2.8.1 + '@shikijs/vscode-textmate@10.0.2': {} - '@smithy/util-buffer-from@2.2.0': - dependencies: - '@smithy/is-array-buffer': 2.2.0 - tslib: 2.8.1 + '@silvia-odwyer/photon-node@0.3.4': {} - '@smithy/util-buffer-from@4.2.2': + '@sindresorhus/is@7.2.0': {} + + '@smithy/core@3.29.0': dependencies: - '@smithy/is-array-buffer': 4.2.2 + '@smithy/types': 4.15.1 tslib: 2.8.1 - '@smithy/util-config-provider@4.2.2': + '@smithy/credential-provider-imds@4.4.5': dependencies: + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.48': + '@smithy/fetch-http-handler@5.6.2': dependencies: - '@smithy/property-provider': 4.2.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.53': + '@smithy/is-array-buffer@2.2.0': dependencies: - '@smithy/config-resolver': 4.4.17 - '@smithy/credential-provider-imds': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-endpoints@3.4.2': + '@smithy/node-http-handler@4.7.3': dependencies: - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.2': + '@smithy/node-http-handler@4.9.2': dependencies: + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 - '@smithy/util-middleware@4.2.14': + '@smithy/signature-v4@5.6.1': dependencies: - '@smithy/types': 4.14.1 + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 - '@smithy/util-retry@4.3.3': + '@smithy/types@4.14.1': dependencies: - '@smithy/service-error-classification': 4.3.0 - '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-stream@4.5.24': + '@smithy/types@4.15.1': dependencies: - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.0 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-buffer-from': 4.2.2 - '@smithy/util-hex-encoding': 4.2.2 - '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.2': + '@smithy/util-buffer-from@2.2.0': dependencies: + '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 '@smithy/util-utf8@2.3.0': @@ -7585,15 +6418,6 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.2.2': - dependencies: - '@smithy/util-buffer-from': 4.2.2 - tslib: 2.8.1 - - '@smithy/uuid@1.1.2': - dependencies: - tslib: 2.8.1 - '@speed-highlight/core@1.2.15': {} '@standard-schema/spec@1.1.0': {} @@ -7664,12 +6488,12 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.4 - '@tailwindcss/vite@4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3))': + '@tailwindcss/vite@4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) '@tanstack/history@1.161.6': {} @@ -7728,7 +6552,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.27(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3))': + '@tanstack/router-plugin@1.167.27(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -7745,7 +6569,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -7767,17 +6591,6 @@ snapshots: '@tanstack/virtual-file-routes@1.161.7': {} - '@tokenizer/inflate@0.4.1': - dependencies: - debug: 4.4.3 - token-types: 6.1.2 - transitivePeerDependencies: - - supports-color - - '@tokenizer/token@0.3.0': {} - - '@tootallnate/quickjs-emscripten@0.23.0': {} - '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -7824,8 +6637,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/mime-types@2.1.4': {} - '@types/ms@2.1.0': {} '@types/nlcst@2.0.3': @@ -7854,14 +6665,9 @@ snapshots: dependencies: '@types/node': 24.12.2 - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 24.12.2 - optional: true - '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3))': + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -7869,7 +6675,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -7881,13 +6687,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3))': + '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -7927,16 +6733,10 @@ snapshots: ansi-regex@6.2.2: {} - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - ansi-styles@6.2.3: {} ansis@4.2.0: {} - any-promise@1.3.0: {} - anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -7954,11 +6754,7 @@ snapshots: assertion-error@2.0.1: {} - ast-types@0.13.4: - dependencies: - tslib: 2.8.1 - - astro@5.18.2(@types/node@24.12.2)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@5.9.3)(yaml@2.8.3): + astro@5.18.2(@types/node@24.12.2)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@5.9.3)(yaml@2.9.0): dependencies: '@astrojs/compiler': 2.13.1 '@astrojs/internal-helpers': 0.7.6 @@ -8015,8 +6811,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5(aws4fetch@1.0.20) vfile: 6.0.3 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) - vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -8083,17 +6879,15 @@ snapshots: baseline-browser-mapping@2.10.22: {} - basic-ftp@5.3.0: {} - - better-auth@1.6.14(@cloudflare/workers-types@4.20260702.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)): + better-auth@1.6.14(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)): dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/kysely-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.28.17) - '@better-auth/memory-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/mongo-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/prisma-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/telemetry': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) + '@better-auth/kysely-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) + '@better-auth/mongo-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) + '@better-auth/prisma-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) + '@better-auth/telemetry': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21) '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.2.0 @@ -8107,7 +6901,7 @@ snapshots: optionalDependencies: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -8158,8 +6952,6 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) - buffer-crc32@0.2.13: {} - buffer-equal-constant-time@1.0.1: {} cac@6.7.14: {} @@ -8180,11 +6972,6 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -8213,6 +7000,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@3.0.0: {} + ci-info@4.4.0: {} class-variance-authority@0.7.1: @@ -8221,35 +7010,16 @@ snapshots: cli-boxes@3.0.0: {} - cli-highlight@2.1.11: - dependencies: - chalk: 4.1.2 - highlight.js: 10.7.3 - mz: 2.7.0 - parse5: 5.1.1 - parse5-htmlparser2-tree-adapter: 6.0.1 - yargs: 16.2.0 - - cliui@7.0.4: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - clsx@2.1.1: {} - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - comma-separated-tokens@2.0.3: {} commander@11.1.0: {} common-ancestor-path@1.0.1: {} + compare-versions@6.1.1: {} + convert-source-map@2.0.0: {} cookie-es@1.2.3: {} @@ -8298,8 +7068,6 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-uri-to-buffer@6.0.2: {} - debug@4.4.3: dependencies: ms: 2.1.3 @@ -8312,12 +7080,6 @@ snapshots: defu@6.1.7: {} - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - dequal@2.0.3: {} destr@2.0.5: {} @@ -8340,6 +7102,11 @@ snapshots: dlv@1.1.3: {} + dockerfile-ast@0.7.1: + dependencies: + vscode-languageserver-textdocument: 1.0.14 + vscode-languageserver-types: 3.18.3 + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -8360,6 +7127,22 @@ snapshots: dset@3.1.4: {} + e2b@2.51.0: + dependencies: + '@bufbuild/protobuf': 2.15.0 + '@connectrpc/connect': 2.2.0(@bufbuild/protobuf@2.15.0) + '@connectrpc/connect-web': 2.2.0(@bufbuild/protobuf@2.15.0)(@connectrpc/connect@2.2.0(@bufbuild/protobuf@2.15.0)) + chalk: 5.6.2 + compare-versions: 6.1.1 + dockerfile-ast: 0.7.1 + glob: 13.0.6 + openapi-fetch: 0.14.1 + platform: 1.3.6 + tar: 7.5.22 + undici: 7.29.1 + optionalDependencies: + undici8: undici@8.10.2 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -8370,10 +7153,6 @@ snapshots: emoji-regex@8.0.0: {} - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - enhanced-resolve@5.21.0: dependencies: graceful-fs: 4.2.11 @@ -8449,18 +7228,6 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - - esprima@4.0.1: {} - - estraverse@5.3.0: {} - estree-util-is-identifier-name@3.0.0: {} estree-walker@2.0.2: {} @@ -8469,38 +7236,12 @@ snapshots: dependencies: '@types/estree': 1.0.8 - esutils@2.0.3: {} - eventemitter3@5.0.4: {} expect-type@1.3.0: {} extend@3.0.2: {} - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - - fast-xml-builder@1.1.5: - dependencies: - path-expression-matcher: 1.5.0 - - fast-xml-parser@5.5.8: - dependencies: - fast-xml-builder: 1.1.5 - path-expression-matcher: 1.5.0 - strnum: 2.2.3 - - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -8510,15 +7251,6 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - file-type@21.3.4: - dependencies: - '@tokenizer/inflate': 0.4.1 - strtok3: 10.3.5 - token-types: 6.1.2 - uint8array-extras: 1.5.0 - transitivePeerDependencies: - - supports-color - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -8558,26 +7290,12 @@ snapshots: gensync@1.0.0-beta.2: {} - get-caller-file@2.0.5: {} - get-east-asian-width@1.5.0: {} get-east-asian-width@1.6.0: {} get-nonce@1.0.1: {} - get-stream@5.2.0: - dependencies: - pump: 3.0.4 - - get-uri@6.0.5: - dependencies: - basic-ftp: 5.3.0 - data-uri-to-buffer: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -8621,8 +7339,6 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 - has-flag@4.0.0: {} - hast-util-from-html@2.0.3: dependencies: '@types/hast': 3.0.4 @@ -8734,10 +7450,6 @@ snapshots: hono@4.13.1: {} - hosted-git-info@9.0.2: - dependencies: - lru-cache: 11.3.5 - hosted-git-info@9.0.3: dependencies: lru-cache: 11.3.5 @@ -8764,16 +7476,12 @@ snapshots: transitivePeerDependencies: - supports-color - ieee754@1.2.1: {} - ignore@7.0.5: {} import-meta-resolve@4.2.0: {} inline-style-parser@0.2.7: {} - ip-address@10.1.0: {} - iron-webcrypto@1.2.1: {} is-alphabetical@2.0.1: {} @@ -8859,9 +7567,6 @@ snapshots: kleur@4.1.5: {} - koffi@2.16.1: - optional: true - kysely@0.28.17: {} lightningcss-android-arm64@1.32.0: @@ -8925,8 +7630,6 @@ snapshots: dependencies: yallist: 3.1.1 - lru-cache@7.18.3: {} - lucide-react@1.11.0(react@19.2.5): dependencies: react: 19.2.5 @@ -8943,8 +7646,6 @@ snapshots: markdown-table@3.0.4: {} - marked@15.0.12: {} - marked@18.0.5: {} mdast-util-definitions@6.0.0: @@ -9301,12 +8002,6 @@ snapshots: transitivePeerDependencies: - supports-color - mime-db@1.54.0: {} - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - miniflare@4.20260611.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -9325,24 +8020,20 @@ snapshots: minipass@7.1.3: {} + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mrmime@2.0.1: {} ms@2.1.3: {} - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - nanoid@3.3.11: {} nanostores@1.3.0: {} neotraverse@0.6.18: {} - netmask@2.1.1: {} - nlcst-to-string@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -9367,8 +8058,6 @@ snapshots: dependencies: boolbase: 1.0.0 - object-assign@4.1.1: {} - ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -9377,10 +8066,6 @@ snapshots: ohash@2.0.11: {} - once@1.4.0: - dependencies: - wrappy: 1.0.2 - oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -9399,6 +8084,12 @@ snapshots: ws: 8.18.0 zod: 4.3.6 + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + p-limit@6.2.0: dependencies: yocto-queue: 1.2.2 @@ -9415,24 +8106,6 @@ snapshots: p-timeout@6.1.4: {} - pac-proxy-agent@7.2.0: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.4 - debug: 4.4.3 - get-uri: 6.0.5 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.1.1 - package-manager-detector@1.6.0: {} parse-entities@4.0.2: @@ -9454,22 +8127,12 @@ snapshots: unist-util-visit-children: 3.0.0 vfile: 6.0.3 - parse5-htmlparser2-tree-adapter@6.0.1: - dependencies: - parse5: 6.0.1 - - parse5@5.1.1: {} - - parse5@6.0.1: {} - parse5@7.3.0: dependencies: entities: 6.0.1 partial-json@0.1.7: {} - path-expression-matcher@1.5.0: {} - path-key@3.1.1: {} path-scurry@2.0.2: @@ -9483,8 +8146,6 @@ snapshots: pathval@2.0.1: {} - pend@1.2.0: {} - piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -9493,6 +8154,8 @@ snapshots: picomatch@4.0.4: {} + platform@1.3.6: {} + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -9536,26 +8199,6 @@ snapshots: '@types/node': 24.12.2 long: 5.3.2 - proxy-agent@6.5.0: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - proxy-from-env@1.1.0: {} - - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@radix-ui/primitive': 1.1.3 @@ -9762,8 +8405,6 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - require-directory@2.1.1: {} - retext-latin@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -9900,28 +8541,10 @@ snapshots: sisteransi@1.0.5: {} - smart-buffer@4.2.0: {} - smol-toml@1.6.1: {} - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color - - socks@2.8.7: - dependencies: - ip-address: 10.1.0 - smart-buffer: 4.2.0 - source-map-js@1.2.1: {} - source-map@0.6.1: - optional: true - space-separated-tokens@2.0.2: {} stackback@0.0.2: {} @@ -9957,12 +8580,6 @@ snapshots: dependencies: js-tokens: 9.0.1 - strnum@2.2.3: {} - - strtok3@10.3.5: - dependencies: - '@tokenizer/token': 0.3.0 - style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -9973,10 +8590,6 @@ snapshots: supports-color@10.2.2: {} - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - svgo@4.0.1: dependencies: commander: 11.1.0 @@ -9993,13 +8606,13 @@ snapshots: tapable@2.3.3: {} - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: + tar@7.5.22: dependencies: - any-promise: 1.3.0 + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 tiny-inflate@1.0.3: {} @@ -10024,12 +8637,6 @@ snapshots: dependencies: is-number: 7.0.0 - token-types@6.1.2: - dependencies: - '@borewit/text-codec': 0.2.2 - '@tokenizer/token': 0.3.0 - ieee754: 1.2.1 - trim-lines@3.0.1: {} trough@2.2.0: {} @@ -10046,14 +8653,10 @@ snapshots: typebox@1.1.38: {} - typebox@1.2.16: {} - typescript@5.9.3: {} ufo@1.6.4: {} - uint8array-extras@1.5.0: {} - ultrahtml@1.6.0: {} uncrypto@0.1.3: {} @@ -10062,6 +8665,11 @@ snapshots: undici@7.24.8: {} + undici@7.29.1: {} + + undici@8.10.2: + optional: true + undici@8.5.0: {} unenv@2.0.0-rc.24: @@ -10172,8 +8780,6 @@ snapshots: util-deprecate@1.0.2: {} - uuid@14.0.0: {} - vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -10189,13 +8795,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3): + vite-node@3.2.4(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -10210,7 +8816,7 @@ snapshots: - tsx - yaml - vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3): + vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -10223,17 +8829,17 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 - yaml: 2.8.3 + yaml: 2.9.0 - vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)): + vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)): optionalDependencies: - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) - vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -10251,8 +8857,8 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 @@ -10271,6 +8877,10 @@ snapshots: - tsx - yaml + vscode-languageserver-textdocument@1.0.14: {} + + vscode-languageserver-types@3.18.3: {} + web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: {} @@ -10317,53 +8927,26 @@ snapshots: - bufferutil - utf-8-validate - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.2.0 - wrappy@1.0.2: {} - ws@8.18.0: {} ws@8.20.1: {} xxhash-wasm@1.1.0: {} - y18n@5.0.8: {} - yallist@3.1.1: {} - yaml@2.8.3: {} + yallist@5.0.0: {} yaml@2.9.0: {} - yargs-parser@20.2.9: {} - yargs-parser@21.1.1: {} - yargs@16.2.0: - dependencies: - cliui: 7.0.4 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 20.2.9 - - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yocto-queue@1.2.2: {} yocto-spinner@0.2.3: @@ -10389,10 +8972,6 @@ snapshots: dependencies: zod: 3.25.76 - zod-to-json-schema@3.25.2(zod@4.3.6): - dependencies: - zod: 4.3.6 - zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): dependencies: typescript: 5.9.3 From c8fea767ef5d8b43f00f168ec58706d16f2d0b2c Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 23:00:12 +0530 Subject: [PATCH 13/32] chore(worker): minimize lockfile churn for e2b dependency --- pnpm-lock.yaml | 1676 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 1619 insertions(+), 57 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98af2cf..c813c01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,10 +85,10 @@ importers: dependencies: '@astrojs/react': specifier: ^4 - version: 4.4.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yaml@2.9.0) + version: 4.4.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yaml@2.8.3) astro: specifier: ^5 - version: 5.18.2(@types/node@24.12.2)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@5.9.3)(yaml@2.9.0) + version: 5.18.2(@types/node@24.12.2)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@5.9.3)(yaml@2.8.3) react: specifier: ^19 version: 19.2.5 @@ -98,7 +98,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4 - version: 4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + version: 4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) '@types/react': specifier: ^19 version: 19.2.14 @@ -110,7 +110,7 @@ importers: version: 4.2.4 vitest: specifier: ^3 - version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) packages/web: dependencies: @@ -165,13 +165,13 @@ importers: version: 0.5.19(tailwindcss@4.2.4) '@tailwindcss/vite': specifier: ^4 - version: 4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + version: 4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) '@tanstack/react-router-devtools': specifier: ^1 version: 1.166.13(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.16)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/router-plugin': specifier: ^1 - version: 1.167.27(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + version: 1.167.27(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) '@types/react': specifier: ^19 version: 19.2.14 @@ -180,7 +180,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4 - version: 4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + version: 4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) tailwindcss: specifier: ^4 version: 4.2.4 @@ -189,10 +189,10 @@ importers: version: 5.9.3 vite: specifier: ^6 - version: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + version: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) vitest: specifier: ^3 - version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) packages/worker: dependencies: @@ -204,7 +204,7 @@ importers: version: link:../shared better-auth: specifier: ^1.6.14 - version: 1.6.14(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + version: 1.6.14(@cloudflare/workers-types@4.20260702.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) e2b: specifier: ^2.51.0 version: 2.51.0 @@ -259,6 +259,10 @@ packages: resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -272,66 +276,154 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/client-bedrock-runtime@3.1033.0': + resolution: {integrity: sha512-CDI4njdtLEd3voxApQMI32IJN/HhpM3FtAh0quJ+aIWNmyDbW3cp2SwQ56Pnsrq1OV40Apw/O4yD822K4aK9HA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock-runtime@3.1048.0': resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.2': + resolution: {integrity: sha512-oav5AOAz+1XkwUfp6SrEm42UPDpUP5D4jNYXkDwFR1VfWqYX62+jpytdfzURmJ9McSoJIQwi0OJlC4oCi6t0VQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.26': resolution: {integrity: sha512-wRj7Pthvjk3anees97pUWlxlTa0DUjeGrEQU5fKDZVdWZV0ekaprbof0df2uaE9g8u67t035v2j+ne2AW2UMkA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.28': + resolution: {integrity: sha512-87GdRJ2OR0qR4VkMjXN/SZi66DZsunW2qQCbtw9rKw3Y7JurFi6tQWYKOSLY/gOADrU6OxGqFmdw3hKzZqDZOQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.52': resolution: {integrity: sha512-sxuaHZGHqOgKB8OdL3doXa1NJjqmO60FPfyTnYVKGjX9taRsIEGS9pd+2yALmo06hijZ8L94uSK0kfXZsRmVyA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.30': + resolution: {integrity: sha512-6quozmW2PKwBJTUQLb+lk1q8w5Pm45qaqhx4Tld9EIqYYQOVGj+MT0a8NRVS7QgWJj7rzGlB7rQu3KYBFHemJw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.54': resolution: {integrity: sha512-e6yz52nq3SpR1oPLcvfsDM7H7k2gIYk/NSn/rwsFqzGXEwr3g0mRMlPbLaKCPCGNZJMU/gZg6/64B3eSam+gBw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.32': + resolution: {integrity: sha512-Nkr+UKtczZlocUjc6g96WzQadZSIZO/HVXPki4qbfaVOZYSbfLQKWKfADtJ0kGYsCvSYOZrO66tSc9dkboUt/w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.59': resolution: {integrity: sha512-9Um/UpruN76AdpiLnvwChVkJJwJ9Vx9ykk/2AeLxxSCM/YYRD8Kkq2towUk9fZQLV7dd9ATlsi87U7hKs0z/iQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.32': + resolution: {integrity: sha512-UxgwT1HmZz1QPXuBy5ZUPJNFXOSlhwdQL61eGhWRthF0xRrT02BCOVJ1p5Ejg5AXfnESTWoKPJ7v/sCkNUtB9g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.58': resolution: {integrity: sha512-H3q96qF8/DJsPsXMVtMRqSWOc85K5O4zos32untdw+vE5vw0f3a6qJo1YqbND4BsEIKd4iZmzzVUq9kV4LjbHg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.33': + resolution: {integrity: sha512-6pGQnEdSeRvBViTQh/FwaRKB38a3Th+W2mVxuvqAd2Z1Ayo3e6eJ5QqJoZwEMwR6xoxkl3wz3qAfiB1xRhMC+w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.61': resolution: {integrity: sha512-2U2KHMRCt1dlZoLU3KZR5g5EL4b0h2HHw96SkaUBK7qvEXPZj5rGRO/3ZTeJmh37dIYQuCnA2273rZOQvmsiHw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.28': + resolution: {integrity: sha512-CRAlD8u6oNBhjnX/3ekVGocarD+lFmEn/qeDzytgIdmwrmwMJGFPqS9lGwEfhOTihZKrQ0xSp3z6paX+iXJJhA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.52': resolution: {integrity: sha512-Aff9Ebs42lz+Ep1wkS+Nlwh5S0eahakpyskPsuKGjiBJ6ExOjNtxbfKJTKovQtQNgJ7oG1BH6esJwGrbs7qgSA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.32': + resolution: {integrity: sha512-whhmQghRYOt9mJxFyVMhX7eB8n0oA25OCvqoR7dzFAZjmioCkf7WVB22Bc6llM5cFpBXFX7s4Jv+xVq32VPGWg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.58': resolution: {integrity: sha512-syloC58mXOacUqM2toPNfwd7X3jT+tWj0F/cN7qdW1FQyI0q41J0tPf6DIZ56BF0x82iS9j3ALP45MoBz79YuQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.32': + resolution: {integrity: sha512-Z0Y0LDaqyQDznlmr9gv6n4+eWKKWNgmi9j5L6RENr6wyOCguhO8FRPmqDbVLSw0DPdMqICKnA3PurJiS8bD6Cw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.58': resolution: {integrity: sha512-pTBImKzcGK+pcMKjL0fAJbnYzzYd1c0UDc7BSIOGNQhF9Nuk66vWlIXfYTYyzNSs+w8Q/vfbbNDDU8zdrouwLg==} engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.14': + resolution: {integrity: sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.25': resolution: {integrity: sha512-df7HN1ozwMrB9+59re9PM7tSLxLAcheMWc5u/KyfCPCAWtN/vP7y7RTUZOy48uT1K9MESisVeOPPzF3O1AW01A==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.10': + resolution: {integrity: sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.21': resolution: {integrity: sha512-HvLgDnxBLaHi9E5K++6Vuk+1+qqn7Pmn8zrlzd+NXH3jBzwujnuzZtAR9WHPkbUGPO92FkoQWj/M1IsdxTlBmQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-host-header@3.972.10': + resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.10': + resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.11': + resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.31': + resolution: {integrity: sha512-5hS08Fp0Rm+59uGCmkWhZmveXiA7OUV7Wa+IARejdzf9JTZ1qAVeIOE9JoBpsLPvUgEjmsGNHBuFbtGmYyqiqQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.32': + resolution: {integrity: sha512-HQ0x9DDKqLZOGhDiL2eicYXXkYT5dogE4mw0lAfHCpJ6t7MM0PNIsJl2TZzWKU9SpBzOMXHRa7K6ZLKUJu1y0w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.16': + resolution: {integrity: sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg==} + engines: {node: '>= 14.0.0'} + '@aws-sdk/middleware-websocket@3.972.34': resolution: {integrity: sha512-8dxKLu5bC74SLwwoYV8RIiCD48jMbMt1Ccl3m+xtQJKet6QsZ4xzJlK6UDg7QNEzm/ZCUknJfGsBHmhkgOfuIQ==} engines: {node: '>= 14.0.0'} + '@aws-sdk/nested-clients@3.997.0': + resolution: {integrity: sha512-4bI5GHjUiY5R8N6PtchpG6tW2Dl8I2IcZNg3JwqwxHRXjfvQlPoo4VMknG4qkd5W0t3Y20rQ6C7pSR561YG5JQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.26': resolution: {integrity: sha512-Lwe3F6K7bs+jEubp1LbrvzeMBYb5fMazJ1IxV9TtKWPF8CSh67Fmwyq9fLz3NL/k55Dfpuph5Dimw76JFgr+SA==} engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.12': + resolution: {integrity: sha512-QQI43Mxd53nBij0pm8HXC+t4IOC6gnhhZfzxE0OATQyO6QfPV4e+aTIRRuAJKA6Nig/cR8eLwPryqYTX9ZrjAQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.19': + resolution: {integrity: sha512-7Sy8+GhfwUi06NQNLplxuJuXMKJURDsNQfK8yTW6E9wN2J1B+8S5dWZG7vg3InvPPhaXqkcYTr8pzeE+dLjMbQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.38': resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1033.0': + resolution: {integrity: sha512-/TsXhqjyRAFb0xVgmbFAha3cJfZdWjnyn6ohJ3AB4E3peLgxNcmKfYr45hruHymyJAydiHoXC3N1a8qgl41cog==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1048.0': resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} engines: {node: '>=20.0.0'} @@ -348,10 +440,38 @@ packages: resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.7': + resolution: {integrity: sha512-ty4LQxN1QC+YhUP28NfEgZDEGXkyqOQy+BDriBozqHsrYO4JMgiPhfizqOGF7P+euBTZ5Ez6SKlLAMCLo8tzmw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-format-url@3.972.10': + resolution: {integrity: sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.5': resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-user-agent-browser@3.972.10': + resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} + + '@aws-sdk/util-user-agent-node@3.973.18': + resolution: {integrity: sha512-Nh4YvAL0Mzv5jBvzXLFL0tLf7WPrRMnYZQ5jlFuyS0xiVJQsObMUKAkbYjmt/e04wpQqUaa+Is7k+mBr89A9yA==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.18': + resolution: {integrity: sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.33': resolution: {integrity: sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==} engines: {node: '>=20.0.0'} @@ -555,6 +675,9 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@bufbuild/protobuf@2.15.0': resolution: {integrity: sha512-DAheWUkVr/SJTWCc+lg9dhY0eN4SaWlf4+bG1KzHeXbnqt0AfB/NX0Z+VunGlM1ki1B4zVvye27MpKh/svySUA==} @@ -988,6 +1111,15 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@google/genai@1.50.1': + resolution: {integrity: sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -1241,6 +1373,9 @@ packages: resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} engines: {node: '>= 10'} + '@mistralai/mistralai@2.2.1': + resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + '@mistralai/mistralai@2.2.6': resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} peerDependencies: @@ -2174,26 +2309,105 @@ packages: '@silvia-odwyer/photon-node@0.3.4': resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} + '@smithy/config-resolver@4.4.17': + resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.16': + resolution: {integrity: sha512-JStomOrINQA1VqNEopLsgcdgwd42au7mykKqVr30XFw89wLt9sDxJDi4djVPRwQmmzyTGy/uOvTc2ultMpFi1w==} + engines: {node: '>=18.0.0'} + '@smithy/core@3.29.0': resolution: {integrity: sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.14': + resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.5': resolution: {integrity: sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.14': + resolution: {integrity: sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.14': + resolution: {integrity: sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.14': + resolution: {integrity: sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.14': + resolution: {integrity: sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.14': + resolution: {integrity: sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.17': + resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.2': resolution: {integrity: sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ==} engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.14': + resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.14': + resolution: {integrity: sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==} + engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.14': + resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.31': + resolution: {integrity: sha512-KJPdCIN2kOE2aGmqZd7eUTr4WQwOGgtLWgUkswGJggs7rBcQYQjcZMEDa3C0DwbOiXS9L8/wDoQHkfxBYLfiLw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.5.4': + resolution: {integrity: sha512-/z7nIFK+ZRW3Ie/l3NEVGdy34LvmEOzBrtBAvgWZ/4PrKX0xP3kWm8pkfcwUk523SqxZhdbQP9JSXgjF77Uhpw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.19': + resolution: {integrity: sha512-Q6y+W9h3iYVMCKWDoVge+OC1LKFqbEKaq8SIWG2X2bWJRpd/6dDLyICcNLT6PbjH3Rr6bmg/SeDB25XFOFfeEw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.14': + resolution: {integrity: sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.14': + resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.6.0': + resolution: {integrity: sha512-P734cAoTFtuGfWa/R3jgBnGlURt2w9bYEBwQNMKf58sRM9RShirB2mKwLsVP+jlG/wxpCu8abv8NxdUts8tdLA==} + engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.7.3': resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} engines: {node: '>=18.0.0'} @@ -2202,10 +2416,42 @@ packages: resolution: {integrity: sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==} engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.14': + resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.14': + resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.14': + resolution: {integrity: sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.14': + resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.3.0': + resolution: {integrity: sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.9': + resolution: {integrity: sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.14': + resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.6.1': resolution: {integrity: sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ==} engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.12.12': + resolution: {integrity: sha512-daO7SJn4eM6ArbmrEs+/BTbH7af8AEbSL3OMQdcRvvn8tuUcR5rU2n6DgxIV53aXMS42uwK8NgKKCh5XgqYOPQ==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.14.1': resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} engines: {node: '>=18.0.0'} @@ -2214,14 +2460,78 @@ packages: resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.14': + resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.48': + resolution: {integrity: sha512-hxVRVPYaRDWa6YQdse1aWX1qrksmLsvNyGBKdc32q4jFzSjxYVNWfstknAfR228TnzS4tzgswXRuYIbhXBuXFQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.53': + resolution: {integrity: sha512-ybgCk+9JdBq8pYC8Y6U5fjyS8e4sboyAShetxPNL0rRBtaVl56GSFAxsolVBIea1tXR4LPIzL8i6xqmcf0+DCQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.4.2': + resolution: {integrity: sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.14': + resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.3.3': + resolution: {integrity: sha512-idjUvd4M9Jj6rXkhqw4H4reHoweuK4ZxYWyOrEp4N2rOF5VtaOlQGLDQJva/8WanNXk9ScQtsAb7o5UHGvFm4A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.24': + resolution: {integrity: sha512-na5vv2mBSDzXewLEEoWGI7LQQkfpmFEomBsmOpzLFjqGctm0iMwXY5lAwesY9pIaErkccW0qzEOUcYP+WKneXg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} @@ -2409,6 +2719,16 @@ packages: engines: {node: '>=20.19'} hasBin: true + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2442,6 +2762,9 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mime-types@2.1.4': + resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2471,6 +2794,9 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -2530,6 +2856,10 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -2538,6 +2868,9 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2560,6 +2893,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + astro@5.18.2: resolution: {integrity: sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} @@ -2593,6 +2930,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-ftp@5.3.0: + resolution: {integrity: sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==} + engines: {node: '>=10.0.0'} + better-auth@1.6.14: resolution: {integrity: sha512-c0/DvTQGDpgfj1knekCpQrg6PSWGDtfAtP7Ou6FkAhoE3RNnnIxLB5qKj6tRg53a1xsq93G6T68cNxrUZ7ZVmw==} peerDependencies: @@ -2696,6 +3037,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -2720,6 +3064,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -2763,10 +3111,25 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2831,6 +3194,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2850,6 +3217,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2917,6 +3288,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.21.0: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} @@ -2953,6 +3327,20 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} @@ -2962,6 +3350,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -2972,6 +3364,21 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-xml-builder@1.1.5: + resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + + fast-xml-parser@5.5.8: + resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} + hasBin: true + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2985,6 +3392,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3021,6 +3432,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.5.0: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} @@ -3033,6 +3448,14 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -3063,6 +3486,10 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + hast-util-from-html@2.0.3: resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} @@ -3103,6 +3530,10 @@ packages: resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==} engines: {node: '>=16.9.0'} + hosted-git-info@9.0.2: + resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} + engines: {node: ^20.17.0 || >=22.9.0} + hosted-git-info@9.0.3: resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3127,6 +3558,9 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} @@ -3137,6 +3571,10 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -3249,6 +3687,9 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + koffi@2.16.1: + resolution: {integrity: sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==} + kysely@0.28.17: resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} engines: {node: '>=20.0.0'} @@ -3343,6 +3784,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lucide-react@1.11.0: resolution: {integrity: sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==} peerDependencies: @@ -3357,6 +3802,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} @@ -3500,6 +3950,14 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + miniflare@4.20260611.0: resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} engines: {node: '>=22.0.0'} @@ -3524,6 +3982,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3537,6 +3998,10 @@ packages: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} @@ -3565,12 +4030,19 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -3611,6 +4083,14 @@ packages: resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} engines: {node: '>=14.16'} + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -3620,12 +4100,25 @@ packages: parse-latin@7.0.0: resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3644,6 +4137,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -3692,6 +4188,16 @@ packages: resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} engines: {node: '>=12.0.0'} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} peerDependencies: @@ -3808,6 +4314,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + retext-latin@4.0.0: resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} @@ -3897,14 +4407,30 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -3936,6 +4462,13 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -3946,6 +4479,10 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + svgo@4.0.1: resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} @@ -3965,6 +4502,13 @@ packages: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -3998,6 +4542,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4028,6 +4576,9 @@ packages: typebox@1.1.38: resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typebox@1.2.16: + resolution: {integrity: sha512-p85YWtR1RznP2kt/sftmj1leGiObfHVJwkK2hkDdEKhbtQONeUeBtbYdK0x9tLPh+5AaEPD62APv189Cy+/C2g==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -4036,6 +4587,10 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} @@ -4197,6 +4752,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -4336,10 +4895,17 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -4367,6 +4933,10 @@ packages: xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4374,15 +4944,31 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -4487,15 +5073,15 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@4.4.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yaml@2.9.0)': + '@astrojs/react@4.4.2(@types/node@24.12.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yaml@2.8.3)': dependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': 4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + '@vitejs/plugin-react': 4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) ultrahtml: 1.6.0 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -4522,6 +5108,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -4548,6 +5140,58 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 + '@aws-sdk/client-bedrock-runtime@3.1033.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.2 + '@aws-sdk/credential-provider-node': 3.972.33 + '@aws-sdk/eventstream-handler-node': 3.972.14 + '@aws-sdk/middleware-eventstream': 3.972.10 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-user-agent': 3.972.32 + '@aws-sdk/middleware-websocket': 3.972.16 + '@aws-sdk/region-config-resolver': 3.972.12 + '@aws-sdk/token-providers': 3.1033.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.7 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.18 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.16 + '@smithy/eventstream-serde-browser': 4.2.14 + '@smithy/eventstream-serde-config-resolver': 4.3.14 + '@smithy/eventstream-serde-node': 4.2.14 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.31 + '@smithy/middleware-retry': 4.5.4 + '@smithy/middleware-serde': 4.2.19 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.0 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.12 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.48 + '@smithy/util-defaults-mode-node': 4.2.53 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.3 + '@smithy/util-stream': 4.5.24 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-bedrock-runtime@3.1048.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4565,6 +5209,22 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.974.2': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/xml-builder': 3.972.18 + '@smithy/core': 3.23.16 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.12 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + '@aws-sdk/core@3.974.26': dependencies: '@aws-sdk/types': 3.973.15 @@ -4576,6 +5236,14 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.28': + dependencies: + '@aws-sdk/core': 3.974.2 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.52': dependencies: '@aws-sdk/core': 3.974.26 @@ -4584,6 +5252,19 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.30': + dependencies: + '@aws-sdk/core': 3.974.2 + '@aws-sdk/types': 3.973.8 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.0 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.12 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.24 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.54': dependencies: '@aws-sdk/core': 3.974.26 @@ -4594,6 +5275,25 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.32': + dependencies: + '@aws-sdk/core': 3.974.2 + '@aws-sdk/credential-provider-env': 3.972.28 + '@aws-sdk/credential-provider-http': 3.972.30 + '@aws-sdk/credential-provider-login': 3.972.32 + '@aws-sdk/credential-provider-process': 3.972.28 + '@aws-sdk/credential-provider-sso': 3.972.32 + '@aws-sdk/credential-provider-web-identity': 3.972.32 + '@aws-sdk/nested-clients': 3.997.0 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-ini@3.972.59': dependencies: '@aws-sdk/core': 3.974.26 @@ -4610,6 +5310,19 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.32': + dependencies: + '@aws-sdk/core': 3.974.2 + '@aws-sdk/nested-clients': 3.997.0 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-login@3.972.58': dependencies: '@aws-sdk/core': 3.974.26 @@ -4619,6 +5332,23 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.33': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.28 + '@aws-sdk/credential-provider-http': 3.972.30 + '@aws-sdk/credential-provider-ini': 3.972.32 + '@aws-sdk/credential-provider-process': 3.972.28 + '@aws-sdk/credential-provider-sso': 3.972.32 + '@aws-sdk/credential-provider-web-identity': 3.972.32 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-node@3.972.61': dependencies: '@aws-sdk/credential-provider-env': 3.972.52 @@ -4633,6 +5363,15 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.28': + dependencies: + '@aws-sdk/core': 3.974.2 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.52': dependencies: '@aws-sdk/core': 3.974.26 @@ -4641,6 +5380,19 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.32': + dependencies: + '@aws-sdk/core': 3.974.2 + '@aws-sdk/nested-clients': 3.997.0 + '@aws-sdk/token-providers': 3.1033.0 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-sso@3.972.58': dependencies: '@aws-sdk/core': 3.974.26 @@ -4651,6 +5403,18 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.32': + dependencies: + '@aws-sdk/core': 3.974.2 + '@aws-sdk/nested-clients': 3.997.0 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.58': dependencies: '@aws-sdk/core': 3.974.26 @@ -4660,6 +5424,13 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.14': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/eventstream-codec': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.25': dependencies: '@aws-sdk/types': 3.973.15 @@ -4667,6 +5438,13 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.21': dependencies: '@aws-sdk/types': 3.973.15 @@ -4674,6 +5452,70 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.31': + dependencies: + '@aws-sdk/core': 3.974.2 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.16 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.12 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.24 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.32': + dependencies: + '@aws-sdk/core': 3.974.2 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.7 + '@smithy/core': 3.23.16 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-retry': 4.3.3 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.16': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-format-url': 3.972.10 + '@smithy/eventstream-codec': 4.2.14 + '@smithy/eventstream-serde-browser': 4.2.14 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.972.34': dependencies: '@aws-sdk/core': 3.974.26 @@ -4684,6 +5526,50 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.2 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-user-agent': 3.972.32 + '@aws-sdk/region-config-resolver': 3.972.12 + '@aws-sdk/signature-v4-multi-region': 3.996.19 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.7 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.18 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.16 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.31 + '@smithy/middleware-retry': 4.5.4 + '@smithy/middleware-serde': 4.2.19 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.0 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.12 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.48 + '@smithy/util-defaults-mode-node': 4.2.53 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.3 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/nested-clients@3.997.26': dependencies: '@aws-sdk/core': 3.974.26 @@ -4695,6 +5581,23 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.972.12': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/config-resolver': 4.4.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.19': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.31 + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.38': dependencies: '@aws-sdk/types': 3.973.15 @@ -4702,6 +5605,18 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/token-providers@3.1033.0': + dependencies: + '@aws-sdk/core': 3.974.2 + '@aws-sdk/nested-clients': 3.997.0 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/token-providers@3.1048.0': dependencies: '@aws-sdk/core': 3.974.26 @@ -4730,10 +5645,51 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/util-arn-parser@3.972.3': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.7': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-endpoints': 3.4.2 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.5': dependencies: tslib: 2.8.1 + '@aws-sdk/util-user-agent-browser@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.18': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.32 + '@aws-sdk/types': 3.973.8 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.18': + dependencies: + '@smithy/types': 4.14.1 + fast-xml-parser: 5.5.8 + tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.33': dependencies: '@smithy/types': 4.15.1 @@ -4878,7 +5834,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': + '@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.1.21 @@ -4891,38 +5847,37 @@ snapshots: zod: 4.3.6 optionalDependencies: '@cloudflare/workers-types': 4.20260702.1 - '@opentelemetry/api': 1.9.0 - '@better-auth/drizzle-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/drizzle-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - '@better-auth/kysely-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.28.17)': + '@better-auth/kysely-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.28.17)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 optionalDependencies: kysely: 0.28.17 - '@better-auth/memory-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/memory-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - '@better-auth/mongo-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/mongo-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - '@better-auth/prisma-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/prisma-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - '@better-auth/telemetry@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)': + '@better-auth/telemetry@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)': dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.1.21 @@ -4932,6 +5887,8 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@borewit/text-codec@0.2.2': {} + '@bufbuild/protobuf@2.15.0': {} '@capsizecss/unpack@4.0.0': @@ -5254,6 +6211,17 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@google/genai@1.50.1': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.5 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@google/genai@1.52.0': dependencies: google-auth-library: 10.6.2 @@ -5433,6 +6401,15 @@ snapshots: '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 optional: true + '@mistralai/mistralai@2.2.1': + dependencies: + ws: 8.18.0 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/semantic-conventions': 1.41.1 @@ -6359,29 +7336,171 @@ snapshots: '@silvia-odwyer/photon-node@0.3.4': {} + '@sinclair/typebox@0.34.49': {} + '@sindresorhus/is@7.2.0': {} + '@smithy/config-resolver@4.4.17': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/core@3.23.16': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.24 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + '@smithy/core@3.29.0': dependencies: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.14': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.5': dependencies: '@smithy/core': 3.29.0 '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.14': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.14': + dependencies: + '@smithy/eventstream-codec': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.17': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.6.2': dependencies: '@smithy/core': 3.29.0 '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/hash-node@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.14': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.31': + dependencies: + '@smithy/core': 3.23.16 + '@smithy/middleware-serde': 4.2.19 + '@smithy/node-config-provider': 4.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.5.4': + dependencies: + '@smithy/core': 3.23.16 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/service-error-classification': 4.3.0 + '@smithy/smithy-client': 4.12.12 + '@smithy/types': 4.14.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.3 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.19': + dependencies: + '@smithy/core': 3.23.16 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.14': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.6.0': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@smithy/node-http-handler@4.7.3': dependencies: '@smithy/core': 3.29.0 @@ -6394,12 +7513,63 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/property-provider@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.3.0': + dependencies: + '@smithy/types': 4.14.1 + + '@smithy/shared-ini-file-loader@4.4.9': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.14': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + '@smithy/signature-v4@5.6.1': dependencies: '@smithy/core': 3.29.0 '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/smithy-client@4.12.12': + dependencies: + '@smithy/core': 3.23.16 + '@smithy/middleware-endpoint': 4.4.31 + '@smithy/middleware-stack': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.24 + tslib: 2.8.1 + '@smithy/types@4.14.1': dependencies: tslib: 2.8.1 @@ -6408,16 +7578,107 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/url-parser@4.2.14': + dependencies: + '@smithy/querystring-parser': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 + '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.48': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.12 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.53': + dependencies: + '@smithy/config-resolver': 4.4.17 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.12 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.4.2': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-retry@4.3.3': + dependencies: + '@smithy/service-error-classification': 4.3.0 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.24': + dependencies: + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.0 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 + '@smithy/util-utf8@2.3.0': dependencies: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 + + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 + '@speed-highlight/core@1.2.15': {} '@standard-schema/spec@1.1.0': {} @@ -6488,12 +7749,12 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.4 - '@tailwindcss/vite@4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': + '@tailwindcss/vite@4.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) '@tanstack/history@1.161.6': {} @@ -6552,7 +7813,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.27(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': + '@tanstack/router-plugin@1.167.27(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -6569,7 +7830,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -6591,6 +7852,17 @@ snapshots: '@tanstack/virtual-file-routes@1.161.7': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -6637,6 +7909,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/mime-types@2.1.4': {} + '@types/ms@2.1.0': {} '@types/nlcst@2.0.3': @@ -6665,9 +7939,14 @@ snapshots: dependencies: '@types/node': 24.12.2 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 24.12.2 + optional: true + '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -6675,7 +7954,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -6687,13 +7966,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': + '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -6733,10 +8012,16 @@ snapshots: ansi-regex@6.2.2: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@6.2.3: {} ansis@4.2.0: {} + any-promise@1.3.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -6754,7 +8039,11 @@ snapshots: assertion-error@2.0.1: {} - astro@5.18.2(@types/node@24.12.2)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@5.9.3)(yaml@2.9.0): + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + + astro@5.18.2(@types/node@24.12.2)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.1 '@astrojs/internal-helpers': 0.7.6 @@ -6811,8 +8100,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5(aws4fetch@1.0.20) vfile: 6.0.3 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) - vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -6879,15 +8168,17 @@ snapshots: baseline-browser-mapping@2.10.22: {} - better-auth@1.6.14(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)): + basic-ftp@5.3.0: {} + + better-auth@1.6.14(@cloudflare/workers-types@4.20260702.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)): dependencies: - '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/kysely-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.28.17) - '@better-auth/memory-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/mongo-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/prisma-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/telemetry': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21) + '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) + '@better-auth/kysely-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) + '@better-auth/mongo-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) + '@better-auth/prisma-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1) + '@better-auth/telemetry': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260702.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21) '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.2.0 @@ -6901,7 +8192,7 @@ snapshots: optionalDependencies: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -6952,6 +8243,8 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} cac@6.7.14: {} @@ -6972,6 +8265,11 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -7010,8 +8308,29 @@ snapshots: cli-boxes@3.0.0: {} + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.0 + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} commander@11.1.0: {} @@ -7068,6 +8387,8 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -7080,6 +8401,12 @@ snapshots: defu@6.1.7: {} + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + dequal@2.0.3: {} destr@2.0.5: {} @@ -7153,6 +8480,10 @@ snapshots: emoji-regex@8.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.21.0: dependencies: graceful-fs: 4.2.11 @@ -7228,6 +8559,18 @@ snapshots: escape-string-regexp@5.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} estree-walker@2.0.2: {} @@ -7236,12 +8579,38 @@ snapshots: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + eventemitter3@5.0.4: {} expect-type@1.3.0: {} extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-xml-builder@1.1.5: + dependencies: + path-expression-matcher: 1.5.0 + + fast-xml-parser@5.5.8: + dependencies: + fast-xml-builder: 1.1.5 + path-expression-matcher: 1.5.0 + strnum: 2.2.3 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -7251,6 +8620,15 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -7290,12 +8668,26 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-east-asian-width@1.5.0: {} get-east-asian-width@1.6.0: {} get-nonce@1.0.1: {} + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + get-uri@6.0.5: + dependencies: + basic-ftp: 5.3.0 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -7339,6 +8731,8 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + has-flag@4.0.0: {} + hast-util-from-html@2.0.3: dependencies: '@types/hast': 3.0.4 @@ -7450,6 +8844,10 @@ snapshots: hono@4.13.1: {} + hosted-git-info@9.0.2: + dependencies: + lru-cache: 11.3.5 + hosted-git-info@9.0.3: dependencies: lru-cache: 11.3.5 @@ -7476,12 +8874,16 @@ snapshots: transitivePeerDependencies: - supports-color + ieee754@1.2.1: {} + ignore@7.0.5: {} import-meta-resolve@4.2.0: {} inline-style-parser@0.2.7: {} + ip-address@10.1.0: {} + iron-webcrypto@1.2.1: {} is-alphabetical@2.0.1: {} @@ -7567,6 +8969,9 @@ snapshots: kleur@4.1.5: {} + koffi@2.16.1: + optional: true + kysely@0.28.17: {} lightningcss-android-arm64@1.32.0: @@ -7630,6 +9035,8 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: {} + lucide-react@1.11.0(react@19.2.5): dependencies: react: 19.2.5 @@ -7646,6 +9053,8 @@ snapshots: markdown-table@3.0.4: {} + marked@15.0.12: {} + marked@18.0.5: {} mdast-util-definitions@6.0.0: @@ -8002,6 +9411,12 @@ snapshots: transitivePeerDependencies: - supports-color + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + miniflare@4.20260611.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -8028,12 +9443,20 @@ snapshots: ms@2.1.3: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.11: {} nanostores@1.3.0: {} neotraverse@0.6.18: {} + netmask@2.1.1: {} + nlcst-to-string@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -8058,6 +9481,8 @@ snapshots: dependencies: boolbase: 1.0.0 + object-assign@4.1.1: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -8066,6 +9491,10 @@ snapshots: ohash@2.0.11: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -8106,6 +9535,24 @@ snapshots: p-timeout@6.1.4: {} + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + package-manager-detector@1.6.0: {} parse-entities@4.0.2: @@ -8127,12 +9574,22 @@ snapshots: unist-util-visit-children: 3.0.0 vfile: 6.0.3 + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + parse5@7.3.0: dependencies: entities: 6.0.1 partial-json@0.1.7: {} + path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} path-scurry@2.0.2: @@ -8146,6 +9603,8 @@ snapshots: pathval@2.0.1: {} + pend@1.2.0: {} + piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -8199,6 +9658,26 @@ snapshots: '@types/node': 24.12.2 long: 5.3.2 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@radix-ui/primitive': 1.1.3 @@ -8405,6 +9884,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-directory@2.1.1: {} + retext-latin@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -8541,10 +10022,28 @@ snapshots: sisteransi@1.0.5: {} + smart-buffer@4.2.0: {} + smol-toml@1.6.1: {} + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + source-map-js@1.2.1: {} + source-map@0.6.1: + optional: true + space-separated-tokens@2.0.2: {} stackback@0.0.2: {} @@ -8580,6 +10079,12 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.2.3: {} + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -8590,6 +10095,10 @@ snapshots: supports-color@10.2.2: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + svgo@4.0.1: dependencies: commander: 11.1.0 @@ -8614,6 +10123,14 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + tiny-inflate@1.0.3: {} tinybench@2.9.0: {} @@ -8637,6 +10154,12 @@ snapshots: dependencies: is-number: 7.0.0 + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -8653,10 +10176,14 @@ snapshots: typebox@1.1.38: {} + typebox@1.2.16: {} + typescript@5.9.3: {} ufo@1.6.4: {} + uint8array-extras@1.5.0: {} + ultrahtml@1.6.0: {} uncrypto@0.1.3: {} @@ -8780,6 +10307,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@14.0.0: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -8795,13 +10324,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0): + vite-node@3.2.4(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -8816,7 +10345,7 @@ snapshots: - tsx - yaml - vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0): + vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -8829,17 +10358,17 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 - yaml: 2.9.0 + yaml: 2.8.3 - vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)): + vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)): optionalDependencies: - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) - vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -8857,8 +10386,8 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 @@ -8927,26 +10456,55 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.2.0 + wrappy@1.0.2: {} + ws@8.18.0: {} ws@8.20.1: {} xxhash-wasm@1.1.0: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yallist@5.0.0: {} + yaml@2.8.3: {} + yaml@2.9.0: {} + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yocto-queue@1.2.2: {} yocto-spinner@0.2.3: @@ -8972,6 +10530,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): dependencies: typescript: 5.9.3 From c59c69a1df89c98cc24a4749be05bdee686862b8 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 23:02:02 +0530 Subject: [PATCH 14/32] docs(sandbox): align E2B spec body with feasibility findings --- .../specs/2026-10-01-e2b-sandbox-provider-design.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md b/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md index f268af4..cd941e9 100644 --- a/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md +++ b/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md @@ -171,12 +171,12 @@ The request path on E2B is Browser → Worker → Orchestrator DO → `handle.fe All existing preview logic stays in `orchestrator/preview.ts`: preview tokens and hashing, revocation, `validatePreviewAccess`, `rewriteHeadersForSandboxDevServer`, path-based body rewriting, and 502/503/504 retries. Only the final `sandbox.fetch` call is replaced by `handle.fetchPort`. -E2B sandboxes are created with `secure: true` and `allowPublicTraffic: false`. The E2B adapter's `fetchPort` adds the `e2b-traffic-access-token` header and forwards WebSocket upgrades with an outbound `fetch` carrying `Upgrade: websocket`, returning the 101 response. +E2B sandboxes are created with `network: { allowPublicTraffic: false }` (the `secure` option is deprecated and ignored in e2b 2.x, and `allowPublicTraffic` is only honored under `network`; see Feasibility Findings). The E2B adapter's `fetchPort` adds the `e2b-traffic-access-token` header and forwards WebSocket upgrades with an outbound `fetch` carrying `Upgrade: websocket`, returning the 101 response. ### Image and template - `Dockerfile.sandbox` gains `ARG SANDBOX_BASE=docker.io/cloudflare/sandbox:0.12.7`. The E2B build passes `node:22-slim`. The runtime stage installs `bun` when it is missing. Everything else (apt packages, the `codevil` user with UID/GID 10001, `/workspace`, the built `sandbox-image` app, Pi skills) is shared. -- `packages/sandbox-image` gains an `e2b:template` script that builds the image with local Docker and publishes it as E2B template `codevil-sandbox` with 2 vCPU / 4096 MiB. The exact publishing tool (E2B CLI or Template SDK) is chosen in the feasibility gate. +- `packages/sandbox-image` gains an `e2b:template` script that builds the image with local Docker and publishes it as E2B template `codevil-sandbox` with 2 vCPU / 4096 MiB. The publishing route, chosen in the feasibility gate, is `docker build` of the multi-stage `Dockerfile.sandbox`, push to a registry, then `Template.build(Template().fromImage(, credentials), "codevil-sandbox", { cpuCount: 2, memoryMB: 4096 })`, because the SDK's `fromDockerfile` rejects multi-stage Dockerfiles (see Feasibility Findings, answer 7). Registry credentials for `fromImage` come from environment variables at template-build time and are never committed or placed in wrangler config. - `dockerfile.test.mjs` covers both base variants. ### Agent process and logs (E2B) @@ -224,7 +224,7 @@ If (1) fails, implementation stops and the design is revisited (a Node control s ## Testing - **Cloudflare adapter:** a behavior-preserving refactor. Existing worker tests keep their assertions. -- **E2B adapter:** unit tests with a mocked E2B client covering create options (`secure`, `allowPublicTraffic: false`, timeout), the access-token header and WebSocket forwarding in `fetchPort`, log file tails, `writeFile` mode and owner, and mapping not-found errors to `SandboxNotFoundError`. +- **E2B adapter:** unit tests with a mocked E2B client covering create options (`network: { allowPublicTraffic: false }`, timeout), the access-token header and WebSocket forwarding in `fetchPort`, log file tails, `writeFile` mode and owner, and mapping not-found errors to `SandboxNotFoundError`. - **Orchestrator** (fake host and fake provider): - idle pause fires after `max_idle_time` without input; - an active or queued run, or an open question, blocks pause; @@ -255,13 +255,13 @@ Probe run on 2026-10-01 under `wrangler dev` 4.100.0 (workerd, `compatibility_da 2. **WebSocket upgrade through `getHost(port)` with token:** Yes. A Worker `fetch("https://" + sandbox.getHost(8001) + "/", { headers: { Upgrade: "websocket", "e2b-traffic-access-token": token } })` returned `status 101` with `response.webSocket` non-null. After `ws.accept()`, a sent `ping` came back as `echo:ping`. Without the token header the upgrade returns 403. (Echo server: a ~25-line stdlib `python3` script; `websockets`/`wscat` were not needed.) 3. **Option-name correction (important for Task 4):** In e2b 2.51.0 `secure` is deprecated and ignored (every sandbox secures envd). `allowPublicTraffic` is **not** a top-level create option; it lives under `network`: `Sandbox.create({ network: { allowPublicTraffic: false } })`. With the plan's original options (`secure: true, allowPublicTraffic: false` top-level) the sandbox was created with public traffic allowed: `trafficAccessToken` was absent and requests with and without the header both returned 200 (and the WS upgrade succeeded without a token). With `network: { allowPublicTraffic: false }`, `trafficAccessToken` is present (64 chars), the HTTP request with the token returns 200 and without it 403. 4. **`Sandbox.connect` repopulates `trafficAccessToken`:** Yes. After `pause()` then `Sandbox.connect(id, { apiKey, timeoutMs })`, the connected instance had `trafficAccessToken` present (64 chars) and it equals the one from `create`. The background `python3 -m http.server` from before the pause still answered 200 after resume, and `/tmp/x` survived. Pause took ~0.5s, resume ~0.4s. -5. **Hobby 1-hour clock after resume:** Docs (https://docs.e2b.dev/sandbox/persistence): continuous runtime max is 1 hour on Hobby (24 hours on Pro), and "the continuous runtime limit is reset" after pause then resume. Observed: after `connect`, `getInfo().startedAt` was reset to the resume time and `endAt = startedAt + timeoutMs`. Record: **resets** (each resume starts a fresh clock). `SDK` doc for `timeoutMs` also states max 1h Hobby / 24h Pro. Do not request a `timeoutMs` above 3_600_000 on Hobby. -6. **Killed sandbox:** `Sandbox.connect(killedId)` throws class `SandboxNotFoundError` (message "Paused sandbox not found"), which `extends NotFoundError extends SandboxError`. Catch `NotFoundError` (instanceof) to cover both; do not match on the literal class name `NotFoundError`. +5. **Hobby 1-hour clock after resume:** Docs (https://docs.e2b.dev/sandbox/persistence): continuous runtime max is 1 hour on Hobby (24 hours on Pro), and "the continuous runtime limit is reset" after pause then resume. Observed: after `connect`, `getInfo().startedAt` was reset to the resume time and `endAt = startedAt + timeoutMs`. Record: **resets** (each resume starts a fresh clock). The SDK doc for `timeoutMs` also states max 1h Hobby / 24h Pro. Do not request a `timeoutMs` above 3_600_000 on Hobby. +6. **Killed sandbox:** `Sandbox.connect(killedId)` throws class `SandboxNotFoundError` (message "Paused sandbox not found"), which `extends NotFoundError extends SandboxError`. Import both from the SDK with `import { Sandbox, NotFoundError } from "e2b"` (`SandboxNotFoundError` is also exported) and catch `NotFoundError` (instanceof) to cover both; do not match on the literal class name `NotFoundError`. 7. **Template publishing:** - The v2 Template SDK `Template().fromDockerfile(pathOrContent)` **rejects multi-stage Dockerfiles**: the converter throws `"Multi-stage Dockerfiles are not supported"` when a Dockerfile has more than one `FROM` (verified in `e2b/dist/index.mjs`). It also requires exactly one `FROM`. - `Template().fromImage('registry/image:tag', { username, password })` accepts any registry image (also `fromTemplate`, and an ECR variant), so a multi-stage Dockerfile must be built and pushed with Docker first, then referenced via `fromImage`. Build with `Template.build(template, 'name', { cpuCount, memoryMB, onBuildLogs })`. - CLI (`@e2b/cli` 2.21.0): `e2b template create -d -p [-c ] [--ready-cmd ] [--cpu-count N] [--memory-mb N]` "builds a Dockerfile as a sandbox template". `e2b template migrate` converts `e2b.Dockerfile`/`e2b.toml` to the SDK format; `e2b template publish` only makes an existing template public (it is not the build step). Multi-stage support in the CLI path was not verified (no template was built); assume unsupported and use the `fromImage` route. Not published, per instructions. - - Recommended command for Task 8: `docker build` + `docker push` the multi-stage image, then a small script calling `Template.build(Template().fromImage('/:', creds), '')`. + - Recommended command for Task 8: `docker build` + `docker push` the multi-stage image, then a small script calling `Template.build(Template().fromImage('/:', creds), '')`. The registry credentials (`creds`) are read from environment variables at template-build time and are never committed or placed in wrangler config. ### Probe results (redacted JSON) From 581fe5f6609163baced41e2c0aacaefbbee34193 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Thu, 1 Oct 2026 23:07:12 +0530 Subject: [PATCH 15/32] feat(sandbox): add E2B sandbox provider and make it the default Deviation from the original plan, per feasibility findings: create uses network.allowPublicTraffic=false (secure and a top-level allowPublicTraffic are ignored by e2b 2.x and left the sandbox public). --- packages/worker/.dev.vars.example | 6 + packages/worker/src/orchestrator.ts | 6 +- packages/worker/src/orchestrator/host.ts | 3 +- .../worker/src/orchestrator/sandbox-access.ts | 17 ++ .../src/orchestrator/sandbox-handlers.ts | 5 +- packages/worker/src/orchestrator/types.ts | 2 + packages/worker/src/sandbox-provider/e2b.ts | 258 ++++++++++++++++++ packages/worker/src/sandbox-provider/index.ts | 18 +- packages/worker/src/worker-env.ts | 4 + packages/worker/test/redaction.test.mjs | 15 + packages/worker/test/sandbox-access.test.mjs | 33 +++ .../worker/test/sandbox-handlers.test.mjs | 1 + .../test/sandbox-provider-cloudflare.test.mjs | 3 +- .../worker/test/sandbox-provider-e2b.test.mjs | 207 ++++++++++++++ packages/worker/test/session-service.test.mjs | 4 +- .../worker/wrangler.operator.example.toml | 5 + packages/worker/wrangler.toml | 2 + 17 files changed, 581 insertions(+), 8 deletions(-) create mode 100644 packages/worker/src/sandbox-provider/e2b.ts create mode 100644 packages/worker/test/sandbox-provider-e2b.test.mjs diff --git a/packages/worker/.dev.vars.example b/packages/worker/.dev.vars.example index b5c39df..085d135 100644 --- a/packages/worker/.dev.vars.example +++ b/packages/worker/.dev.vars.example @@ -15,6 +15,12 @@ GITHUB_PAT= BETTER_AUTH_URL=http://localhost:8787 CODEVIL_WEB_ORIGIN=http://localhost:5173,http://localhost:8787 +# Sandbox provider (e2b | cloudflare). E2B is the default. +# SANDBOX_PROVIDER=e2b +E2B_API_KEY= +# E2B_TEMPLATE_ID=codevil-sandbox +# E2B_MAX_SANDBOX_SECONDS=3600 + # Optional email delivery. # EMAIL_PROVIDER=resend # RESEND_API_KEY= diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index be99703..752449f 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -25,7 +25,9 @@ import { collectAgentDiagnostics, connectSandboxHandle, destroySandbox, + loadStoredSandboxSecret, readRedactedAgentLogs, + SANDBOX_SECRET_KEY, sandboxDiagnosticsResponse, sandboxLogsResponse, } from "./orchestrator/sandbox-access.js"; @@ -163,6 +165,8 @@ export class Orchestrator extends DurableObject implements OrchestratorHost this.ctx = ctx as DurableObjectState<{}>; this.workerEnv = env; this.redactionSecrets = collectWorkerSecretValues(env); + // Gate every request on the stored provider secret being redactable. + ctx.blockConcurrencyWhile(() => loadStoredSandboxSecret(ctx.storage, this.redactionSecrets)); this.sql = ctx.storage.sql; this.eventLog = new SessionEventLog( this.sql, @@ -744,7 +748,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost return connectSandboxHandle({ meta: this.meta, provider: this.sandboxProvider(), - readSecret: () => this.ctx.storage.get("codevil:sandbox_secret"), + readSecret: () => this.ctx.storage.get(SANDBOX_SECRET_KEY), }); } diff --git a/packages/worker/src/orchestrator/host.ts b/packages/worker/src/orchestrator/host.ts index 5afa8e5..e8fef67 100644 --- a/packages/worker/src/orchestrator/host.ts +++ b/packages/worker/src/orchestrator/host.ts @@ -16,7 +16,8 @@ export interface OrchestratorHost { sql: SqlStorage; workerEnv: Env; ctx: DurableObjectState; - redactionSecrets: readonly string[]; + /** Mutable: provisioning appends the provider secret so later logs redact it. */ + redactionSecrets: string[]; loadMeta(): void; saveMeta(): void; diff --git a/packages/worker/src/orchestrator/sandbox-access.ts b/packages/worker/src/orchestrator/sandbox-access.ts index 2496d83..0fde2c9 100644 --- a/packages/worker/src/orchestrator/sandbox-access.ts +++ b/packages/worker/src/orchestrator/sandbox-access.ts @@ -11,6 +11,23 @@ import type { export const AGENT_PROCESS_ID = "codevil-agent"; +/** Durable Object storage key for the provider secret (E2B traffic token). */ +export const SANDBOX_SECRET_KEY = "codevil:sandbox_secret"; + +/** Add a provider secret to the live redaction list, in place and at most once. */ +export function registerSandboxSecret(secrets: string[], secret: string | undefined): void { + const normalized = secret?.trim(); + if (normalized && !secrets.includes(normalized)) secrets.push(normalized); +} + +/** Cold start: put the persisted provider secret back on the redaction list. */ +export async function loadStoredSandboxSecret( + storage: { get(key: string): Promise }, + secrets: string[], +): Promise { + registerSandboxSecret(secrets, await storage.get(SANDBOX_SECRET_KEY)); +} + export type SandboxHandleResolver = () => Promise; /** diff --git a/packages/worker/src/orchestrator/sandbox-handlers.ts b/packages/worker/src/orchestrator/sandbox-handlers.ts index 2b2bfe5..d103c0f 100644 --- a/packages/worker/src/orchestrator/sandbox-handlers.ts +++ b/packages/worker/src/orchestrator/sandbox-handlers.ts @@ -25,6 +25,7 @@ import { getProvisioningCredentialContext, requireProviderPublicConfig } from ". import { createSandboxGitProxyToken, createSandboxProxyToken } from "../sandbox-proxy.js"; import { createSandboxWebSocketToken } from "../sandbox-ws-token.js"; import { traceSandboxProvisioning } from "./provisioning.js"; +import { registerSandboxSecret, SANDBOX_SECRET_KEY } from "./sandbox-access.js"; import { buildPreviewUrl, createPreviewToken, @@ -79,7 +80,9 @@ export async function provisionSessionSandbox(host: OrchestratorHost): Promise; + timeoutMs?: number; +} + +/** The slice of the E2B SDK's `Sandbox` instance the adapter relies on. */ +export interface E2BSdkSandbox { + sandboxId: string; + trafficAccessToken?: string; + getHost(port: number): string; + commands: { + run(command: string, options: E2BRunOptions & { background: true }): Promise; + run(command: string, options?: E2BRunOptions): Promise<{ stdout: string; stderr: string; exitCode: number }>; + }; + files: { write(path: string, content: string, options?: { user?: string }): Promise }; + setTimeout(ms: number): Promise; + pause(): Promise; + kill(): Promise; +} + +export interface E2BSdk { + create(template: string, options: { + apiKey: string; + timeoutMs: number; + network: { allowPublicTraffic: false }; + metadata: Record; + }): Promise; + connect(sandboxId: string, options: { apiKey: string; timeoutMs?: number }): Promise; + isNotFound(error: unknown): boolean; +} + +export interface E2BSandboxProviderOptions { + apiKey: string; + templateId: string; + /** Upper bound for any lease the adapter requests (E2B Hobby allows one hour). */ + maxLeaseMs: number; + /** Injected in tests; production lazily imports `e2b`. */ + sdk?: E2BSdk; + fetch?: typeof fetch; +} + +export function createE2BSandboxProvider(options: E2BSandboxProviderOptions): SandboxProvider { + const sdk = options.sdk ?? lazyE2BSdk(); + const fetchImpl: typeof fetch = options.fetch ?? ((input, init) => fetch(input, init)); + const cap = (ms: number) => Math.min(ms, options.maxLeaseMs); + + return { + name: "e2b", + capabilities: { pauseResume: true, workspaceCache: false }, + async create({ sessionId, leaseMs }) { + // `secure` is ignored in e2b 2.x and a top-level `allowPublicTraffic` + // is not honored: only `network.allowPublicTraffic` locks the sandbox. + const sandbox = await sdk.create(options.templateId, { + apiKey: options.apiKey, + timeoutMs: cap(leaseMs), + network: { allowPublicTraffic: false }, + metadata: { codevil_session_id: sessionId }, + }); + if (!sandbox.trafficAccessToken) { + // Without a token the sandbox may be publicly reachable; never use it. + await sandbox.kill().catch(() => undefined); + throw new Error("E2B sandbox was created without a traffic access token"); + } + return e2bHandle({ + sandbox, + ref: { provider: "e2b", id: sandbox.sandboxId }, + secret: sandbox.trafficAccessToken, + sdk, + cap, + fetchImpl, + }); + }, + async connect(ref: SandboxRef, connectOptions) { + let sandbox: E2BSdkSandbox; + try { + sandbox = await sdk.connect(ref.id, { + apiKey: options.apiKey, + ...(connectOptions?.leaseMs !== undefined ? { timeoutMs: cap(connectOptions.leaseMs) } : {}), + }); + } catch (error) { + throw mapNotFound(sdk, error); + } + const secret = connectOptions?.secret ?? sandbox.trafficAccessToken; + return e2bHandle({ sandbox, ref, ...(secret ? { secret } : {}), sdk, cap, fetchImpl }); + }, + }; +} + +function e2bHandle(context: { + sandbox: E2BSdkSandbox; + ref: SandboxRef; + secret?: string; + sdk: E2BSdk; + cap: (ms: number) => number; + fetchImpl: typeof fetch; +}): SandboxHandle { + const { sandbox, sdk } = context; + + const exec: SandboxHandle["exec"] = async (command, execOptions) => { + try { + const result = await sandbox.commands.run(command, { + ...(execOptions?.cwd !== undefined ? { cwd: execOptions.cwd } : {}), + ...(execOptions?.env !== undefined ? { envs: execOptions.env } : {}), + ...(execOptions?.timeoutMs !== undefined ? { timeoutMs: execOptions.timeoutMs } : {}), + user: execOptions?.user === "codevil" ? "codevil" : "root", + }); + return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode } satisfies ShellResult; + } catch (error) { + const exit = asCommandExit(error); + if (exit) return exit; + throw mapNotFound(sdk, error); + } + }; + + // `path` is built from a validated process id, so it needs no quoting. + const tail = async (path: string): Promise => + (await exec(`tail -c ${LOG_TAIL_BYTES} ${path} 2>/dev/null || true`)).stdout; + + return { + ref: context.ref, + ...(context.secret ? { secret: context.secret } : {}), + exec, + async writeFile(path, content, fileOptions) { + try { + await sandbox.files.write(path, content, { user: "root" }); + } catch (error) { + throw mapNotFound(sdk, error); + } + const steps: string[] = []; + if (fileOptions?.mode !== undefined) steps.push(`chmod ${fileOptions.mode.toString(8)} ${shellQuote(path)}`); + if (fileOptions?.owner !== undefined) steps.push(`chown ${OWNER_IDS[fileOptions.owner]} ${shellQuote(path)}`); + if (steps.length > 0) { + const result = await exec(steps.join(" && ")); + if (result.exitCode !== 0) { + throw new Error(`Failed to set permissions on ${path} (exit ${result.exitCode}): ${result.stderr}`); + } + } + }, + async startProcess(command, { processId, cwd, env }) { + assertProcessId(processId); + const mkdir = await exec(`mkdir -p ${LOG_DIR}`); + if (mkdir.exitCode !== 0) { + throw new Error(`Failed to create ${LOG_DIR} (exit ${mkdir.exitCode}): ${mkdir.stderr}`); + } + const redirected = `sh -c ${shellQuote(command)} >${LOG_DIR}/${processId}.out 2>${LOG_DIR}/${processId}.err`; + try { + await sandbox.commands.run(redirected, { background: true, user: "root", cwd, envs: env }); + } catch (error) { + throw mapNotFound(sdk, error); + } + }, + async readProcessLogs(processId) { + assertProcessId(processId); + return { + stdout: await tail(`${LOG_DIR}/${processId}.out`), + stderr: await tail(`${LOG_DIR}/${processId}.err`), + }; + }, + fetchPort(port, request) { + const url = new URL(request.url); + const target = `https://${sandbox.getHost(port)}${url.pathname}${url.search}`; + const headers = new Headers(request.headers); + headers.delete("host"); + // Overwrites any client-supplied value; without the token E2B answers 403. + if (context.secret) headers.set(E2B_TRAFFIC_TOKEN_HEADER, context.secret); + return context.fetchImpl(new Request(target, { + method: request.method, + headers, + body: request.body, + redirect: "manual", + })); + }, + async renewLease(ms) { + try { + await sandbox.setTimeout(context.cap(ms)); + } catch (error) { + throw mapNotFound(sdk, error); + } + }, + async pause() { + try { + await sandbox.pause(); + } catch (error) { + throw mapNotFound(sdk, error); + } + }, + async destroy() { + try { + await sandbox.kill(); + } catch (error) { + if (!sdk.isNotFound(error)) throw error; + } + }, + }; +} + +/** A non-zero exit surfaces from the SDK as a thrown `CommandExitError`; callers expect a result. */ +function asCommandExit(error: unknown): ShellResult | null { + if (typeof error !== "object" || error === null) return null; + const candidate = error as { exitCode?: unknown; stdout?: unknown; stderr?: unknown }; + if (typeof candidate.exitCode !== "number") return null; + return { + stdout: typeof candidate.stdout === "string" ? candidate.stdout : "", + stderr: typeof candidate.stderr === "string" ? candidate.stderr : "", + exitCode: candidate.exitCode, + }; +} + +function mapNotFound(sdk: E2BSdk, error: unknown): unknown { + return sdk.isNotFound(error) ? new SandboxNotFoundError("E2B sandbox not found") : error; +} + +function assertProcessId(processId: string): void { + if (!PROCESS_ID_PATTERN.test(processId)) throw new Error("Invalid process id"); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** Loads `e2b` on first use so Cloudflare-only paths and tests never pay for the SDK. */ +function lazyE2BSdk(): E2BSdk { + let notFoundClass: (new (...args: never[]) => Error) | undefined; + let loaded: Promise | undefined; + const load = () => (loaded ??= import("e2b").then((module) => { + notFoundClass = module.NotFoundError; + return module; + })); + return { + async create(template, createOptions) { + const { Sandbox } = await load(); + return (await Sandbox.create(template, createOptions)) as unknown as E2BSdkSandbox; + }, + async connect(sandboxId, connectOptions) { + const { Sandbox } = await load(); + return (await Sandbox.connect(sandboxId, connectOptions)) as unknown as E2BSdkSandbox; + }, + // Synchronous by contract: any error the SDK can throw implies it is already loaded. + isNotFound: (error) => notFoundClass !== undefined && error instanceof notFoundClass, + }; +} diff --git a/packages/worker/src/sandbox-provider/index.ts b/packages/worker/src/sandbox-provider/index.ts index 0760eb7..8af7bc0 100644 --- a/packages/worker/src/sandbox-provider/index.ts +++ b/packages/worker/src/sandbox-provider/index.ts @@ -1,4 +1,5 @@ import { createCloudflareSandboxProvider } from "./cloudflare.js"; +import { createE2BSandboxProvider } from "./e2b.js"; import { parseSandboxProviderName, type SandboxProvider, type SandboxProviderName } from "./types.js"; export type { @@ -15,11 +16,18 @@ export { SandboxNotFoundError, parseSandboxProviderName } from "./types.js"; export interface SandboxProviderEnv { Sandbox: unknown; SANDBOX_PROVIDER?: string; + E2B_API_KEY?: string; + E2B_TEMPLATE_ID?: string; + E2B_MAX_SANDBOX_SECONDS?: string; } +const DEFAULT_E2B_TEMPLATE_ID = "codevil-sandbox"; +/** Matches the E2B Hobby continuous-runtime limit. */ +const DEFAULT_E2B_MAX_SANDBOX_SECONDS = 3600; + export function configuredSandboxProviderName(env: Pick): SandboxProviderName { const raw = env.SANDBOX_PROVIDER?.trim(); - if (!raw) return "cloudflare"; + if (!raw) return "e2b"; const name = parseSandboxProviderName(raw); if (!name) throw new Error("Unsupported SANDBOX_PROVIDER"); return name; @@ -27,7 +35,13 @@ export function configuredSandboxProviderName(env: Pick> CODEVIL_PROXY_SIGNING_SECRET?: string; SLACK_BOT_TOKEN?: string; SLACK_SIGNING_SECRET?: string; + E2B_API_KEY?: string; }; /** @@ -38,6 +39,7 @@ export function collectWorkerSecretValues(env: WorkerSecretEnv): string[] { env.CODEVIL_PROXY_SIGNING_SECRET, env.SLACK_BOT_TOKEN, env.SLACK_SIGNING_SECRET, + env.E2B_API_KEY, ].map(normalizeSecret).filter((secret): secret is string => secret !== undefined))]; } @@ -74,4 +76,6 @@ export interface Env extends WorkerSecretEnv { SLACK_SIGNING_SECRET?: string; CODEVIL_SLACK_BOT_USER_ID?: string; SANDBOX_PROVIDER?: string; + E2B_TEMPLATE_ID?: string; + E2B_MAX_SANDBOX_SECONDS?: string; } diff --git a/packages/worker/test/redaction.test.mjs b/packages/worker/test/redaction.test.mjs index 0cc2c58..288399b 100644 --- a/packages/worker/test/redaction.test.mjs +++ b/packages/worker/test/redaction.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { safeExceptionAttributes } from "@codevil/shared"; +import { registerSandboxSecret } from "../dist/orchestrator/sandbox-access.js"; import { redactEvent } from "../dist/redaction.js"; +import { collectWorkerSecretValues } from "../dist/worker-env.js"; test("redacts exact secret values from nested event payloads", () => { const event = redactEvent({ @@ -96,3 +99,15 @@ test("redacts Error causes and custom properties without invoking throwing diagn assert.equal(redacted.error.provider_context.stdout, "sandbox printed [REDACTED]"); assert.equal(redacted.error.diagnostic, "[UNAVAILABLE]"); }); + +test("E2B API key and sandbox traffic token are redacted from error logging attributes", () => { + const error = new Error("e2b rejected e2b_key_value with traffic token tat_secret_value"); + error.stack = "Error: e2b rejected e2b_key_value tat_secret_value\n at create (e2b.js:1:1)"; + const secrets = collectWorkerSecretValues({ E2B_API_KEY: "e2b_key_value" }); + registerSandboxSecret(secrets, "tat_secret_value"); + + const serialized = JSON.stringify(redactEvent(safeExceptionAttributes(error), secrets)); + + assert.doesNotMatch(serialized, /e2b_key_value|tat_secret_value/); + assert.match(serialized, /\[REDACTED\]/); +}); diff --git a/packages/worker/test/sandbox-access.test.mjs b/packages/worker/test/sandbox-access.test.mjs index 518a6da..2a39738 100644 --- a/packages/worker/test/sandbox-access.test.mjs +++ b/packages/worker/test/sandbox-access.test.mjs @@ -4,7 +4,10 @@ import test from "node:test"; import { connectSandboxHandle, destroySandbox, + loadStoredSandboxSecret, readRedactedAgentLogs, + registerSandboxSecret, + SANDBOX_SECRET_KEY, sandboxDiagnosticsResponse, sandboxLogsResponse, sandboxRefForMeta, @@ -147,3 +150,33 @@ test("diagnostics response is a 500 with the legacy body when no sandbox exists" assert.equal(response.status, 500); assert.deepEqual(await response.json(), { error: "Failed to read sandbox diagnostics" }); }); + +test("registerSandboxSecret adds a trimmed secret once, in place", () => { + const secrets = ["existing"]; + const same = secrets; + registerSandboxSecret(secrets, " tat_secret "); + registerSandboxSecret(secrets, "tat_secret"); + registerSandboxSecret(secrets, undefined); + registerSandboxSecret(secrets, " "); + assert.equal(secrets, same); + assert.deepEqual(secrets, ["existing", "tat_secret"]); +}); + +test("loadStoredSandboxSecret restores the persisted secret into the redaction list on cold start", async () => { + const secrets = ["e2b_key"]; + const reads = []; + await loadStoredSandboxSecret({ + get: async (key) => { reads.push(key); return "tat_secret"; }, + }, secrets); + assert.equal(SANDBOX_SECRET_KEY, "codevil:sandbox_secret"); + assert.deepEqual(reads, ["codevil:sandbox_secret"]); + assert.deepEqual(secrets, ["e2b_key", "tat_secret"]); + const logs = await sandboxLogsResponse( + async () => createFakeSandboxHandle({ readProcessLogs: async () => ({ stdout: "token tat_secret", stderr: "key e2b_key" }) }), + secrets, + ); + const body = JSON.stringify(await logs.json()); + assert.doesNotMatch(body, /tat_secret|e2b_key/); + await loadStoredSandboxSecret({ get: async () => undefined }, secrets); + assert.deepEqual(secrets, ["e2b_key", "tat_secret"]); +}); diff --git a/packages/worker/test/sandbox-handlers.test.mjs b/packages/worker/test/sandbox-handlers.test.mjs index a367186..109ddc3 100644 --- a/packages/worker/test/sandbox-handlers.test.mjs +++ b/packages/worker/test/sandbox-handlers.test.mjs @@ -503,6 +503,7 @@ test("provisionSessionSandbox creates through the provider, records the ref, res assert.deepEqual(host.meta.sandbox_ref, { provider: "cloudflare", id: "ses_test" }); assert.equal(host.meta.workspace_cache_restored, false); assert.equal(await host.ctx.storage.get("codevil:sandbox_secret"), "handle-secret"); + assert.ok(host.redactionSecrets.includes("handle-secret")); assert.deepEqual(calls, [ ["create", { sessionId: "ses_test", leaseMs: 30 * 60_000 }], ["startProcess", "codevil-agent", "/workspace"], diff --git a/packages/worker/test/sandbox-provider-cloudflare.test.mjs b/packages/worker/test/sandbox-provider-cloudflare.test.mjs index dab67b1..09fa31c 100644 --- a/packages/worker/test/sandbox-provider-cloudflare.test.mjs +++ b/packages/worker/test/sandbox-provider-cloudflare.test.mjs @@ -169,7 +169,8 @@ test("provider name parsing and legacy meta default", () => { assert.equal(parseSandboxProviderName("e2b"), "e2b"); assert.equal(parseSandboxProviderName("modal"), undefined); assert.equal(new SandboxNotFoundError().name, "SandboxNotFoundError"); - assert.equal(configuredSandboxProviderName({}), "cloudflare"); + assert.equal(configuredSandboxProviderName({}), "e2b"); + assert.equal(configuredSandboxProviderName({ SANDBOX_PROVIDER: "cloudflare" }), "cloudflare"); assert.throws(() => configuredSandboxProviderName({ SANDBOX_PROVIDER: "modal" }), /Unsupported SANDBOX_PROVIDER/); const provider = sandboxProviderForMeta({ Sandbox: {} }, {}); assert.equal(provider.name, "cloudflare"); diff --git a/packages/worker/test/sandbox-provider-e2b.test.mjs b/packages/worker/test/sandbox-provider-e2b.test.mjs new file mode 100644 index 0000000..656b78d --- /dev/null +++ b/packages/worker/test/sandbox-provider-e2b.test.mjs @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createE2BSandboxProvider, E2B_TRAFFIC_TOKEN_HEADER } from "../dist/sandbox-provider/e2b.js"; +import { SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; +import { collectWorkerSecretValues } from "../dist/worker-env.js"; +import { configuredSandboxProviderName, resolveSandboxProvider } from "../dist/sandbox-provider/index.js"; + +class NotFound extends Error {} + +class CommandExit extends Error { + constructor(exitCode, stdout, stderr) { + super("exit"); + this.exitCode = exitCode; + this.stdout = stdout; + this.stderr = stderr; + } +} + +function fakeSdk(options = {}) { + const log = []; + const files = new Map(); + const sandbox = { + sandboxId: "sbx_1", + trafficAccessToken: "tat_secret", + getHost: (port) => `${port}-sbx_1.e2b.app`, + commands: { + run: async (cmd, opts) => { + log.push(["run", cmd, opts]); + if (options.run) return options.run(cmd, opts); + if (cmd.startsWith("tail -c 65536 /var/log/codevil/codevil-agent.out")) return { stdout: "agent out", stderr: "", exitCode: 0 }; + if (cmd.startsWith("tail -c 65536 /var/log/codevil/codevil-agent.err")) return { stdout: "agent err", stderr: "", exitCode: 0 }; + return { stdout: "", stderr: "", exitCode: 0 }; + }, + }, + files: { write: async (path, content, opts) => { files.set(path, content); log.push(["write", path, opts]); } }, + setTimeout: async (ms) => { log.push(["setTimeout", ms]); }, + pause: async () => { log.push(["pause"]); }, + kill: async () => { log.push(["kill"]); if (options.killError) throw options.killError; }, + }; + const sdk = { + create: async (template, opts) => { log.push(["create", template, opts]); return sandbox; }, + connect: async (id, opts) => { + log.push(["connect", id, opts]); + if (id === "gone") throw new NotFound("sandbox gone"); + return sandbox; + }, + isNotFound: (error) => error instanceof NotFound, + }; + return { sdk, log, files, sandbox }; +} + +function provider(overrides = {}, sdkOptions = {}) { + const fake = fakeSdk(sdkOptions); + const fetchCalls = []; + const p = createE2BSandboxProvider({ + apiKey: "e2b_key", templateId: "codevil-sandbox", maxLeaseMs: 3_600_000, sdk: fake.sdk, + fetch: async (request) => { fetchCalls.push(request); return new Response("preview"); }, + ...overrides, + }); + return { p, fake, fetchCalls }; +} + +test("create locks public traffic, caps the lease, and returns the traffic token as the secret", async () => { + const { p, fake } = provider(); + const handle = await p.create({ sessionId: "ses_1", leaseMs: 7_200_000 }); + assert.deepEqual(handle.ref, { provider: "e2b", id: "sbx_1" }); + assert.equal(handle.secret, "tat_secret"); + assert.deepEqual(fake.log[0], ["create", "codevil-sandbox", { + apiKey: "e2b_key", timeoutMs: 3_600_000, + network: { allowPublicTraffic: false }, + metadata: { codevil_session_id: "ses_1" }, + }]); + assert.deepEqual(p.capabilities, { pauseResume: true, workspaceCache: false }); +}); + +test("create keeps a lease below the cap", async () => { + const { p, fake } = provider(); + await p.create({ sessionId: "ses_1", leaseMs: 600_000 }); + assert.equal(fake.log[0][2].timeoutMs, 600_000); +}); + +test("create fails closed when E2B returns no traffic token", async () => { + const { p, fake } = provider(); + delete fake.sandbox.trafficAccessToken; + await assert.rejects(p.create({ sessionId: "ses_1", leaseMs: 600_000 }), /traffic access token/); + assert.deepEqual(fake.log.at(-1), ["kill"]); +}); + +test("connect maps not-found to SandboxNotFoundError", async () => { + const { p } = provider(); + await assert.rejects(p.connect({ provider: "e2b", id: "gone" }), SandboxNotFoundError); +}); + +test("connect rethrows other errors and caps the lease", async () => { + const { p, fake } = provider(); + fake.sdk.connect = async () => { throw new Error("boom"); }; + await assert.rejects(p.connect({ provider: "e2b", id: "sbx_1" }), /boom/); + const second = provider(); + await second.p.connect({ provider: "e2b", id: "sbx_1" }, { leaseMs: 9_000_000 }); + await second.p.connect({ provider: "e2b", id: "sbx_1" }); + assert.equal(second.fake.log[0][2].timeoutMs, 3_600_000); + assert.equal(second.fake.log[1][2].timeoutMs, undefined); +}); + +test("connect uses the passed secret, else the repopulated traffic token", async () => { + const { p } = provider(); + assert.equal((await p.connect({ provider: "e2b", id: "sbx_1" }, { secret: "stored" })).secret, "stored"); + assert.equal((await p.connect({ provider: "e2b", id: "sbx_1" })).secret, "tat_secret"); +}); + +test("fetchPort targets the E2B host with the traffic token and keeps path and query", async () => { + const { p, fetchCalls } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }, { secret: "tat_secret" }); + await handle.fetchPort(5173, new Request("http://localhost/src/main.ts?t=1", { + headers: { host: "localhost:5173", upgrade: "websocket", [E2B_TRAFFIC_TOKEN_HEADER]: "client-supplied" }, + })); + assert.equal(fetchCalls[0].url, "https://5173-sbx_1.e2b.app/src/main.ts?t=1"); + assert.equal(fetchCalls[0].headers.get(E2B_TRAFFIC_TOKEN_HEADER), "tat_secret"); + assert.equal(fetchCalls[0].headers.get("upgrade"), "websocket"); + assert.equal(fetchCalls[0].redirect, "manual"); +}); + +test("startProcess redirects output to per-process log files and readProcessLogs tails them", async () => { + const { p, fake } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await handle.startProcess("node /app/x.js --name 'a b'", { processId: "codevil-agent", cwd: "/workspace", env: { A: "1" } }); + const background = fake.log.find(([kind, , opts]) => kind === "run" && opts?.background); + assert.match(background[1], /^sh -c '.*' >\/var\/log\/codevil\/codevil-agent\.out 2>\/var\/log\/codevil\/codevil-agent\.err$/); + assert.ok(background[1].includes(`node /app/x.js --name '\\''a b'\\''`)); + assert.deepEqual(background[2], { background: true, user: "root", cwd: "/workspace", envs: { A: "1" } }); + const mkdirIndex = fake.log.findIndex(([, cmd]) => cmd === "mkdir -p /var/log/codevil"); + assert.ok(mkdirIndex >= 0 && mkdirIndex < fake.log.indexOf(background)); + assert.deepEqual(await handle.readProcessLogs("codevil-agent"), { stdout: "agent out", stderr: "agent err" }); + await assert.rejects(handle.startProcess("x", { processId: "../etc", cwd: "/", env: {} })); + await assert.rejects(handle.readProcessLogs("../etc")); +}); + +test("writeFile sets mode and owner", async () => { + const { p, fake } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await handle.writeFile("/run/codevil/ws-token", "tok", { mode: 0o600, owner: "codevil" }); + assert.equal(fake.files.get("/run/codevil/ws-token"), "tok"); + assert.deepEqual(fake.log.find(([kind]) => kind === "write")[2], { user: "root" }); + const chmod = fake.log.find(([kind, cmd]) => kind === "run" && cmd.includes("chmod")); + assert.match(chmod[1], /chmod 600 '\/run\/codevil\/ws-token' && chown 10001:10001 '\/run\/codevil\/ws-token'/); +}); + +test("writeFile throws when permissions cannot be set", async () => { + const { p } = provider({}, { run: async () => ({ stdout: "", stderr: "denied", exitCode: 1 }) }); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await assert.rejects(handle.writeFile("/x", "y", { mode: 0o600 }), /Failed to set permissions on \/x/); +}); + +test("exec maps users and options, and turns a command exit error into a result", async () => { + const { p, fake } = provider({}, { + run: async (cmd) => { + if (cmd === "false") throw new CommandExit(3, "out", "err"); + if (cmd === "hang") throw new Error("timed out"); + return { stdout: "ok", stderr: "", exitCode: 0 }; + }, + }); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await handle.exec("echo", { cwd: "/w", env: { A: "1" }, timeoutMs: 5, user: "codevil" }); + await handle.exec("echo"); + assert.deepEqual(fake.log.filter(([kind]) => kind === "run").map(([, , opts]) => opts), [ + { cwd: "/w", envs: { A: "1" }, timeoutMs: 5, user: "codevil" }, + { user: "root" }, + ]); + assert.deepEqual(await handle.exec("false"), { stdout: "out", stderr: "err", exitCode: 3 }); + await assert.rejects(handle.exec("hang"), /timed out/); +}); + +test("renewLease caps at the provider maximum; pause and destroy delegate", async () => { + const { p, fake } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await handle.renewLease(9_999_999); + await handle.pause(); + await handle.destroy("done"); + assert.deepEqual(fake.log.slice(-3), [["setTimeout", 3_600_000], ["pause"], ["kill"]]); +}); + +test("destroy treats not-found as success and rethrows other errors", async () => { + const gone = provider({}, { killError: new NotFound("gone") }); + await (await gone.p.connect({ provider: "e2b", id: "sbx_1" })).destroy("done"); + const broken = provider({}, { killError: new Error("boom") }); + await assert.rejects((await broken.p.connect({ provider: "e2b", id: "sbx_1" })).destroy("done"), /boom/); +}); + +test("renewLease and pause map not-found to SandboxNotFoundError", async () => { + const { p, fake } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + fake.sandbox.setTimeout = async () => { throw new NotFound("gone"); }; + fake.sandbox.pause = async () => { throw new NotFound("gone"); }; + await assert.rejects(handle.renewLease(1_000), SandboxNotFoundError); + await assert.rejects(handle.pause(), SandboxNotFoundError); +}); + +test("E2B key is a redacted worker secret and e2b is the default provider", () => { + assert.ok(collectWorkerSecretValues({ E2B_API_KEY: "e2b_key" }).includes("e2b_key")); + assert.equal(configuredSandboxProviderName({}), "e2b"); + assert.throws(() => resolveSandboxProvider({ Sandbox: {} }, "e2b"), /E2B_API_KEY is not configured/); +}); + +test("resolveSandboxProvider builds the e2b provider from env", () => { + const provider = resolveSandboxProvider({ Sandbox: {}, E2B_API_KEY: "e2b_key" }, "e2b"); + assert.equal(provider.name, "e2b"); +}); diff --git a/packages/worker/test/session-service.test.mjs b/packages/worker/test/session-service.test.mjs index 3c30a25..6cb017e 100644 --- a/packages/worker/test/session-service.test.mjs +++ b/packages/worker/test/session-service.test.mjs @@ -81,7 +81,7 @@ test("createSession inserts a session, initializes the orchestrator, and returns exec_model: normalized.exec_model, max_time: normalized.max_session_time, created_by: { id: "usr_123", name: "Alice" }, - sandbox_provider: "cloudflare", + sandbox_provider: "e2b", max_idle_time: normalized.max_idle_time, }, ]); @@ -178,7 +178,7 @@ test("handleCreateSession forwards a provided max_idle_time to the orchestrator" assert.equal(response.status, 201); assert.equal(initCall[3].max_idle_time, "45m"); - assert.equal(initCall[3].sandbox_provider, "cloudflare"); + assert.equal(initCall[3].sandbox_provider, "e2b"); }); test("handleCreateSession forwards the default max_idle_time when none is provided", async () => { diff --git a/packages/worker/wrangler.operator.example.toml b/packages/worker/wrangler.operator.example.toml index f2ce70a..5107930 100644 --- a/packages/worker/wrangler.operator.example.toml +++ b/packages/worker/wrangler.operator.example.toml @@ -13,6 +13,11 @@ upload_source_maps = true [vars] EMAIL_PROVIDER = "none" +# Sandbox provider: "e2b" (default) or "cloudflare". E2B_API_KEY is a secret: +# set it with `wrangler secret put E2B_API_KEY`, never in this file. +SANDBOX_PROVIDER = "e2b" +E2B_TEMPLATE_ID = "codevil-sandbox" +# E2B_MAX_SANDBOX_SECONDS = "3600" CODEVIL_WEB_ORIGIN = "https://your-web-origin.example" CODEVIL_PREVIEW_ORIGIN = "https://your-preview-origin.example" BACKUP_BUCKET_NAME = "your-backup-bucket" diff --git a/packages/worker/wrangler.toml b/packages/worker/wrangler.toml index 34cdc0f..d54fc92 100644 --- a/packages/worker/wrangler.toml +++ b/packages/worker/wrangler.toml @@ -7,6 +7,8 @@ upload_source_maps = true [vars] EMAIL_PROVIDER = "none" +SANDBOX_PROVIDER = "e2b" +E2B_TEMPLATE_ID = "codevil-sandbox" CODEVIL_WEB_ORIGIN = "http://localhost:5173,http://localhost:8787" # Leaving CODEVIL_PREVIEW_ORIGIN unset keeps preview URLs on the portable # workers.dev path. Set it in an untracked operator overlay to use a domain. From 766ec045ac69b0b5434b6215d0fa61eda5f71ed4 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 07:44:57 +0530 Subject: [PATCH 16/32] fix(sandbox): disconnect E2B command streams and harden E2B adapter edges --- packages/worker/src/orchestrator.ts | 2 + .../worker/src/orchestrator/sandbox-access.ts | 9 +- .../worker/src/sandbox-provider/cloudflare.ts | 7 +- packages/worker/src/sandbox-provider/e2b.ts | 73 ++++++++---- packages/worker/src/sandbox-provider/index.ts | 8 +- packages/worker/src/sandbox-provider/shell.ts | 7 ++ packages/worker/test/sandbox-access.test.mjs | 22 ++++ .../worker/test/sandbox-provider-e2b.test.mjs | 109 ++++++++++++++++-- 8 files changed, 200 insertions(+), 37 deletions(-) create mode 100644 packages/worker/src/sandbox-provider/shell.ts diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index 752449f..d06babb 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -749,6 +749,8 @@ export class Orchestrator extends DurableObject implements OrchestratorHost meta: this.meta, provider: this.sandboxProvider(), readSecret: () => this.ctx.storage.get(SANDBOX_SECRET_KEY), + secrets: this.redactionSecrets, + storeSecret: (secret) => this.ctx.storage.put(SANDBOX_SECRET_KEY, secret), }); } diff --git a/packages/worker/src/orchestrator/sandbox-access.ts b/packages/worker/src/orchestrator/sandbox-access.ts index 0fde2c9..0b60cf7 100644 --- a/packages/worker/src/orchestrator/sandbox-access.ts +++ b/packages/worker/src/orchestrator/sandbox-access.ts @@ -50,11 +50,18 @@ export async function connectSandboxHandle(options: { meta: { session_id: string; sandbox_ref?: SandboxRef } | null; provider: SandboxProvider; readSecret: () => Promise; + /** Live redaction list; any secret the handle ends up using is added to it. */ + secrets?: string[]; + /** Persist a secret the provider supplied because none was stored. */ + storeSecret?: (secret: string) => Promise; }): Promise { const ref = sandboxRefForMeta(options.meta, options.provider); if (!ref) return null; const secret = await options.readSecret(); - return options.provider.connect(ref, secret ? { secret } : undefined); + const handle = await options.provider.connect(ref, secret ? { secret } : undefined); + if (options.secrets) registerSandboxSecret(options.secrets, handle.secret); + if (!secret && handle.secret) await options.storeSecret?.(handle.secret); + return handle; } /** Destroy the Session's sandbox; failures are reported, never thrown. */ diff --git a/packages/worker/src/sandbox-provider/cloudflare.ts b/packages/worker/src/sandbox-provider/cloudflare.ts index 95705fc..641dda5 100644 --- a/packages/worker/src/sandbox-provider/cloudflare.ts +++ b/packages/worker/src/sandbox-provider/cloudflare.ts @@ -12,6 +12,7 @@ import type { SandboxRef, ShellResult, } from "./types.js"; +import { OWNER_IDS, shellQuote } from "./shell.js"; /** The slice of `@cloudflare/sandbox`'s Sandbox the adapter relies on. */ export interface CloudflareSandboxLike extends WorkspaceCacheSandbox { @@ -45,8 +46,6 @@ export interface CloudflareSandboxProviderOptions { getSandbox?: GetCloudflareSandbox; } -const OWNER_IDS = { codevil: "10001:10001", root: "0:0" } as const; - export function createCloudflareSandboxProvider( providerOptions: CloudflareSandboxProviderOptions, ): SandboxProvider { @@ -125,7 +124,3 @@ function cloudflareHandle(sandbox: CloudflareSandboxLike, ref: SandboxRef): Sand workspaceCache: sandbox, }; } - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} diff --git a/packages/worker/src/sandbox-provider/e2b.ts b/packages/worker/src/sandbox-provider/e2b.ts index 7f7f57e..51716ca 100644 --- a/packages/worker/src/sandbox-provider/e2b.ts +++ b/packages/worker/src/sandbox-provider/e2b.ts @@ -5,13 +5,20 @@ import { type SandboxRef, type ShellResult, } from "./types.js"; +import { OWNER_IDS, shellQuote } from "./shell.js"; export const E2B_TRAFFIC_TOKEN_HEADER = "e2b-traffic-access-token"; const LOG_DIR = "/var/log/codevil"; const LOG_TAIL_BYTES = 65_536; const PROCESS_ID_PATTERN = /^[a-z0-9-]+$/; -const OWNER_IDS = { codevil: "10001:10001", root: "0:0" } as const; +/** + * Root-owned staging directory for `writeFile`. It lives on the same tmpfs as + * `/run/codevil`, so the final `mv` is an atomic rename there. Assumption: the + * targets Codevil writes (the websocket token) are under `/run`; for a target + * on another filesystem `mv` degrades to copy-and-delete. + */ +const STAGE_DIR = "/run/.codevil-stage"; interface E2BRunOptions { user?: string; @@ -26,7 +33,7 @@ export interface E2BSdkSandbox { trafficAccessToken?: string; getHost(port: number): string; commands: { - run(command: string, options: E2BRunOptions & { background: true }): Promise; + run(command: string, options: E2BRunOptions & { background: true }): Promise<{ disconnect(): Promise }>; run(command: string, options?: E2BRunOptions): Promise<{ stdout: string; stderr: string; exitCode: number }>; }; files: { write(path: string, content: string, options?: { user?: string }): Promise }; @@ -75,8 +82,10 @@ export function createE2BSandboxProvider(options: E2BSandboxProviderOptions): Sa }); if (!sandbox.trafficAccessToken) { // Without a token the sandbox may be publicly reachable; never use it. - await sandbox.kill().catch(() => undefined); - throw new Error("E2B sandbox was created without a traffic access token"); + const cleanupFailed = await sandbox.kill().then(() => false, () => true); + throw new Error( + `E2B sandbox was created without a traffic access token${cleanupFailed ? " (cleanup kill also failed)" : ""}`, + ); } return e2bHandle({ sandbox, @@ -138,19 +147,28 @@ function e2bHandle(context: { ...(context.secret ? { secret: context.secret } : {}), exec, async writeFile(path, content, fileOptions) { + // Content is staged in a root-only directory, permissioned there, then + // renamed into place: `mv -fT` replaces a symlink at the target instead of + // following it, and the file is never visible with default permissions. + const stage = `${STAGE_DIR}/${crypto.randomUUID()}`; + const prepare = await exec( + `mkdir -p -m 700 ${STAGE_DIR} && chmod 700 ${STAGE_DIR} && chown 0:0 ${STAGE_DIR}`, + ); + assertOk(prepare, `Failed to prepare staging for ${path}`); try { - await sandbox.files.write(path, content, { user: "root" }); + await sandbox.files.write(stage, content, { user: "root" }); } catch (error) { throw mapNotFound(sdk, error); } const steps: string[] = []; - if (fileOptions?.mode !== undefined) steps.push(`chmod ${fileOptions.mode.toString(8)} ${shellQuote(path)}`); - if (fileOptions?.owner !== undefined) steps.push(`chown ${OWNER_IDS[fileOptions.owner]} ${shellQuote(path)}`); - if (steps.length > 0) { - const result = await exec(steps.join(" && ")); - if (result.exitCode !== 0) { - throw new Error(`Failed to set permissions on ${path} (exit ${result.exitCode}): ${result.stderr}`); - } + if (fileOptions?.mode !== undefined) steps.push(`chmod ${fileOptions.mode.toString(8)} ${shellQuote(stage)}`); + if (fileOptions?.owner !== undefined) steps.push(`chown ${OWNER_IDS[fileOptions.owner]} ${shellQuote(stage)}`); + steps.push(`mkdir -p ${shellQuote(parentDirectory(path))}`); + steps.push(`mv -fT ${shellQuote(stage)} ${shellQuote(path)}`); + const result = await exec(steps.join(" && ")); + if (result.exitCode !== 0) { + await exec(`rm -f ${shellQuote(stage)}`).catch(() => undefined); + throw new Error(`Failed to write ${path} (exit ${result.exitCode}): ${result.stderr}`); } }, async startProcess(command, { processId, cwd, env }) { @@ -161,7 +179,10 @@ function e2bHandle(context: { } const redirected = `sh -c ${shellQuote(command)} >${LOG_DIR}/${processId}.out 2>${LOG_DIR}/${processId}.err`; try { - await sandbox.commands.run(redirected, { background: true, user: "root", cwd, envs: env }); + const handle = await sandbox.commands.run(redirected, { background: true, user: "root", cwd, envs: env }); + // The process keeps running; this only closes the SDK's event stream, + // which would otherwise keep the Durable Object resident. + await handle.disconnect(); } catch (error) { throw mapNotFound(sdk, error); } @@ -173,19 +194,26 @@ function e2bHandle(context: { stderr: await tail(`${LOG_DIR}/${processId}.err`), }; }, - fetchPort(port, request) { + async fetchPort(port, request) { const url = new URL(request.url); const target = `https://${sandbox.getHost(port)}${url.pathname}${url.search}`; const headers = new Headers(request.headers); headers.delete("host"); - // Overwrites any client-supplied value; without the token E2B answers 403. - if (context.secret) headers.set(E2B_TRAFFIC_TOKEN_HEADER, context.secret); - return context.fetchImpl(new Request(target, { + // Never forward a client-supplied token, and never send tokenless: E2B + // would answer 403 at best. + headers.delete(E2B_TRAFFIC_TOKEN_HEADER); + if (!context.secret) throw new Error("E2B sandbox traffic token is unavailable"); + headers.set(E2B_TRAFFIC_TOKEN_HEADER, context.secret); + const init: RequestInit & { duplex?: "half" } = { method: request.method, headers, body: request.body, redirect: "manual", - })); + signal: request.signal, + ...(request.cf ? { cf: request.cf } : {}), + ...(request.body ? { duplex: "half" as const } : {}), + }; + return context.fetchImpl(new Request(target, init)); }, async renewLease(ms) { try { @@ -231,8 +259,13 @@ function assertProcessId(processId: string): void { if (!PROCESS_ID_PATTERN.test(processId)) throw new Error("Invalid process id"); } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; +function assertOk(result: ShellResult, message: string): void { + if (result.exitCode !== 0) throw new Error(`${message} (exit ${result.exitCode}): ${result.stderr}`); +} + +function parentDirectory(path: string): string { + const index = path.lastIndexOf("/"); + return index <= 0 ? "/" : path.slice(0, index); } /** Loads `e2b` on first use so Cloudflare-only paths and tests never pay for the SDK. */ diff --git a/packages/worker/src/sandbox-provider/index.ts b/packages/worker/src/sandbox-provider/index.ts index 8af7bc0..dedec41 100644 --- a/packages/worker/src/sandbox-provider/index.ts +++ b/packages/worker/src/sandbox-provider/index.ts @@ -25,6 +25,12 @@ const DEFAULT_E2B_TEMPLATE_ID = "codevil-sandbox"; /** Matches the E2B Hobby continuous-runtime limit. */ const DEFAULT_E2B_MAX_SANDBOX_SECONDS = 3600; +/** Lease cap in ms: a positive whole number of seconds, else the Hobby default. */ +export function e2bMaxLeaseMs(env: Pick): number { + const seconds = Math.floor(Number(env.E2B_MAX_SANDBOX_SECONDS)); + return (Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_E2B_MAX_SANDBOX_SECONDS) * 1000; +} + export function configuredSandboxProviderName(env: Pick): SandboxProviderName { const raw = env.SANDBOX_PROVIDER?.trim(); if (!raw) return "e2b"; @@ -40,7 +46,7 @@ export function resolveSandboxProvider(env: SandboxProviderEnv, name: SandboxPro return createE2BSandboxProvider({ apiKey, templateId: env.E2B_TEMPLATE_ID?.trim() || DEFAULT_E2B_TEMPLATE_ID, - maxLeaseMs: (Number(env.E2B_MAX_SANDBOX_SECONDS) || DEFAULT_E2B_MAX_SANDBOX_SECONDS) * 1000, + maxLeaseMs: e2bMaxLeaseMs(env), }); } diff --git a/packages/worker/src/sandbox-provider/shell.ts b/packages/worker/src/sandbox-provider/shell.ts new file mode 100644 index 0000000..aa249ac --- /dev/null +++ b/packages/worker/src/sandbox-provider/shell.ts @@ -0,0 +1,7 @@ +/** UID:GID pairs the sandbox adapters chown written files to. */ +export const OWNER_IDS = { codevil: "10001:10001", root: "0:0" } as const; + +/** Single-quote a value for safe interpolation into a POSIX shell command. */ +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/packages/worker/test/sandbox-access.test.mjs b/packages/worker/test/sandbox-access.test.mjs index 2a39738..917cfcc 100644 --- a/packages/worker/test/sandbox-access.test.mjs +++ b/packages/worker/test/sandbox-access.test.mjs @@ -180,3 +180,25 @@ test("loadStoredSandboxSecret restores the persisted secret into the redaction l await loadStoredSandboxSecret({ get: async () => undefined }, secrets); assert.deepEqual(secrets, ["e2b_key", "tat_secret"]); }); + +test("connectSandboxHandle registers and persists a provider-supplied secret when none was stored", async () => { + const secrets = []; + const stored = []; + const provider = { name: "e2b", connect: async () => createFakeSandboxHandle({ secret: "fallback_tat" }) }; + const meta = { session_id: "ses_1", sandbox_ref: { provider: "e2b", id: "sbx_1" } }; + await connectSandboxHandle({ + meta, provider, secrets, + readSecret: async () => undefined, + storeSecret: async (secret) => { stored.push(secret); }, + }); + assert.deepEqual(secrets, ["fallback_tat"]); + assert.deepEqual(stored, ["fallback_tat"]); + + await connectSandboxHandle({ + meta, provider, secrets, + readSecret: async () => "fallback_tat", + storeSecret: async (secret) => { stored.push(secret); }, + }); + assert.deepEqual(secrets, ["fallback_tat"]); + assert.deepEqual(stored, ["fallback_tat"]); +}); diff --git a/packages/worker/test/sandbox-provider-e2b.test.mjs b/packages/worker/test/sandbox-provider-e2b.test.mjs index 656b78d..de03666 100644 --- a/packages/worker/test/sandbox-provider-e2b.test.mjs +++ b/packages/worker/test/sandbox-provider-e2b.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { createE2BSandboxProvider, E2B_TRAFFIC_TOKEN_HEADER } from "../dist/sandbox-provider/e2b.js"; import { SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; import { collectWorkerSecretValues } from "../dist/worker-env.js"; -import { configuredSandboxProviderName, resolveSandboxProvider } from "../dist/sandbox-provider/index.js"; +import { configuredSandboxProviderName, e2bMaxLeaseMs, resolveSandboxProvider } from "../dist/sandbox-provider/index.js"; class NotFound extends Error {} @@ -27,6 +27,7 @@ function fakeSdk(options = {}) { run: async (cmd, opts) => { log.push(["run", cmd, opts]); if (options.run) return options.run(cmd, opts); + if (opts?.background) return { disconnect: async () => { log.push(["disconnect"]); } }; if (cmd.startsWith("tail -c 65536 /var/log/codevil/codevil-agent.out")) return { stdout: "agent out", stderr: "", exitCode: 0 }; if (cmd.startsWith("tail -c 65536 /var/log/codevil/codevil-agent.err")) return { stdout: "agent err", stderr: "", exitCode: 0 }; return { stdout: "", stderr: "", exitCode: 0 }; @@ -86,6 +87,16 @@ test("create fails closed when E2B returns no traffic token", async () => { assert.deepEqual(fake.log.at(-1), ["kill"]); }); +test("create reports a failed cleanup kill without leaking secrets", async () => { + const { p, fake } = provider(); + delete fake.sandbox.trafficAccessToken; + fake.sandbox.kill = async () => { throw new Error("kill failed e2b_key"); }; + await assert.rejects(p.create({ sessionId: "ses_1", leaseMs: 600_000 }), (error) => { + assert.equal(error.message, "E2B sandbox was created without a traffic access token (cleanup kill also failed)"); + return true; + }); +}); + test("connect maps not-found to SandboxNotFoundError", async () => { const { p } = provider(); await assert.rejects(p.connect({ provider: "e2b", id: "gone" }), SandboxNotFoundError); @@ -120,6 +131,28 @@ test("fetchPort targets the E2B host with the traffic token and keeps path and q assert.equal(fetchCalls[0].redirect, "manual"); }); +test("fetchPort forwards method, body and abort signal", async () => { + const { p, fetchCalls } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + const controller = new AbortController(); + await handle.fetchPort(3000, new Request("http://localhost/api", { method: "POST", body: "payload", signal: controller.signal })); + assert.equal(fetchCalls[0].method, "POST"); + assert.equal(await fetchCalls[0].text(), "payload"); + controller.abort(); + assert.equal(fetchCalls[0].signal.aborted, true); +}); + +test("fetchPort without a traffic token fails closed and never calls fetch", async () => { + const { p, fake, fetchCalls } = provider(); + delete fake.sandbox.trafficAccessToken; + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await assert.rejects( + handle.fetchPort(5173, new Request("http://localhost/", { headers: { [E2B_TRAFFIC_TOKEN_HEADER]: "client-supplied" } })), + /E2B sandbox traffic token is unavailable/, + ); + assert.deepEqual(fetchCalls, []); +}); + test("startProcess redirects output to per-process log files and readProcessLogs tails them", async () => { const { p, fake } = provider(); const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); @@ -130,25 +163,83 @@ test("startProcess redirects output to per-process log files and readProcessLogs assert.deepEqual(background[2], { background: true, user: "root", cwd: "/workspace", envs: { A: "1" } }); const mkdirIndex = fake.log.findIndex(([, cmd]) => cmd === "mkdir -p /var/log/codevil"); assert.ok(mkdirIndex >= 0 && mkdirIndex < fake.log.indexOf(background)); + assert.deepEqual(fake.log[fake.log.indexOf(background) + 1], ["disconnect"]); assert.deepEqual(await handle.readProcessLogs("codevil-agent"), { stdout: "agent out", stderr: "agent err" }); await assert.rejects(handle.startProcess("x", { processId: "../etc", cwd: "/", env: {} })); await assert.rejects(handle.readProcessLogs("../etc")); }); -test("writeFile sets mode and owner", async () => { +test("writeFile stages as root, permissions the staged file, then renames over the target", async () => { const { p, fake } = provider(); const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); await handle.writeFile("/run/codevil/ws-token", "tok", { mode: 0o600, owner: "codevil" }); - assert.equal(fake.files.get("/run/codevil/ws-token"), "tok"); - assert.deepEqual(fake.log.find(([kind]) => kind === "write")[2], { user: "root" }); - const chmod = fake.log.find(([kind, cmd]) => kind === "run" && cmd.includes("chmod")); - assert.match(chmod[1], /chmod 600 '\/run\/codevil\/ws-token' && chown 10001:10001 '\/run\/codevil\/ws-token'/); + const writeIndex = fake.log.findIndex(([kind]) => kind === "write"); + const [, stage, writeOptions] = fake.log[writeIndex]; + assert.match(stage, /^\/run\/\.codevil-stage\/[0-9a-f-]{36}$/); + assert.deepEqual(writeOptions, { user: "root" }); + assert.equal(fake.files.get(stage), "tok"); + assert.equal(fake.files.has("/run/codevil/ws-token"), false); + assert.equal(fake.log[writeIndex - 1][1], "mkdir -p -m 700 /run/.codevil-stage && chmod 700 /run/.codevil-stage && chown 0:0 /run/.codevil-stage"); + assert.equal( + fake.log[writeIndex + 1][1], + `chmod 600 '${stage}' && chown 10001:10001 '${stage}' && mkdir -p '/run/codevil' && mv -fT '${stage}' '/run/codevil/ws-token'`, + ); }); -test("writeFile throws when permissions cannot be set", async () => { - const { p } = provider({}, { run: async () => ({ stdout: "", stderr: "denied", exitCode: 1 }) }); +test("writeFile quotes hostile paths and skips chmod/chown when not requested", async () => { + const { p, fake } = provider(); const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); - await assert.rejects(handle.writeFile("/x", "y", { mode: 0o600 }), /Failed to set permissions on \/x/); + await handle.writeFile("/run/it's/x y", "tok"); + const final = fake.log.at(-1)[1]; + assert.match(final, /^mkdir -p '\/run\/it'\\''s' && mv -fT '\/run\/\.codevil-stage\/[0-9a-f-]{36}' '\/run\/it'\\''s\/x y'$/); +}); + +test("writeFile throws on any failing step and removes the staged file", async () => { + const commands = []; + const { p } = provider({}, { + run: async (cmd) => { + commands.push(cmd); + return cmd.includes("mv -fT") ? { stdout: "", stderr: "denied", exitCode: 1 } : { stdout: "", stderr: "", exitCode: 0 }; + }, + }); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await assert.rejects(handle.writeFile("/x", "y", { mode: 0o600 }), /Failed to write \/x \(exit 1\): denied/); + assert.match(commands.at(-1), /^rm -f '\/run\/\.codevil-stage\//); + const prep = provider({}, { run: async () => ({ stdout: "", stderr: "ro fs", exitCode: 1 }) }); + const h2 = await prep.p.connect({ provider: "e2b", id: "sbx_1" }); + await assert.rejects(h2.writeFile("/x", "y"), /Failed to prepare staging for \/x \(exit 1\): ro fs/); + assert.equal(prep.fake.files.size, 0); +}); + +test("exec, writeFile and startProcess map not-found to SandboxNotFoundError", async () => { + const gone = () => { throw new NotFound("gone"); }; + const viaRun = provider({}, { run: gone }); + const h1 = await viaRun.p.connect({ provider: "e2b", id: "sbx_1" }); + await assert.rejects(h1.exec("echo"), SandboxNotFoundError); + + const viaWrite = provider(); + const h2 = await viaWrite.p.connect({ provider: "e2b", id: "sbx_1" }); + viaWrite.fake.sandbox.files.write = gone; + await assert.rejects(h2.writeFile("/run/x", "y"), SandboxNotFoundError); + + const viaBackground = provider({}, { + run: async (cmd, opts) => { if (opts?.background) gone(); return { stdout: "", stderr: "", exitCode: 0 }; }, + }); + const h3 = await viaBackground.p.connect({ provider: "e2b", id: "sbx_1" }); + await assert.rejects(h3.startProcess("x", { processId: "p", cwd: "/", env: {} }), SandboxNotFoundError); +}); + +test("E2B_MAX_SANDBOX_SECONDS is clamped to a positive whole number", () => { + const ms = (value) => e2bMaxLeaseMs({ E2B_MAX_SANDBOX_SECONDS: value }); + assert.equal(ms(undefined), 3_600_000); + assert.equal(ms(""), 3_600_000); + assert.equal(ms("abc"), 3_600_000); + assert.equal(ms("Infinity"), 3_600_000); + assert.equal(ms("0"), 3_600_000); + assert.equal(ms("-5"), 3_600_000); + assert.equal(ms("0.5"), 3_600_000); + assert.equal(ms("90.9"), 90_000); + assert.equal(ms("1800"), 1_800_000); }); test("exec maps users and options, and turns a command exit error into a result", async () => { From 83e11eb3929dedc925c5dfafcb22907685e35816 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 07:46:51 +0530 Subject: [PATCH 17/32] fix(sandbox): tolerate E2B stream disconnect errors and clean staged files on transport failure --- packages/worker/src/sandbox-provider/e2b.ts | 34 +++++++++++-------- .../worker/test/sandbox-provider-e2b.test.mjs | 24 +++++++++++++ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/worker/src/sandbox-provider/e2b.ts b/packages/worker/src/sandbox-provider/e2b.ts index 51716ca..a416b7c 100644 --- a/packages/worker/src/sandbox-provider/e2b.ts +++ b/packages/worker/src/sandbox-provider/e2b.ts @@ -156,19 +156,21 @@ function e2bHandle(context: { ); assertOk(prepare, `Failed to prepare staging for ${path}`); try { - await sandbox.files.write(stage, content, { user: "root" }); + try { + await sandbox.files.write(stage, content, { user: "root" }); + } catch (error) { + throw mapNotFound(sdk, error); + } + const steps: string[] = []; + if (fileOptions?.mode !== undefined) steps.push(`chmod ${fileOptions.mode.toString(8)} ${shellQuote(stage)}`); + if (fileOptions?.owner !== undefined) steps.push(`chown ${OWNER_IDS[fileOptions.owner]} ${shellQuote(stage)}`); + steps.push(`mkdir -p ${shellQuote(parentDirectory(path))}`); + steps.push(`mv -fT ${shellQuote(stage)} ${shellQuote(path)}`); + assertOk(await exec(steps.join(" && ")), `Failed to write ${path}`); } catch (error) { - throw mapNotFound(sdk, error); - } - const steps: string[] = []; - if (fileOptions?.mode !== undefined) steps.push(`chmod ${fileOptions.mode.toString(8)} ${shellQuote(stage)}`); - if (fileOptions?.owner !== undefined) steps.push(`chown ${OWNER_IDS[fileOptions.owner]} ${shellQuote(stage)}`); - steps.push(`mkdir -p ${shellQuote(parentDirectory(path))}`); - steps.push(`mv -fT ${shellQuote(stage)} ${shellQuote(path)}`); - const result = await exec(steps.join(" && ")); - if (result.exitCode !== 0) { + // Non-zero exit or transport failure: never leave staged content behind. await exec(`rm -f ${shellQuote(stage)}`).catch(() => undefined); - throw new Error(`Failed to write ${path} (exit ${result.exitCode}): ${result.stderr}`); + throw error; } }, async startProcess(command, { processId, cwd, env }) { @@ -178,14 +180,16 @@ function e2bHandle(context: { throw new Error(`Failed to create ${LOG_DIR} (exit ${mkdir.exitCode}): ${mkdir.stderr}`); } const redirected = `sh -c ${shellQuote(command)} >${LOG_DIR}/${processId}.out 2>${LOG_DIR}/${processId}.err`; + let handle: { disconnect(): Promise }; try { - const handle = await sandbox.commands.run(redirected, { background: true, user: "root", cwd, envs: env }); - // The process keeps running; this only closes the SDK's event stream, - // which would otherwise keep the Durable Object resident. - await handle.disconnect(); + handle = await sandbox.commands.run(redirected, { background: true, user: "root", cwd, envs: env }); } catch (error) { throw mapNotFound(sdk, error); } + // Closes the SDK's event stream so the Durable Object is not kept resident. + // The process is already running, so a failure here must not fail + // startProcess: a retry would start a duplicate agent. + await handle.disconnect().catch(() => undefined); }, async readProcessLogs(processId) { assertProcessId(processId); diff --git a/packages/worker/test/sandbox-provider-e2b.test.mjs b/packages/worker/test/sandbox-provider-e2b.test.mjs index de03666..c33d232 100644 --- a/packages/worker/test/sandbox-provider-e2b.test.mjs +++ b/packages/worker/test/sandbox-provider-e2b.test.mjs @@ -186,6 +186,30 @@ test("writeFile stages as root, permissions the staged file, then renames over t ); }); +test("writeFile removes the staged file when a step throws instead of exiting non-zero", async () => { + const commands = []; + const { p } = provider({}, { + run: async (cmd) => { + commands.push(cmd); + if (cmd.includes("mv -fT")) throw new Error("transport reset"); + return { stdout: "", stderr: "", exitCode: 0 }; + }, + }); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await assert.rejects(handle.writeFile("/run/x", "y", { mode: 0o600 }), /transport reset/); + assert.match(commands.at(-1), /^rm -f '\/run\/\.codevil-stage\/[0-9a-f-]{36}'$/); +}); + +test("startProcess succeeds when disconnecting the command stream fails", async () => { + const { p } = provider({}, { + run: async (cmd, opts) => opts?.background + ? { disconnect: async () => { throw new Error("stream already closed"); } } + : { stdout: "", stderr: "", exitCode: 0 }, + }); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await handle.startProcess("x", { processId: "p", cwd: "/", env: {} }); +}); + test("writeFile quotes hostile paths and skips chmod/chown when not requested", async () => { const { p, fake } = provider(); const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); From ba9d3f353e3bd38d0981cf33fc11a368e03b0fff Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 07:56:16 +0530 Subject: [PATCH 18/32] feat(sandbox): pause idle E2B sandboxes and resume on demand --- packages/worker/src/orchestrator.ts | 77 ++- .../src/orchestrator/agent-run-coordinator.ts | 2 + .../worker/src/orchestrator/cli-handlers.ts | 10 +- packages/worker/src/orchestrator/host.ts | 10 + packages/worker/src/orchestrator/preview.ts | 11 + .../src/orchestrator/sandbox-handlers.ts | 14 +- .../worker/src/orchestrator/sandbox-resume.ts | 409 ++++++++++++ packages/worker/src/sandbox-connection.ts | 14 + packages/worker/src/sandbox-provider/index.ts | 8 + packages/worker/test/helpers/fake-host.mjs | 91 ++- .../worker/test/sandbox-handlers.test.mjs | 9 +- packages/worker/test/sandbox-resume.test.mjs | 629 ++++++++++++++++++ 12 files changed, 1252 insertions(+), 32 deletions(-) create mode 100644 packages/worker/src/orchestrator/sandbox-resume.ts create mode 100644 packages/worker/test/sandbox-resume.test.mjs diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index d06babb..4e567a8 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -24,7 +24,6 @@ import { buildSandboxDisconnectLogPayload } from "./sandbox.js"; import { collectAgentDiagnostics, connectSandboxHandle, - destroySandbox, loadStoredSandboxSecret, readRedactedAgentLogs, SANDBOX_SECRET_KEY, @@ -40,6 +39,7 @@ import { SANDBOX_RECONNECT_GRACE_MS, sandboxConnectionMode, completeSandboxReconnect, + isUnexpectedSandboxDisconnect, sandboxReconnectExpired, } from "./sandbox-connection.js"; import { redactEvent } from "./redaction.js"; @@ -117,8 +117,19 @@ import { drainQueuedAgentWorkIfReady, dispatchSandboxSocketMessage, initializeSandboxConnection, + issueSandboxWebSocketCapability, provisionSessionSandbox, } from "./orchestrator/sandbox-handlers.js"; +import { + clearStalePausedMarker, + expireSessionAtMaxTime, + pauseIdleSandbox, + prepareAuthenticatedPreview, + renewSandboxLeaseIfDue, + resumeSandbox, + sandboxAlarmDeadlines, + terminateSandbox as terminateSandboxFn, +} from "./orchestrator/sandbox-resume.js"; import { nextSessionDirectoryTimestamp, runSessionDirectoryUpdateWithRetry, @@ -142,6 +153,8 @@ import { armNextAlarm as armNextAlarmAt } from "./orchestrator/alarm.js"; export type { InitOptions } from "./orchestrator/types.js"; +const ACTIVITY_WRITE_INTERVAL_MS = 1_000; + export class Orchestrator extends DurableObject implements OrchestratorHost { readonly ctx: DurableObjectState<{}>; sql: SqlStorage; @@ -292,18 +305,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost } const createdAt = Date.parse(this.meta.created_at); - const maxTimeMs = parseMaxTimeMs(this.meta.max_time); - if (maxTimeMs !== null && now >= createdAt + maxTimeMs) { - const activeRunId = this.meta.active_run?.id; - this.transition("timed_out"); - if (activeRunId) { - this.cancelOpenQuestions(activeRunId, "session timed out"); - } - this.appendAndBroadcast({ - type: "error", - message: `Session timed out after ${this.meta.max_time}.`, - }); - await this.terminateSandbox("timed out"); + if (await expireSessionAtMaxTime(this, now)) { await this.armNextAlarm(Date.now() - 1); return; } @@ -340,6 +342,10 @@ export class Orchestrator extends DurableObject implements OrchestratorHost return; } + clearStalePausedMarker(this); + await renewSandboxLeaseIfDue(this, now); + await pauseIdleSandbox(this, now); + drainQueuedAgentWorkIfReady(this); await this.armNextAlarm(now); } @@ -566,10 +572,11 @@ export class Orchestrator extends DurableObject implements OrchestratorHost }); if (isSandbox && this.meta) { - if ( - !this.meta.expected_close - && !isTerminalState(this.meta.state) - ) { + if (isUnexpectedSandboxDisconnect({ + expectedClose: this.meta.expected_close, + state: this.meta.state, + otherSandboxSocketsAttached: this.ctx.getWebSockets("sandbox").some((socket) => socket !== ws), + })) { const state = this.meta.state; if (!this.meta.sandbox_disconnected_at) { this.meta.sandbox_disconnected_at = new Date().toISOString(); @@ -768,16 +775,32 @@ export class Orchestrator extends DurableObject implements OrchestratorHost return sandboxDiagnosticsResponse(this.sandboxResolver, this.redactionSecrets); } - private async terminateSandbox(reason: string): Promise { + async terminateSandbox(reason: string): Promise { + await terminateSandboxFn(this, reason); + } + + recordActivity(): void { if (!this.meta) return; - this.meta.expected_close = true; + const lastMs = this.meta.last_activity_at ? Date.parse(this.meta.last_activity_at) : Number.NaN; + const nowMs = Date.now(); + // Preview traffic can arrive many times a second; the idle clock only needs second resolution. + if (Number.isFinite(lastMs) && nowMs - lastMs < ACTIVITY_WRITE_INTERVAL_MS) return; + this.meta.last_activity_at = new Date(nowMs).toISOString(); this.saveMeta(); - await destroySandbox(this.sandboxResolver, reason, (error) => { - this.getTracer()?.log("ERROR", "sandbox.stop.failed", { + // The idle-pause deadline moved; re-arm so the alarm tracks it (only providers that pause have one). + if (sandboxAlarmDeadlines(this).idlePauseAt !== null) void this.armNextAlarmSafe(); + } + + requestSandboxResume(): void { + this.ctx.waitUntil(resumeSandbox(this).catch((error) => { + this.getTracer()?.log("ERROR", "sandbox.resume.request_failed", { ...redactEvent(safeExceptionAttributes(error), this.redactionSecrets), }); - }); - this.closeSandboxSockets(reason); + })); + } + + issueSandboxWebSocketToken(): Promise { + return issueSandboxWebSocketCapability(this); } // --- OrchestratorHost delegation --- @@ -1035,6 +1058,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost sandboxDisconnectedAt: this.meta.sandbox_disconnected_at, presentationRetryAt: this.liveRunCards.nextRetryAt(), workspaceCacheRetryAt: nextWorkspaceCacheJobAt(this.sql), + ...sandboxAlarmDeadlines(this), }, (deadline) => this.ctx.storage.setAlarm(deadline)); } @@ -1046,7 +1070,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost }); } - private closeSandboxSockets(reason: string): void { + closeSandboxSockets(reason: string): void { for (const sandbox of this.ctx.getWebSockets("sandbox")) { sandbox.close(1000, reason); } @@ -1115,7 +1139,10 @@ export class Orchestrator extends DurableObject implements OrchestratorHost return new Response("Preview is not active.", { status: 404 }); } - return proxyPreviewRequest(request, this.meta, token, () => this.sandboxHandle()); + return proxyPreviewRequest(request, this.meta, token, () => this.sandboxHandle(), { + // Resume and activity are earned only by a request that passed the token check. + beforeProxy: () => prepareAuthenticatedPreview(this), + }); } submitAgentRequest(args: { diff --git a/packages/worker/src/orchestrator/agent-run-coordinator.ts b/packages/worker/src/orchestrator/agent-run-coordinator.ts index 362880e..ed48e24 100644 --- a/packages/worker/src/orchestrator/agent-run-coordinator.ts +++ b/packages/worker/src/orchestrator/agent-run-coordinator.ts @@ -123,6 +123,8 @@ export function finishRunAndDrainQueue(host: OrchestratorHost, finalState: Agent if (next.started) { startAgentRun(host, next.started); } + // A finished run restarts the idle-pause clock (after any queued run started, so the alarm sees the final state). + host.recordActivity(); } export function failActiveRunAndReturnReady(host: OrchestratorHost, message: string): void { diff --git a/packages/worker/src/orchestrator/cli-handlers.ts b/packages/worker/src/orchestrator/cli-handlers.ts index f49895d..eea80d6 100644 --- a/packages/worker/src/orchestrator/cli-handlers.ts +++ b/packages/worker/src/orchestrator/cli-handlers.ts @@ -35,6 +35,7 @@ import { ensureAnnotatableRevision, lockPlanRevision, } from "./plan-revision-actions.js"; +import { sandboxSocketAttached } from "./sandbox-resume.js"; export function handleApprove( host: OrchestratorHost, @@ -99,7 +100,14 @@ export function handleAgentRequest( const trimmed = text.trim(); if (!trimmed) return; - const sandboxConnected = host.ctx.getWebSockets("sandbox").length > 0; + host.recordActivity(); + if (host.meta.sandbox_paused_at) { + // Sockets are closed while paused, so the run queues below and drains + // when the resumed agent reconnects. + host.requestSandboxResume(); + } + + const sandboxConnected = sandboxSocketAttached(host); if ( host.meta.state === "ready" && !host.meta.active_run diff --git a/packages/worker/src/orchestrator/host.ts b/packages/worker/src/orchestrator/host.ts index e8fef67..7f54e8b 100644 --- a/packages/worker/src/orchestrator/host.ts +++ b/packages/worker/src/orchestrator/host.ts @@ -51,6 +51,16 @@ export interface OrchestratorHost { ): { type: "error"; message: string; actor?: string }; armNextAlarm(now?: number): Promise; sandboxProvider(): SandboxProvider; + /** Records user-visible activity (Agent Request, run finish, authenticated preview) for the idle-pause clock. */ + recordActivity(): void; + /** Starts a background resume of a paused sandbox; concurrent calls share one resume. */ + requestSandboxResume(): void; + /** Closes the DO's own sandbox sockets (callers set `expected_close` first when the close is intentional). */ + closeSandboxSockets(reason: string): void; + /** Fresh sandbox WebSocket capability, for the token file written on resume. */ + issueSandboxWebSocketToken(): Promise; + /** Destroys the provider sandbox (running or paused) and closes its sockets. Failures are logged, never thrown. */ + terminateSandbox(reason: string): Promise; /** Null when the Session has no sandbox yet (`meta.sandbox_ref` unset). */ sandboxHandle(): Promise; } diff --git a/packages/worker/src/orchestrator/preview.ts b/packages/worker/src/orchestrator/preview.ts index 43a155a..28d1b61 100644 --- a/packages/worker/src/orchestrator/preview.ts +++ b/packages/worker/src/orchestrator/preview.ts @@ -63,6 +63,14 @@ export async function proxyPreviewRequest( token: string, /** Resolved only after access and token checks pass, so unauthenticated requests never reach the sandbox. */ resolveHandle: () => Promise, + hooks: { + /** + * Runs only after access and token checks pass (so only an authenticated + * request can resume the sandbox or count as activity). A returned + * Response short-circuits the proxy. + */ + beforeProxy?: () => Promise; + } = {}, ): Promise { const blocked = validatePreviewAccess(meta); if (blocked) return blocked; @@ -72,6 +80,9 @@ export async function proxyPreviewRequest( return new Response("Unknown preview token.", { status: 404 }); } + const intercepted = await hooks.beforeProxy?.(); + if (intercepted) return intercepted; + const handle = await resolveHandle(); if (!handle) return new Response("Preview is not active.", { status: 404 }); diff --git a/packages/worker/src/orchestrator/sandbox-handlers.ts b/packages/worker/src/orchestrator/sandbox-handlers.ts index d103c0f..709c96a 100644 --- a/packages/worker/src/orchestrator/sandbox-handlers.ts +++ b/packages/worker/src/orchestrator/sandbox-handlers.ts @@ -25,6 +25,8 @@ import { getProvisioningCredentialContext, requireProviderPublicConfig } from ". import { createSandboxGitProxyToken, createSandboxProxyToken } from "../sandbox-proxy.js"; import { createSandboxWebSocketToken } from "../sandbox-ws-token.js"; import { traceSandboxProvisioning } from "./provisioning.js"; +import { sandboxProviderMaxLeaseMs } from "../sandbox-provider/index.js"; +import { sandboxLeaseMs } from "./sandbox-lifecycle.js"; import { registerSandboxSecret, SANDBOX_SECRET_KEY } from "./sandbox-access.js"; import { buildPreviewUrl, @@ -39,6 +41,7 @@ import { finishRunAndDrainQueue, setActiveRunState, } from "./agent-run-coordinator.js"; +import { sandboxSocketAttached } from "./sandbox-resume.js"; import { freezePlanRevision } from "./plan-revision-actions.js"; import { cancelOpenQuestions, @@ -75,9 +78,15 @@ export async function provisionSessionSandbox(host: OrchestratorHost): Promise 0 - && host.ctx.getWebSockets("sandbox").length > 0 + && sandboxSocketAttached(host) ) { finishRunAndDrainQueue(host, "completed"); } diff --git a/packages/worker/src/orchestrator/sandbox-resume.ts b/packages/worker/src/orchestrator/sandbox-resume.ts new file mode 100644 index 0000000..216384b --- /dev/null +++ b/packages/worker/src/orchestrator/sandbox-resume.ts @@ -0,0 +1,409 @@ +import { isTerminalState, safeExceptionAttributes } from "@codevil/shared"; +import { redactEvent } from "../redaction.js"; +import { SandboxNotFoundError } from "../sandbox-provider/types.js"; +import { sandboxProviderMaxLeaseMs } from "../sandbox-provider/index.js"; +import type { OrchestratorHost } from "./host.js"; +import { validatePreviewAccess } from "./preview.js"; +import { listOpenQuestionIds } from "./questions-store.js"; +import { + destroySandbox, + registerSandboxSecret, + sandboxRefForMeta, + SANDBOX_SECRET_KEY, +} from "./sandbox-access.js"; +import { + idlePauseDeadline, + leaseRenewDeadline, + sandboxLeaseMs, + shouldPauseSandbox, + type IdlePauseInput, +} from "./sandbox-lifecycle.js"; +import { parseMaxTimeMs } from "./session-guards.js"; + +export const PREVIEW_RESUME_TIMEOUT_MS = 15_000; +export const RESUME_ATTEMPTS = 3; +/** Delay before each resume attempt (the first runs immediately). */ +const RESUME_RETRY_DELAYS_MS: readonly number[] = [0, 250, 750]; +/** Path the sandbox agent rereads before each (re)connection attempt. */ +const SANDBOX_WS_TOKEN_FILE = "/run/codevil/ws-token"; + +const pausing = new WeakMap>(); +const resuming = new WeakMap>(); + +/** True while a pause is in flight: the sandbox may freeze at any moment, so it must not receive work. */ +export function isSandboxPausing(host: OrchestratorHost): boolean { + return pausing.has(host); +} + +/** A sandbox socket is attached and the sandbox is not about to be paused. */ +export function sandboxSocketAttached(host: OrchestratorHost): boolean { + return host.ctx.getWebSockets("sandbox").length > 0 && !pausing.has(host); +} + +function logFailure(host: OrchestratorHost, event: string, error: unknown, extra: Record = {}): void { + host.getTracer()?.log("ERROR", event, { + ...extra, + ...redactEvent(safeExceptionAttributes(error), host.redactionSecrets), + }); +} + +function providerSupportsPause(host: OrchestratorHost): boolean { + try { + return host.sandboxProvider().capabilities.pauseResume; + } catch { + return false; + } +} + +function idlePauseInput(host: OrchestratorHost, now: number): IdlePauseInput | null { + const meta = host.meta; + if (!meta) return null; + const activeRunId = meta.active_run?.id; + return { + now, + pauseSupported: providerSupportsPause(host), + sessionState: meta.state, + paused: Boolean(meta.sandbox_paused_at), + sandboxConnected: sandboxSocketAttached(host), + ...(meta.sandbox_disconnected_at ? { disconnectedAt: meta.sandbox_disconnected_at } : {}), + hasActiveRun: Boolean(meta.active_run), + queuedRuns: meta.queued_runs.length, + openQuestions: activeRunId ? listOpenQuestionIds(host.sql, activeRunId).length : 0, + lastActivityAt: meta.last_activity_at ?? meta.created_at, + maxIdleMs: parseMaxTimeMs(meta.max_idle_time ?? "10m"), + }; +} + +/** + * Deadlines the alarm must wake for. Null means "not applicable right now"; + * Cloudflare Sessions (no pause support) always get nulls. + */ +export function sandboxAlarmDeadlines(host: OrchestratorHost): { + idlePauseAt: number | null; + leaseRenewAt: number | null; +} { + const meta = host.meta; + if (!meta || isTerminalState(meta.state) || meta.sandbox_paused_at || !providerSupportsPause(host)) { + return { idlePauseAt: null, leaseRenewAt: null }; + } + + // Every pause condition except the clock: an infinite `now` passes the time check. + const input = idlePauseInput(host, Number.POSITIVE_INFINITY); + const idlePauseAt = input && shouldPauseSandbox(input) ? idlePauseDeadline(input) : null; + const leaseRenewAt = meta.sandbox_ref + ? leaseRenewDeadline({ renewedAt: meta.sandbox_lease_renewed_at, createdAt: meta.created_at }) + : null; + return { idlePauseAt, leaseRenewAt }; +} + +/** Pauses the sandbox when the Session has been idle long enough. Resolves true only if it paused. */ +export function pauseIdleSandbox(host: OrchestratorHost, now: number): Promise { + if (pausing.has(host)) return Promise.resolve(false); + const input = idlePauseInput(host, now); + if (!input || !shouldPauseSandbox(input)) return Promise.resolve(false); + + // `pausing` is set synchronously (before any await) so requests arriving + // while the sandbox is being paused queue instead of reaching a freezing agent. + const run = doPause(host, now).finally(() => pausing.delete(host)); + pausing.set(host, run); + return run; +} + +async function doPause(host: OrchestratorHost, now: number): Promise { + const meta = host.meta!; + meta.expected_close = true; + host.saveMeta(); + + try { + const handle = await host.sandboxHandle(); + if (!handle?.pause) throw new Error("Sandbox provider cannot pause"); + await handle.pause(); + } catch (error) { + if (error instanceof SandboxNotFoundError) { + await failLostSandbox(host); + return false; + } + // A terminal session already owns expected_close (teardown set it). + if (!isTerminalState(meta.state)) { + meta.expected_close = false; + host.saveMeta(); + } + logFailure(host, "sandbox.pause.failed", error); + return false; + } + + // The session may have ended while the provider call was in flight. + if (isTerminalState(meta.state)) return false; + + meta.sandbox_paused_at = new Date(now).toISOString(); + host.saveMeta(); + host.closeSandboxSockets("sandbox paused"); + host.updateDirectory({ sandbox_state: "paused" }); + host.appendAndBroadcast({ type: "status", message: "Sandbox paused (idle)." }); + // A request that queued while the pause was in flight is still waiting. + if (meta.queued_runs.length > 0) host.requestSandboxResume(); + return true; +} + +/** Resumes a paused sandbox. Concurrent callers share one in-flight resume. */ +export function resumeSandbox( + host: OrchestratorHost, + options: { retryDelaysMs?: readonly number[] } = {}, +): Promise { + const inflight = resuming.get(host); + if (inflight) return inflight; + if (!host.meta?.sandbox_paused_at && !pausing.has(host)) return Promise.resolve(); + + const run = doResume(host, options.retryDelaysMs ?? RESUME_RETRY_DELAYS_MS).finally(() => resuming.delete(host)); + resuming.set(host, run); + return run; +} + +async function doResume(host: OrchestratorHost, retryDelaysMs: readonly number[]): Promise { + // A pause in flight finishes first; resuming a half-paused sandbox would race it. + await pausing.get(host)?.catch(() => false); + const meta = host.meta; + if (!meta?.sandbox_paused_at || isTerminalState(meta.state)) return; + + const provider = host.sandboxProvider(); + const ref = sandboxRefForMeta(meta, provider); + if (!ref) { + await failLostSandbox(host); + return; + } + + for (let attempt = 0; attempt < RESUME_ATTEMPTS; attempt++) { + const delayMs = retryDelaysMs[attempt] ?? 0; + if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs)); + // Teardown or another resume may have settled the Session while we waited. + if (!meta.sandbox_paused_at || isTerminalState(meta.state)) return; + + try { + const now = Date.now(); + const secret = await host.ctx.storage.get(SANDBOX_SECRET_KEY); + const handle = await provider.connect(ref, { + leaseMs: sandboxLeaseMs({ + now, + createdAt: Date.parse(meta.created_at), + maxTimeMs: parseMaxTimeMs(meta.max_time), + providerMaxMs: sandboxProviderMaxLeaseMs(host.workerEnv, provider.name), + }), + ...(secret ? { secret } : {}), + }); + registerSandboxSecret(host.redactionSecrets, handle.secret); + // The agent adopts a changed token file on its next reconnect; the + // in-memory token it holds may have expired while paused. + await handle.writeFile(SANDBOX_WS_TOKEN_FILE, await host.issueSandboxWebSocketToken(), { + mode: 0o600, + owner: "codevil", + }); + await completeResume(host, now); + return; + } catch (error) { + if (error instanceof SandboxNotFoundError) { + await failLostSandbox(host); + return; + } + logFailure(host, "sandbox.resume.failed", error, { attempt: attempt + 1 }); + } + } + + await failSandboxResume(host); +} + +async function completeResume(host: OrchestratorHost, now: number): Promise { + const meta = host.meta!; + // The session may have ended (and the sandbox been destroyed) mid-resume. + if (!meta.sandbox_paused_at || isTerminalState(meta.state)) return; + + meta.sandbox_paused_at = undefined; + meta.expected_close = false; + meta.sandbox_lease_renewed_at = new Date(now).toISOString(); + // The agent reconnects on its own; start the reconnect grace so a resumed + // sandbox whose agent never returns fails instead of stranding queued runs. + // Skip when the agent already reconnected while the resume was in flight. + if (host.ctx.getWebSockets("sandbox").length === 0) { + meta.sandbox_disconnected_at = new Date().toISOString(); + } + host.saveMeta(); + host.updateDirectory({ sandbox_state: meta.state === "cloning_repo" ? "cloning" : "ready" }); + host.appendAndBroadcast({ type: "status", message: "Sandbox resumed." }); + await armAlarmSafely(host); +} + +/** + * A paused marker with an attached sandbox socket and no resume in flight is + * stale (e.g. the Durable Object restarted mid-resume): a paused agent cannot + * hold a socket. Clear it so lease renewal and idle pause work again. + */ +export function clearStalePausedMarker(host: OrchestratorHost): boolean { + const meta = host.meta; + if (!meta?.sandbox_paused_at || resuming.has(host) || !sandboxSocketAttached(host)) return false; + meta.sandbox_paused_at = undefined; + meta.expected_close = false; + host.saveMeta(); + return true; +} + +async function armAlarmSafely(host: OrchestratorHost, now?: number): Promise { + try { + await host.armNextAlarm(now); + } catch (error) { + logFailure(host, "alarm.arm.failed", error); + } +} + +/** Extends the provider lease on schedule while the sandbox is running. */ +export async function renewSandboxLeaseIfDue(host: OrchestratorHost, now: number): Promise { + const meta = host.meta; + if ( + !meta + || !meta.sandbox_ref + || meta.sandbox_paused_at + || isTerminalState(meta.state) + || pausing.has(host) + || !providerSupportsPause(host) + || now < leaseRenewDeadline({ renewedAt: meta.sandbox_lease_renewed_at, createdAt: meta.created_at }) + ) { + return; + } + + try { + const handle = await host.sandboxHandle(); + if (!handle) return; + await handle.renewLease(sandboxLeaseMs({ + now, + createdAt: Date.parse(meta.created_at), + maxTimeMs: parseMaxTimeMs(meta.max_time), + providerMaxMs: sandboxProviderMaxLeaseMs(host.workerEnv, host.sandboxProvider().name), + })); + if (isTerminalState(meta.state)) return; + meta.sandbox_lease_renewed_at = new Date(now).toISOString(); + host.saveMeta(); + } catch (error) { + if (error instanceof SandboxNotFoundError) { + await failLostSandbox(host); + return; + } + // The alarm retries a past-due renewal after a bounded delay. + logFailure(host, "sandbox.lease_renew.failed", error); + } +} + +/** Fails every run the Session still owns (active and queued) with one message. */ +function failOutstandingRuns(host: OrchestratorHost, message: string, questionReason: string): string | undefined { + const meta = host.meta!; + const activeRunId = meta.active_run?.id; + if (meta.active_run) { + meta.active_run = { ...meta.active_run, state: "failed" }; + host.cancelOpenQuestions(meta.active_run.id, questionReason); + host.appendAndBroadcast({ type: "agent_run_failed", run_id: meta.active_run.id, message }); + } + for (const run of meta.queued_runs) { + host.appendAndBroadcast({ type: "agent_run_failed", run_id: run.id, message }); + } + meta.queued_runs = []; + host.saveMeta(); + return activeRunId; +} + +async function failSession( + host: OrchestratorHost, + message: string, + questionReason: string, +): Promise { + const meta = host.meta; + if (!meta || isTerminalState(meta.state)) return false; + if (!host.transition("failed")) return false; + meta.sandbox_paused_at = undefined; + const activeRunId = failOutstandingRuns(host, message, questionReason); + host.appendAndBroadcast({ type: "error", message }); + host.updateDirectory({ + room_state: "failed", + sandbox_state: "failed", + active_run_state: activeRunId ? "failed" : null, + }); + return true; +} + +/** The provider no longer has the sandbox. Never recreate silently. */ +export async function failLostSandbox(host: OrchestratorHost): Promise { + if (!(await failSession(host, "Sandbox expired.", "sandbox expired"))) return; + await armAlarmSafely(host, Date.now() - 1); +} + +async function failSandboxResume(host: OrchestratorHost): Promise { + if (!(await failSession(host, "Sandbox failed to resume.", "sandbox failed to resume"))) return; + await host.terminateSandbox("resume failed"); + await armAlarmSafely(host, Date.now() - 1); +} + +/** + * Destroys the Session's sandbox whether it is running or paused (a paused + * E2B sandbox never expires, so this is its only cleanup). + */ +export async function terminateSandbox(host: OrchestratorHost, reason: string): Promise { + if (!host.meta) return; + host.meta.expected_close = true; + host.meta.sandbox_paused_at = undefined; + host.saveMeta(); + await destroySandbox(() => host.sandboxHandle(), reason, (error) => { + logFailure(host, "sandbox.stop.failed", error); + }); + host.closeSandboxSockets(reason); +} + +/** Alarm branch: end the Session at `max_time`, destroying its sandbox even when paused. */ +export async function expireSessionAtMaxTime(host: OrchestratorHost, now: number): Promise { + const meta = host.meta; + if (!meta) return false; + const maxTimeMs = parseMaxTimeMs(meta.max_time); + if (maxTimeMs === null || now < Date.parse(meta.created_at) + maxTimeMs) return false; + + const activeRunId = meta.active_run?.id; + host.transition("timed_out"); + if (activeRunId) host.cancelOpenQuestions(activeRunId, "session timed out"); + host.appendAndBroadcast({ type: "error", message: `Session timed out after ${meta.max_time}.` }); + await host.terminateSandbox("timed out"); + return true; +} + +/** + * Runs only for an authenticated preview request: counts it as activity and + * resumes a paused sandbox, answering 503 if that takes too long. + */ +export async function prepareAuthenticatedPreview( + host: OrchestratorHost, + timeoutMs: number = PREVIEW_RESUME_TIMEOUT_MS, +): Promise { + host.recordActivity(); + if (!host.meta?.sandbox_paused_at && !pausing.has(host)) return null; + + const resume = resumeSandbox(host); + // Keep resuming after a timed-out request has been answered. + host.ctx.waitUntil(resume.catch(() => undefined)); + + let timer: ReturnType | null = null; + const timedOut = new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), timeoutMs); + }); + try { + const outcome = await Promise.race([resume.then(() => "resumed" as const), timedOut]); + if (outcome === "timeout") return resumingResponse(); + } catch (error) { + logFailure(host, "sandbox.resume.preview_failed", error); + return resumingResponse(); + } finally { + clearTimeout(timer); + } + + // Resume may have failed the Session; re-check before proxying. + return host.meta ? validatePreviewAccess(host.meta) : null; +} + +function resumingResponse(): Response { + return new Response("Sandbox is resuming. Retry shortly.", { + status: 503, + headers: { "Retry-After": "2" }, + }); +} diff --git a/packages/worker/src/sandbox-connection.ts b/packages/worker/src/sandbox-connection.ts index 4894fbd..9ac65cb 100644 --- a/packages/worker/src/sandbox-connection.ts +++ b/packages/worker/src/sandbox-connection.ts @@ -19,6 +19,20 @@ export function sandboxConnectionMode( return "reject"; } +/** + * Whether a closed sandbox socket means the agent dropped. A close the DO + * initiated (pause, teardown) is expected, and a close that arrives after the + * agent already reconnected on a newer socket (a paused agent only answers the + * close handshake once resumed) must not mark the live connection interrupted. + */ +export function isUnexpectedSandboxDisconnect(input: { + expectedClose?: boolean; + state: SessionState; + otherSandboxSocketsAttached: boolean; +}): boolean { + return !input.expectedClose && !isTerminalState(input.state) && !input.otherSandboxSocketsAttached; +} + export function sandboxReconnectExpired(disconnectedAt: string, now: number): boolean { return now >= Date.parse(disconnectedAt) + SANDBOX_RECONNECT_GRACE_MS; } diff --git a/packages/worker/src/sandbox-provider/index.ts b/packages/worker/src/sandbox-provider/index.ts index dedec41..1ef1ea7 100644 --- a/packages/worker/src/sandbox-provider/index.ts +++ b/packages/worker/src/sandbox-provider/index.ts @@ -31,6 +31,14 @@ export function e2bMaxLeaseMs(env: Pick 0 ? seconds : DEFAULT_E2B_MAX_SANDBOX_SECONDS) * 1000; } +/** Longest lease the Orchestrator may request from a provider. Cloudflare leases are keepalive-based, so its cap is nominal. */ +export function sandboxProviderMaxLeaseMs( + env: Pick, + providerName: SandboxProviderName, +): number { + return providerName === "e2b" ? e2bMaxLeaseMs(env) : 3_600_000; +} + export function configuredSandboxProviderName(env: Pick): SandboxProviderName { const raw = env.SANDBOX_PROVIDER?.trim(); if (!raw) return "e2b"; diff --git a/packages/worker/test/helpers/fake-host.mjs b/packages/worker/test/helpers/fake-host.mjs index b5ffbea..c8dabe7 100644 --- a/packages/worker/test/helpers/fake-host.mjs +++ b/packages/worker/test/helpers/fake-host.mjs @@ -1,5 +1,6 @@ import { isValidTransition } from "../../../shared/dist/index.js"; import { createAgentRun } from "../../dist/agent-runs.js"; +import { terminateSandbox as terminateSandboxForHost } from "../../dist/orchestrator/sandbox-resume.js"; const actor = { id: "usr_test", name: "Tester" }; @@ -98,6 +99,61 @@ export function createFakeTracer() { }; } +/** A tracer that records `log` calls so tests can assert on (redacted) log output. */ +export function createRecordingTracer() { + const logs = []; + return { + ...createFakeTracer(), + logs, + log: (level, name, attributes) => logs.push({ level, name, attributes }), + }; +} + +/** + * An E2B-like handle that records its calls as `[name, ...args]`. + * `pauseError` makes `pause` reject. + */ +export function createFakeE2BHandle(options = {}) { + const calls = []; + return { + calls, + ref: { provider: "e2b", id: "sbx_1" }, + secret: options.secret, + exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + writeFile: async (...args) => { calls.push(["writeFile", ...args]); }, + startProcess: async () => {}, + readProcessLogs: async () => ({ stdout: "", stderr: "" }), + fetchPort: async () => new Response("ok"), + renewLease: async (...args) => { + calls.push(["renewLease", ...args]); + if (options.renewError) throw options.renewError; + }, + pause: async () => { + calls.push(["pause"]); + if (options.pauseError) throw options.pauseError; + }, + destroy: async (...args) => { calls.push(["destroy", ...args]); }, + }; +} + +/** An E2B-like provider whose `connect` counts calls and resolves `handle` (or throws `connectError`). */ +export function createFakeE2BProvider(handle, options = {}) { + const provider = { + name: "e2b", + capabilities: { pauseResume: true, workspaceCache: false }, + connectCalls: 0, + connectOptions: [], + create: async () => handle, + connect: async (_ref, connectOptions) => { + provider.connectCalls += 1; + provider.connectOptions.push(connectOptions); + if (options.connectError) throw options.connectError; + return handle; + }, + }; + return provider; +} + export function createFakeHost(metaOverrides = {}, options = {}) { const meta = createDefaultMeta(metaOverrides); const broadcasts = []; @@ -108,6 +164,13 @@ export function createFakeHost(metaOverrides = {}, options = {}) { let saveMetaCalls = 0; let previewRevoked = false; const storage = new Map(); + // `e2b: true` swaps in an E2B-like provider and a call-recording handle. + const e2bHandle = options.e2b ? createFakeE2BHandle(options) : undefined; + const e2bProvider = options.e2b ? createFakeE2BProvider(e2bHandle, options) : undefined; + const sandboxSockets = options.sandboxConnected === false ? [] : [{}]; + const closedSandboxSockets = []; + const activity = { count: 0 }; + const resumeRequests = { count: 0 }; const host = { meta, @@ -127,7 +190,7 @@ export function createFakeHost(metaOverrides = {}, options = {}) { }, getWebSockets(tag) { if (tag !== "sandbox") return []; - return options.sandboxConnected === false ? [] : [{}]; + return [...sandboxSockets]; }, }, redactionSecrets: [], @@ -200,10 +263,28 @@ export function createFakeHost(metaOverrides = {}, options = {}) { }, armNextAlarm: async () => {}, sandboxProvider() { - return options.sandboxProvider ?? createFakeSandboxProvider(); + return options.sandboxProvider ?? e2bProvider ?? createFakeSandboxProvider(); + }, + recordActivity() { + activity.count += 1; + meta.last_activity_at = new Date().toISOString(); + }, + requestSandboxResume() { + resumeRequests.count += 1; + }, + closeSandboxSockets(reason) { + closedSandboxSockets.push(reason); + sandboxSockets.length = 0; + }, + async issueSandboxWebSocketToken() { + return "fresh_token"; + }, + terminateSandbox(reason) { + return terminateSandboxForHost(host, reason); }, async sandboxHandle() { if (options.sandboxHandle !== undefined) return options.sandboxHandle; + if (e2bHandle) return e2bHandle; return createFakeSandboxHandle({ ref: meta.sandbox_ref ?? { provider: "cloudflare", id: meta.session_id }, }); @@ -218,6 +299,12 @@ export function createFakeHost(metaOverrides = {}, options = {}) { sandboxMessages, directoryPatches, storage, + handle: e2bHandle, + provider: e2bProvider, + sandboxSockets, + closedSandboxSockets, + activity, + resumeRequests, get saveMetaCalls() { return saveMetaCalls; }, diff --git a/packages/worker/test/sandbox-handlers.test.mjs b/packages/worker/test/sandbox-handlers.test.mjs index 109ddc3..d91d0e5 100644 --- a/packages/worker/test/sandbox-handlers.test.mjs +++ b/packages/worker/test/sandbox-handlers.test.mjs @@ -493,7 +493,7 @@ test("provisionSessionSandbox creates through the provider, records the ref, res create: async (options) => { calls.push(["create", options]); return handle; }, }); const { host } = createFakeHost( - { state: "initializing", max_time: "30m" }, + { state: "initializing", max_time: "30m", created_at: new Date().toISOString() }, { tracer: createFakeTracer(), sandboxProvider: provider, workerEnv: provisioningEnv() }, ); @@ -501,11 +501,16 @@ test("provisionSessionSandbox creates through the provider, records the ref, res assert.equal(host.meta.state, "provisioning_sandbox"); assert.deepEqual(host.meta.sandbox_ref, { provider: "cloudflare", id: "ses_test" }); + assert.ok(host.meta.sandbox_lease_renewed_at); + // The lease is the time left until max_time, measured from Session creation. + const [, createOptions] = calls[0]; + assert.ok(createOptions.leaseMs <= 30 * 60_000 && createOptions.leaseMs > 30 * 60_000 - 5_000); + calls[0][1] = { sessionId: createOptions.sessionId }; assert.equal(host.meta.workspace_cache_restored, false); assert.equal(await host.ctx.storage.get("codevil:sandbox_secret"), "handle-secret"); assert.ok(host.redactionSecrets.includes("handle-secret")); assert.deepEqual(calls, [ - ["create", { sessionId: "ses_test", leaseMs: 30 * 60_000 }], + ["create", { sessionId: "ses_test" }], ["startProcess", "codevil-agent", "/workspace"], ]); }); diff --git a/packages/worker/test/sandbox-resume.test.mjs b/packages/worker/test/sandbox-resume.test.mjs new file mode 100644 index 0000000..af169a4 --- /dev/null +++ b/packages/worker/test/sandbox-resume.test.mjs @@ -0,0 +1,629 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { handleAgentRequest } from "../dist/orchestrator/cli-handlers.js"; +import { hashPreviewToken, proxyPreviewRequest } from "../dist/orchestrator/preview.js"; +import { drainQueuedAgentWorkIfReady } from "../dist/orchestrator/sandbox-handlers.js"; +import { + clearStalePausedMarker, + expireSessionAtMaxTime, + isSandboxPausing, + pauseIdleSandbox, + prepareAuthenticatedPreview, + renewSandboxLeaseIfDue, + resumeSandbox, + sandboxAlarmDeadlines, + sandboxSocketAttached, + terminateSandbox, +} from "../dist/orchestrator/sandbox-resume.js"; +import { isUnexpectedSandboxDisconnect, sandboxConnectionMode } from "../dist/sandbox-connection.js"; +import { SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; +import { sandboxProviderMaxLeaseMs } from "../dist/sandbox-provider/index.js"; +import { actor, createFakeHost, createRecordingTracer } from "./helpers/fake-host.mjs"; + +const T0 = Date.parse("2026-10-01T00:00:00.000Z"); +const idleMeta = { + state: "ready", + sandbox_provider: "e2b", + sandbox_ref: { provider: "e2b", id: "sbx_1" }, + max_idle_time: "10m", + last_activity_at: "2026-10-01T00:00:00.000Z", + created_at: "2026-10-01T00:00:00.000Z", + max_time: "2h", +}; +const pausedMeta = { ...idleMeta, sandbox_paused_at: "2026-10-01T00:10:00.000Z", expected_close: true }; +const NO_DELAY = { retryDelaysMs: [0, 0, 0] }; + +function queuedRun(id = "run_q") { + return { id, state: "queued", text: "t", actor: { id: "u", name: "U" }, created_at: "t" }; +} + +function callNames(handle) { + return handle.calls.map(([name]) => name); +} + +// --- pause --- + +test("pauses after max_idle_time, closes sandbox sockets, and marks the sandbox paused", async () => { + const { host, handle, directoryPatches, broadcasts } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + assert.equal(await pauseIdleSandbox(host, T0 + 600_000), true); + assert.deepEqual(callNames(handle), ["pause"]); + assert.equal(host.meta.expected_close, true); + assert.equal(host.meta.sandbox_paused_at, new Date(T0 + 600_000).toISOString()); + assert.equal(host.ctx.getWebSockets("sandbox").length, 0); + assert.deepEqual(directoryPatches.at(-1), { sandbox_state: "paused" }); + assert.ok(broadcasts.some((event) => event.type === "status" && event.message === "Sandbox paused (idle).")); +}); + +test("does not pause before max_idle_time elapses", async () => { + const { host, handle } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + assert.equal(await pauseIdleSandbox(host, T0 + 599_999), false); + assert.equal(handle.calls.length, 0); +}); + +test("does not pause during an active run", async () => { + const { host, handle } = createFakeHost( + { ...idleMeta, active_run: { id: "run_1", state: "thinking" } }, + { e2b: true, sandboxConnected: true }, + ); + assert.equal(await pauseIdleSandbox(host, T0 + 3_600_000), false); + assert.equal(handle.calls.length, 0); +}); + +test("does not pause with a queued run or while cloning", async () => { + const idle = T0 + 3_600_000; + const queued = createFakeHost({ ...idleMeta, queued_runs: [queuedRun()] }, { e2b: true, sandboxConnected: true }); + assert.equal(await pauseIdleSandbox(queued.host, idle), false); + + const cloning = createFakeHost({ ...idleMeta, state: "cloning_repo" }, { e2b: true, sandboxConnected: true }); + assert.equal(await pauseIdleSandbox(cloning.host, idle), false); + + for (const { handle } of [queued, cloning]) assert.equal(handle.calls.length, 0); +}); + +test("does not pause mid-reconnect or without an attached sandbox socket", async () => { + const idle = T0 + 3_600_000; + const reconnecting = createFakeHost( + { ...idleMeta, sandbox_disconnected_at: "2026-10-01T00:09:30.000Z" }, + { e2b: true, sandboxConnected: true }, + ); + assert.equal(await pauseIdleSandbox(reconnecting.host, idle), false); + + const detached = createFakeHost(idleMeta, { e2b: true, sandboxConnected: false }); + assert.equal(await pauseIdleSandbox(detached.host, idle), false); + + assert.equal(reconnecting.handle.calls.length, 0); + assert.equal(detached.handle.calls.length, 0); +}); + +test("a pause failure leaves the sandbox running and clears expected_close", async () => { + const { host, closedSandboxSockets } = createFakeHost(idleMeta, { + e2b: true, + sandboxConnected: true, + pauseError: new Error("boom"), + }); + assert.equal(await pauseIdleSandbox(host, T0 + 600_000), false); + assert.equal(host.meta.expected_close, false); + assert.equal(host.meta.sandbox_paused_at, undefined); + assert.equal(host.ctx.getWebSockets("sandbox").length, 1); + assert.deepEqual(closedSandboxSockets, []); +}); + +test("a pause failure is logged with secrets redacted", async () => { + const tracer = createRecordingTracer(); + const { host } = createFakeHost(idleMeta, { + e2b: true, + sandboxConnected: true, + tracer, + pauseError: new Error("pause rejected for token sekret-traffic-token"), + }); + host.redactionSecrets.push("sekret-traffic-token"); + await pauseIdleSandbox(host, T0 + 600_000); + const entry = tracer.logs.find((log) => log.name === "sandbox.pause.failed"); + assert.ok(entry); + assert.ok(!JSON.stringify(entry).includes("sekret-traffic-token")); +}); + +test("pausing a vanished sandbox fails the session with Sandbox expired", async () => { + const { host, broadcasts } = createFakeHost(idleMeta, { + e2b: true, + sandboxConnected: true, + pauseError: new SandboxNotFoundError(), + }); + assert.equal(await pauseIdleSandbox(host, T0 + 600_000), false); + assert.equal(host.meta.state, "failed"); + assert.ok(broadcasts.some((event) => event.type === "error" && event.message === "Sandbox expired.")); +}); + +test("a request arriving mid-pause queues, then triggers a resume once the pause lands", async () => { + const { host, handle, resumeRequests } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + let release; + handle.pause = () => new Promise((resolve) => { release = resolve; }); + + const pausing = pauseIdleSandbox(host, T0 + 600_000); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(isSandboxPausing(host), true); + assert.equal(sandboxSocketAttached(host), false); + // A second tick does not start a second pause. + assert.equal(await pauseIdleSandbox(host, T0 + 600_000), false); + + handleAgentRequest(host, "do the thing", actor, false); + assert.equal(host.meta.active_run ?? null, null); + assert.equal(host.meta.queued_runs.length, 1); + assert.equal(resumeRequests.count, 0); + + release(); + assert.equal(await pausing, true); + assert.equal(isSandboxPausing(host), false); + assert.equal(resumeRequests.count, 1); +}); + +test("a session that ends mid-pause is not marked paused", async () => { + const { host, handle, directoryPatches } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + let release; + handle.pause = () => new Promise((resolve) => { release = resolve; }); + + const pausing = pauseIdleSandbox(host, T0 + 600_000); + await new Promise((resolve) => setImmediate(resolve)); + host.meta.state = "failed"; + release(); + + assert.equal(await pausing, false); + assert.equal(host.meta.sandbox_paused_at, undefined); + assert.ok(!directoryPatches.some((patch) => patch.sandbox_state === "paused")); +}); + +// --- resume --- + +test("resume connects once for concurrent triggers and writes a fresh ws token", async () => { + const { host, handle, provider } = createFakeHost(pausedMeta, { e2b: true }); + await Promise.all([resumeSandbox(host), resumeSandbox(host)]); + assert.equal(provider.connectCalls, 1); + assert.deepEqual(handle.calls.find(([name]) => name === "writeFile"), [ + "writeFile", + "/run/codevil/ws-token", + "fresh_token", + { mode: 0o600, owner: "codevil" }, + ]); + assert.equal(host.meta.sandbox_paused_at, undefined); + assert.equal(host.meta.expected_close, false); + assert.ok(host.meta.sandbox_lease_renewed_at); +}); + +test("resume connects with the lease cap and the stored provider secret, and redacts the secret", async () => { + const { host, provider, storage } = createFakeHost( + { ...pausedMeta, created_at: new Date().toISOString() }, + { e2b: true, secret: "sekret-handle" }, + ); + storage.set("codevil:sandbox_secret", "stored-secret"); + await resumeSandbox(host); + const [options] = provider.connectOptions; + assert.equal(options.secret, "stored-secret"); + assert.ok(options.leaseMs > 0 && options.leaseMs <= sandboxProviderMaxLeaseMs({}, "e2b")); + assert.ok(host.redactionSecrets.includes("sekret-handle")); +}); + +test("resume announces itself, updates the directory, and starts the reconnect grace", async () => { + const { host, broadcasts, directoryPatches } = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: false }); + await resumeSandbox(host); + assert.ok(broadcasts.some((event) => event.type === "status" && event.message === "Sandbox resumed.")); + assert.deepEqual(directoryPatches.at(-1), { sandbox_state: "ready" }); + assert.ok(host.meta.sandbox_disconnected_at); +}); + +test("resume does not start the reconnect grace when the agent already reconnected", async () => { + const { host } = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: true }); + await resumeSandbox(host); + assert.equal(host.meta.sandbox_disconnected_at, undefined); +}); + +test("resume of a session that is not paused does nothing", async () => { + const { host, provider } = createFakeHost(idleMeta, { e2b: true }); + await resumeSandbox(host); + assert.equal(provider.connectCalls, 0); +}); + +test("resume of a vanished sandbox fails the session with Sandbox expired", async () => { + const { host, broadcasts, directoryPatches } = createFakeHost( + { ...idleMeta, sandbox_paused_at: "2026-10-01T00:10:00.000Z" }, + { e2b: true, connectError: new SandboxNotFoundError() }, + ); + await resumeSandbox(host); + assert.equal(host.meta.state, "failed"); + assert.ok(broadcasts.some((event) => event.type === "error" && event.message === "Sandbox expired.")); + assert.deepEqual(directoryPatches.at(-1), { room_state: "failed", sandbox_state: "failed", active_run_state: null }); +}); + +test("resume retries three times then fails, cancels queued runs, and destroys the sandbox", async () => { + const { host, handle, provider, broadcasts, directoryPatches } = createFakeHost( + { ...idleMeta, sandbox_paused_at: "x", queued_runs: [queuedRun()] }, + { e2b: true, connectError: new Error("503") }, + ); + await resumeSandbox(host, NO_DELAY); + assert.equal(provider.connectCalls, 3); + assert.equal(host.meta.state, "failed"); + assert.ok( + broadcasts.some( + (event) => event.type === "agent_run_failed" && event.run_id === "run_q" && event.message === "Sandbox failed to resume.", + ), + ); + assert.deepEqual(host.meta.queued_runs, []); + assert.deepEqual(directoryPatches.find((patch) => patch.room_state === "failed"), { + room_state: "failed", + sandbox_state: "failed", + active_run_state: null, + }); + assert.deepEqual(handle.calls.find(([name]) => name === "destroy"), ["destroy", "resume failed"]); + assert.equal(host.meta.sandbox_paused_at, undefined); +}); + +test("resume recovers when a retry succeeds", async () => { + const { host, provider, handle } = createFakeHost(pausedMeta, { e2b: true }); + const connect = provider.connect; + provider.connect = async (...args) => { + if (provider.connectCalls === 0) { + provider.connectCalls += 1; + throw new Error("transient"); + } + return connect(...args); + }; + await resumeSandbox(host, NO_DELAY); + assert.equal(host.meta.state, "ready"); + assert.equal(host.meta.sandbox_paused_at, undefined); + assert.ok(callNames(handle).includes("writeFile")); +}); + +test("resume failures are logged with secrets redacted", async () => { + const tracer = createRecordingTracer(); + const { host } = createFakeHost(pausedMeta, { + e2b: true, + tracer, + connectError: new Error("connect failed with sekret-traffic-token"), + }); + host.redactionSecrets.push("sekret-traffic-token"); + await resumeSandbox(host, NO_DELAY); + const entries = tracer.logs.filter((log) => log.name === "sandbox.resume.failed"); + assert.equal(entries.length, 3); + assert.ok(!JSON.stringify(tracer.logs).includes("sekret-traffic-token")); +}); + +test("a session stopped mid-resume stays failed and is not resurrected", async () => { + const { host, provider, handle } = createFakeHost(pausedMeta, { e2b: true }); + let release; + provider.connect = () => new Promise((resolve) => { release = () => resolve(handle); }); + + const resuming = resumeSandbox(host); + await new Promise((resolve) => setImmediate(resolve)); + host.meta.state = "failed"; + host.meta.sandbox_paused_at = undefined; + release(); + await resuming; + + assert.equal(host.meta.state, "failed"); + assert.equal(host.meta.expected_close, true); +}); + +test("after resume the queued run starts exactly once when the agent reconnects", async () => { + const { host, sandboxMessages, sandboxSockets } = createFakeHost( + { ...pausedMeta, queued_runs: [queuedRun()] }, + { e2b: true, sandboxConnected: false }, + ); + // Paused: no socket, so nothing drains. + drainQueuedAgentWorkIfReady(host); + assert.equal(sandboxMessages.length, 0); + + await Promise.all([resumeSandbox(host), resumeSandbox(host)]); + // Resumed, but the agent has not reconnected yet. + drainQueuedAgentWorkIfReady(host); + assert.equal(sandboxMessages.length, 0); + assert.equal(host.meta.queued_runs.length, 1); + + sandboxSockets.push({}); + drainQueuedAgentWorkIfReady(host); + drainQueuedAgentWorkIfReady(host); + assert.deepEqual(sandboxMessages.map((message) => message.type), ["agent_turn"]); + assert.equal(host.meta.queued_runs.length, 0); + assert.equal(host.meta.active_run.id, "run_q"); +}); + +test("a paused sandbox reconnects in resume mode", () => { + assert.equal(sandboxConnectionMode("ready", undefined, 0), "resume"); + assert.equal(sandboxConnectionMode("ready", undefined, 1), "reject"); +}); + +test("a late close of a pause-closed socket is not an interruption", () => { + const base = { state: "ready", otherSandboxSocketsAttached: false }; + assert.equal(isUnexpectedSandboxDisconnect({ ...base }), true); + // Closed by the DO (pause or teardown). + assert.equal(isUnexpectedSandboxDisconnect({ ...base, expectedClose: true }), false); + // The agent already reconnected on a newer socket; this is the old socket's late close. + assert.equal(isUnexpectedSandboxDisconnect({ ...base, otherSandboxSocketsAttached: true }), false); + assert.equal(isUnexpectedSandboxDisconnect({ ...base, state: "failed" }), false); +}); + +test("a stale paused marker with a live agent socket is cleared; a real pause is not", () => { + const stale = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: true }); + assert.equal(clearStalePausedMarker(stale.host), true); + assert.equal(stale.host.meta.sandbox_paused_at, undefined); + assert.equal(stale.host.meta.expected_close, false); + + const paused = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: false }); + assert.equal(clearStalePausedMarker(paused.host), false); + assert.ok(paused.host.meta.sandbox_paused_at); +}); + +// --- lease renewal --- + +test("lease renewal runs on schedule and not while paused", async () => { + const { host, handle } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + await renewSandboxLeaseIfDue(host, T0 + 5 * 60_000); + assert.equal(handle.calls.filter(([name]) => name === "renewLease").length, 1); + assert.equal(host.meta.sandbox_lease_renewed_at, new Date(T0 + 5 * 60_000).toISOString()); + host.meta.sandbox_paused_at = "x"; + await renewSandboxLeaseIfDue(host, T0 + 20 * 60_000); + assert.equal(handle.calls.filter(([name]) => name === "renewLease").length, 1); +}); + +test("lease renewal waits for its interval and skips terminal sessions", async () => { + const early = createFakeHost(idleMeta, { e2b: true }); + await renewSandboxLeaseIfDue(early.host, T0 + 5 * 60_000 - 1); + assert.equal(early.handle.calls.length, 0); + + const ended = createFakeHost({ ...idleMeta, state: "completed" }, { e2b: true }); + await renewSandboxLeaseIfDue(ended.host, T0 + 60 * 60_000); + assert.equal(ended.handle.calls.length, 0); +}); + +test("a renewal failure is logged redacted and retried, not fatal", async () => { + const tracer = createRecordingTracer(); + const { host } = createFakeHost(idleMeta, { + e2b: true, + tracer, + renewError: new Error("renew failed sekret-traffic-token"), + }); + host.redactionSecrets.push("sekret-traffic-token"); + await renewSandboxLeaseIfDue(host, T0 + 5 * 60_000); + assert.equal(host.meta.state, "ready"); + assert.equal(host.meta.sandbox_lease_renewed_at, undefined); + assert.ok(tracer.logs.some((log) => log.name === "sandbox.lease_renew.failed")); + assert.ok(!JSON.stringify(tracer.logs).includes("sekret-traffic-token")); +}); + +test("a renewal that finds the sandbox gone fails the session", async () => { + const { host } = createFakeHost(idleMeta, { e2b: true, renewError: new SandboxNotFoundError() }); + await renewSandboxLeaseIfDue(host, T0 + 5 * 60_000); + assert.equal(host.meta.state, "failed"); +}); + +// --- alarm deadlines --- + +test("alarm deadlines: idle pause and lease renewal for an idle E2B session", () => { + const { host } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + assert.deepEqual(sandboxAlarmDeadlines(host), { + idlePauseAt: T0 + 600_000, + leaseRenewAt: T0 + 5 * 60_000, + }); +}); + +test("alarm deadlines: no idle pause unless ready, attached, and without runs", () => { + const cases = [ + [{ state: "cloning_repo" }, { sandboxConnected: true }], + [{ active_run: { id: "run_1", state: "thinking" } }, { sandboxConnected: true }], + [{ queued_runs: [queuedRun()] }, { sandboxConnected: true }], + [{}, { sandboxConnected: false }], + [{ sandbox_disconnected_at: "2026-10-01T00:09:30.000Z" }, { sandboxConnected: true }], + ]; + for (const [meta, options] of cases) { + const { host } = createFakeHost({ ...idleMeta, ...meta }, { e2b: true, ...options }); + assert.equal(sandboxAlarmDeadlines(host).idlePauseAt, null, JSON.stringify(meta)); + } +}); + +test("alarm deadlines: nothing while paused, terminal, or without a sandbox ref", () => { + const paused = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: true }); + assert.deepEqual(sandboxAlarmDeadlines(paused.host), { idlePauseAt: null, leaseRenewAt: null }); + + const ended = createFakeHost({ ...idleMeta, state: "failed" }, { e2b: true, sandboxConnected: true }); + assert.deepEqual(sandboxAlarmDeadlines(ended.host), { idlePauseAt: null, leaseRenewAt: null }); + + const noRef = createFakeHost({ ...idleMeta, sandbox_ref: undefined }, { e2b: true, sandboxConnected: true }); + assert.equal(sandboxAlarmDeadlines(noRef.host).leaseRenewAt, null); +}); + +// --- Cloudflare is unaffected --- + +test("the idle and lease path is a no-op for a Cloudflare session", async () => { + const resolved = []; + const { host, broadcasts, directoryPatches } = createFakeHost( + { + state: "ready", + sandbox_provider: "cloudflare", + sandbox_ref: { provider: "cloudflare", id: "ses_test" }, + max_idle_time: "10m", + last_activity_at: "2026-10-01T00:00:00.000Z", + created_at: "2026-10-01T00:00:00.000Z", + max_time: "2h", + }, + { sandboxConnected: true }, + ); + const sandboxHandle = host.sandboxHandle; + host.sandboxHandle = async () => { + resolved.push("handle"); + return sandboxHandle.call(host); + }; + const farFuture = T0 + 24 * 3_600_000; + + assert.deepEqual(sandboxAlarmDeadlines(host), { idlePauseAt: null, leaseRenewAt: null }); + assert.equal(await pauseIdleSandbox(host, farFuture), false); + await renewSandboxLeaseIfDue(host, farFuture); + await resumeSandbox(host); + + assert.deepEqual(resolved, []); + assert.equal(host.meta.sandbox_paused_at, undefined); + assert.equal(host.meta.expected_close, undefined); + assert.equal(host.meta.sandbox_lease_renewed_at, undefined); + assert.equal(host.ctx.getWebSockets("sandbox").length, 1); + assert.deepEqual(broadcasts, []); + assert.deepEqual(directoryPatches, []); +}); + +// --- activity and resume triggers --- + +test("an Agent Request on a paused session queues, resumes once, and records activity", () => { + const { host, resumeRequests, activity, broadcasts, sandboxMessages } = createFakeHost( + { ...pausedMeta, last_activity_at: "2026-10-01T00:00:00.000Z" }, + { e2b: true, sandboxConnected: false }, + ); + handleAgentRequest(host, "wake up", actor, false); + + assert.equal(resumeRequests.count, 1); + assert.equal(activity.count, 1); + assert.notEqual(host.meta.last_activity_at, "2026-10-01T00:00:00.000Z"); + assert.equal(host.meta.queued_runs.length, 1); + assert.equal(host.meta.active_run ?? null, null); + assert.ok(broadcasts.some((event) => event.type === "agent_request_queued")); + assert.deepEqual(sandboxMessages, []); +}); + +test("an Agent Request on a running session records activity without resuming", () => { + const { host, resumeRequests, activity } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + handleAgentRequest(host, "go", actor, false); + assert.equal(activity.count, 1); + assert.equal(resumeRequests.count, 0); +}); + +test("an empty Agent Request does not count as activity", () => { + const { host, activity } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + handleAgentRequest(host, " ", actor, false); + assert.equal(activity.count, 0); +}); + +// --- teardown --- + +test("max_time on a paused session destroys the sandbox", async () => { + const { host, handle, closedSandboxSockets, broadcasts } = createFakeHost( + { ...pausedMeta, max_time: "1h" }, + { e2b: true, sandboxConnected: false }, + ); + assert.equal(await expireSessionAtMaxTime(host, T0 + 3_599_999), false); + assert.equal(handle.calls.length, 0); + + assert.equal(await expireSessionAtMaxTime(host, T0 + 3_600_000), true); + assert.equal(host.meta.state, "timed_out"); + assert.deepEqual(handle.calls.find(([name]) => name === "destroy"), ["destroy", "timed out"]); + assert.equal(host.meta.sandbox_paused_at, undefined); + assert.deepEqual(closedSandboxSockets, ["timed out"]); + assert.ok(broadcasts.some((event) => event.type === "error" && event.message === "Session timed out after 1h.")); +}); + +test("stopping a paused session destroys the sandbox and clears the paused marker", async () => { + const { host, handle } = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: false }); + await terminateSandbox(host, "stopped by user"); + assert.deepEqual(handle.calls.find(([name]) => name === "destroy"), ["destroy", "stopped by user"]); + assert.equal(host.meta.sandbox_paused_at, undefined); + assert.equal(host.meta.expected_close, true); +}); + +test("a destroy failure at teardown is logged redacted and not thrown", async () => { + const tracer = createRecordingTracer(); + const { host, handle } = createFakeHost(pausedMeta, { e2b: true, tracer }); + handle.destroy = async () => { throw new Error("kill failed sekret-traffic-token"); }; + host.redactionSecrets.push("sekret-traffic-token"); + await terminateSandbox(host, "stopped by user"); + assert.ok(tracer.logs.some((log) => log.name === "sandbox.stop.failed")); + assert.ok(!JSON.stringify(tracer.logs).includes("sekret-traffic-token")); +}); + +// --- preview --- + +const PREVIEW_TOKEN = "ses-test-secrettoken"; + +async function previewHost(metaOverrides, options = {}) { + const fake = createFakeHost( + { + ...pausedMeta, + preview_active: true, + preview_port: 5173, + preview_token_hash: await hashPreviewToken(PREVIEW_TOKEN), + ...metaOverrides, + }, + { e2b: true, sandboxConnected: false, ...options }, + ); + fake.handle.fetchPort = async () => new Response("preview body"); + return fake; +} + +function previewRequest(token) { + return new Request(`https://worker.example/sessions/ses_test/preview/${token}/`); +} + +function proxy(fake, token, timeoutMs) { + return proxyPreviewRequest( + previewRequest(token), + fake.host.meta, + token, + () => fake.host.sandboxHandle(), + { beforeProxy: () => prepareAuthenticatedPreview(fake.host, timeoutMs) }, + ); +} + +test("an authenticated preview request resumes a paused sandbox, records activity, and proxies", async () => { + const fake = await previewHost({}); + const response = await proxy(fake, PREVIEW_TOKEN); + assert.equal(response.status, 200); + assert.equal(await response.text(), "preview body"); + assert.equal(fake.provider.connectCalls, 1); + assert.equal(fake.host.meta.sandbox_paused_at, undefined); + assert.equal(fake.activity.count, 1); +}); + +test("an authenticated preview request on a running sandbox records activity without resuming", async () => { + const fake = await previewHost({ sandbox_paused_at: undefined, expected_close: false }); + const response = await proxy(fake, PREVIEW_TOKEN); + assert.equal(response.status, 200); + assert.equal(fake.provider.connectCalls, 0); + assert.equal(fake.activity.count, 1); +}); + +test("an unknown preview token neither resumes the sandbox nor records activity", async () => { + const fake = await previewHost({}); + const response = await proxy(fake, "ses-test-wrongtoken"); + assert.equal(response.status, 404); + assert.equal(fake.provider.connectCalls, 0); + assert.equal(fake.activity.count, 0); + assert.ok(fake.host.meta.sandbox_paused_at); +}); + +test("an inactive or ended preview neither resumes the sandbox nor records activity", async () => { + const inactive = await previewHost({ preview_active: false }); + assert.equal((await proxy(inactive, PREVIEW_TOKEN)).status, 404); + + const ended = await previewHost({ state: "completed" }); + assert.equal((await proxy(ended, PREVIEW_TOKEN)).status, 410); + + for (const fake of [inactive, ended]) { + assert.equal(fake.provider.connectCalls, 0); + assert.equal(fake.activity.count, 0); + } +}); + +test("a preview request answers 503 with Retry-After when the resume is slow, and the resume continues", async () => { + const fake = await previewHost({}); + let release; + fake.provider.connect = () => new Promise((resolve) => { release = () => resolve(fake.handle); }); + + const response = await proxy(fake, PREVIEW_TOKEN, 10); + assert.equal(response.status, 503); + assert.equal(response.headers.get("Retry-After"), "2"); + assert.equal(await response.text(), "Sandbox is resuming. Retry shortly."); + + release(); + await fake.drainBackgroundWork(); + assert.equal(fake.host.meta.sandbox_paused_at, undefined); +}); + +test("a preview request on a session whose resume failed answers 410, not a proxied error", async () => { + const fake = await previewHost({}, { connectError: new SandboxNotFoundError() }); + const response = await proxy(fake, PREVIEW_TOKEN); + assert.equal(response.status, 410); + assert.equal(fake.host.meta.state, "failed"); +}); From 098b9fc9c1edd0d550a16eba5233631bba143574 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 08:08:32 +0530 Subject: [PATCH 19/32] fix(sandbox): count only live sandbox sockets and harden pause/resume races --- packages/shared/src/session-meta-schema.ts | 2 + packages/worker/src/orchestrator.ts | 36 +- .../worker/src/orchestrator/cli-handlers.ts | 18 +- .../src/orchestrator/question-answer.ts | 3 +- .../worker/src/orchestrator/sandbox-access.ts | 17 +- .../src/orchestrator/sandbox-handlers.ts | 19 +- ...resume.ts => sandbox-session-lifecycle.ts} | 160 +++++++- packages/worker/src/sandbox-connection.ts | 69 +++- .../worker/src/sandbox-provider/cloudflare.ts | 2 +- packages/worker/src/sandbox-provider/e2b.ts | 2 +- packages/worker/src/sandbox-provider/types.ts | 3 +- packages/worker/test/helpers/fake-host.mjs | 42 +- packages/worker/test/question-answer.test.mjs | 16 +- packages/worker/test/sandbox-access.test.mjs | 2 +- .../worker/test/sandbox-handlers.test.mjs | 43 +- .../test/sandbox-provider-cloudflare.test.mjs | 2 +- .../worker/test/sandbox-provider-e2b.test.mjs | 2 +- ...mjs => sandbox-session-lifecycle.test.mjs} | 370 +++++++++++++++++- 18 files changed, 709 insertions(+), 99 deletions(-) rename packages/worker/src/orchestrator/{sandbox-resume.ts => sandbox-session-lifecycle.ts} (71%) rename packages/worker/test/{sandbox-resume.test.mjs => sandbox-session-lifecycle.test.mjs} (61%) diff --git a/packages/shared/src/session-meta-schema.ts b/packages/shared/src/session-meta-schema.ts index aeb52f6..afae068 100644 --- a/packages/shared/src/session-meta-schema.ts +++ b/packages/shared/src/session-meta-schema.ts @@ -43,6 +43,8 @@ export const SessionMetaSchema = z.object({ last_activity_at: z.string().optional(), sandbox_paused_at: z.string().optional(), sandbox_lease_renewed_at: z.string().optional(), + /** A preview start requested while the sandbox was paused; sent once the agent reconnects. */ + pending_preview_start: z.object({ app_key: z.string().optional() }).optional(), last_decision: LastDecisionSchema.optional(), }); diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index 4e567a8..2d4b3ab 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -39,7 +39,9 @@ import { SANDBOX_RECONNECT_GRACE_MS, sandboxConnectionMode, completeSandboxReconnect, + closeSandboxSockets as closeSandboxSocketsFn, isUnexpectedSandboxDisconnect, + liveSandboxSockets, sandboxReconnectExpired, } from "./sandbox-connection.js"; import { redactEvent } from "./redaction.js"; @@ -127,9 +129,11 @@ import { prepareAuthenticatedPreview, renewSandboxLeaseIfDue, resumeSandbox, + recordSessionActivity, + flushPendingPreviewStart, sandboxAlarmDeadlines, terminateSandbox as terminateSandboxFn, -} from "./orchestrator/sandbox-resume.js"; +} from "./orchestrator/sandbox-session-lifecycle.js"; import { nextSessionDirectoryTimestamp, runSessionDirectoryUpdateWithRetry, @@ -153,8 +157,6 @@ import { armNextAlarm as armNextAlarmAt } from "./orchestrator/alarm.js"; export type { InitOptions } from "./orchestrator/types.js"; -const ACTIVITY_WRITE_INTERVAL_MS = 1_000; - export class Orchestrator extends DurableObject implements OrchestratorHost { readonly ctx: DurableObjectState<{}>; sql: SqlStorage; @@ -427,7 +429,8 @@ export class Orchestrator extends DurableObject implements OrchestratorHost ); if (!capability) return new Response("Unauthorized", { status: 401 }); - const attachedSandboxCount = this.ctx.getWebSockets("sandbox").length; + // Sockets the DO closed (pause, teardown) can linger until the peer answers; only live ones are attached. + const attachedSandboxCount = liveSandboxSockets(this.ctx).length; const mode = sandboxConnectionMode(this.meta.state, this.meta.sandbox_disconnected_at, attachedSandboxCount); if (mode === "reject") { this.getTracer()?.log("WARN", "sandbox.ws.rejected", { @@ -455,6 +458,8 @@ export class Orchestrator extends DurableObject implements OrchestratorHost completeSandboxReconnect(this); this.armNextAlarmSafe(); } + // A preview requested while the sandbox was paused starts now that the agent is back. + flushPendingPreviewStart(this); return new Response(null, { status: 101, webSocket: client }); } @@ -575,7 +580,8 @@ export class Orchestrator extends DurableObject implements OrchestratorHost if (isUnexpectedSandboxDisconnect({ expectedClose: this.meta.expected_close, state: this.meta.state, - otherSandboxSocketsAttached: this.ctx.getWebSockets("sandbox").some((socket) => socket !== ws), + closedSocket: ws, + sandboxSockets: this.ctx.getWebSockets("sandbox"), })) { const state = this.meta.state; if (!this.meta.sandbox_disconnected_at) { @@ -766,13 +772,13 @@ export class Orchestrator extends DurableObject implements OrchestratorHost /** RPC for `GET /sessions/:id/logs`. */ async readSandboxLogs(): Promise { this.loadMeta(); - return sandboxLogsResponse(this.sandboxResolver, this.redactionSecrets); + return sandboxLogsResponse(this.sandboxResolver, this.redactionSecrets, { paused: Boolean(this.meta?.sandbox_paused_at) }); } /** RPC for `GET /sessions/:id/diagnostics`. */ async readSandboxDiagnosticsResponse(): Promise { this.loadMeta(); - return sandboxDiagnosticsResponse(this.sandboxResolver, this.redactionSecrets); + return sandboxDiagnosticsResponse(this.sandboxResolver, this.redactionSecrets, { paused: Boolean(this.meta?.sandbox_paused_at) }); } async terminateSandbox(reason: string): Promise { @@ -780,15 +786,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost } recordActivity(): void { - if (!this.meta) return; - const lastMs = this.meta.last_activity_at ? Date.parse(this.meta.last_activity_at) : Number.NaN; - const nowMs = Date.now(); - // Preview traffic can arrive many times a second; the idle clock only needs second resolution. - if (Number.isFinite(lastMs) && nowMs - lastMs < ACTIVITY_WRITE_INTERVAL_MS) return; - this.meta.last_activity_at = new Date(nowMs).toISOString(); - this.saveMeta(); - // The idle-pause deadline moved; re-arm so the alarm tracks it (only providers that pause have one). - if (sandboxAlarmDeadlines(this).idlePauseAt !== null) void this.armNextAlarmSafe(); + recordSessionActivity(this); } requestSandboxResume(): void { @@ -843,7 +841,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost } sendToSandbox(message: DOToSandboxMessage): void { - const sandboxes = this.ctx.getWebSockets("sandbox"); + const sandboxes = liveSandboxSockets(this.ctx); if (sandboxes.length === 0) { this.appendAndBroadcast({ type: "error", message: "Sandbox is not connected." }); return; @@ -1071,9 +1069,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost } closeSandboxSockets(reason: string): void { - for (const sandbox of this.ctx.getWebSockets("sandbox")) { - sandbox.close(1000, reason); - } + closeSandboxSocketsFn(this.ctx, reason); } private async logSandboxDisconnectDiagnostics(options: { diff --git a/packages/worker/src/orchestrator/cli-handlers.ts b/packages/worker/src/orchestrator/cli-handlers.ts index eea80d6..5cc414b 100644 --- a/packages/worker/src/orchestrator/cli-handlers.ts +++ b/packages/worker/src/orchestrator/cli-handlers.ts @@ -35,7 +35,12 @@ import { ensureAnnotatableRevision, lockPlanRevision, } from "./plan-revision-actions.js"; -import { sandboxSocketAttached } from "./sandbox-resume.js"; +import { + buildPreviewStartMessage, + cancelDeferredPreviewStart, + deferPreviewStartWhilePaused, + sandboxSocketAttached, +} from "./sandbox-session-lifecycle.js"; export function handleApprove( host: OrchestratorHost, @@ -498,16 +503,13 @@ export function cancelOpenQuestions(host: OrchestratorHost, runId: string, reaso export async function handlePreviewStart(host: OrchestratorHost, appKey?: string): Promise { if (!host.meta) return; + if (deferPreviewStartWhilePaused(host, appKey)) return; - host.sendToSandbox({ - type: "preview_start", - model: host.meta.plan_model, - provider: host.meta.provider, - task_prompt: host.meta.prompt, - app_key: appKey, - }); + host.sendToSandbox(buildPreviewStartMessage(host.meta, appKey)); } export async function handlePreviewStop(host: OrchestratorHost): Promise { + // A deferred start never reached the sandbox and a paused sandbox has no preview to stop. + if (cancelDeferredPreviewStart(host)) return; host.sendToSandbox({ type: "preview_stop" }); } diff --git a/packages/worker/src/orchestrator/question-answer.ts b/packages/worker/src/orchestrator/question-answer.ts index 008777b..25290c0 100644 --- a/packages/worker/src/orchestrator/question-answer.ts +++ b/packages/worker/src/orchestrator/question-answer.ts @@ -1,4 +1,5 @@ import type { ParticipantIdentity } from "@codevil/shared"; +import { liveSandboxSockets } from "../sandbox-connection.js"; import type { OrchestratorHost } from "./host.js"; import { loadQuestionAnswerRow, type QuestionAnswerRow } from "./questions-store.js"; @@ -83,7 +84,7 @@ export function applyQuestionAnswer( return { ok: false, status: "not_open", error: "Question is no longer open" }; } - if (host.ctx.getWebSockets("sandbox").length === 0) { + if (liveSandboxSockets(host.ctx).length === 0) { return { ok: false, status: "sandbox_unavailable", diff --git a/packages/worker/src/orchestrator/sandbox-access.ts b/packages/worker/src/orchestrator/sandbox-access.ts index 0b60cf7..b4b32c0 100644 --- a/packages/worker/src/orchestrator/sandbox-access.ts +++ b/packages/worker/src/orchestrator/sandbox-access.ts @@ -47,7 +47,7 @@ export function sandboxRefForMeta( } export async function connectSandboxHandle(options: { - meta: { session_id: string; sandbox_ref?: SandboxRef } | null; + meta: { session_id: string; sandbox_ref?: SandboxRef; sandbox_paused_at?: string } | null; provider: SandboxProvider; readSecret: () => Promise; /** Live redaction list; any secret the handle ends up using is added to it. */ @@ -55,6 +55,9 @@ export async function connectSandboxHandle(options: { /** Persist a secret the provider supplied because none was stored. */ storeSecret?: (secret: string) => Promise; }): Promise { + // Connecting would silently resume a paused VM (no token write, lease or marker + // update). Only the resume path may wake it, and it calls `provider.connect` itself. + if (options.meta?.sandbox_paused_at) return null; const ref = sandboxRefForMeta(options.meta, options.provider); if (!ref) return null; const secret = await options.readSecret(); @@ -78,6 +81,14 @@ export async function destroySandbox( } } +/** Body for log and diagnostics reads of a paused sandbox, which must not wake the VM. */ +export function pausedSandboxResponse(): Response { + return Response.json( + { paused: true, message: "Sandbox is paused. Send an Agent Request or open the preview to resume it." }, + { status: 200 }, + ); +} + /** Agent process logs, redacted; null when the sandbox cannot be read. */ export async function readRedactedAgentLogs( resolveHandle: SandboxHandleResolver, @@ -112,7 +123,9 @@ export async function collectAgentDiagnostics( export async function sandboxLogsResponse( resolveHandle: SandboxHandleResolver, secrets: readonly string[], + options: { paused?: boolean } = {}, ): Promise { + if (options.paused) return pausedSandboxResponse(); try { const handle = await resolveHandle(); if (!handle) throw new Error("Sandbox not found"); @@ -127,7 +140,9 @@ export async function sandboxLogsResponse( export async function sandboxDiagnosticsResponse( resolveHandle: SandboxHandleResolver, secrets: readonly string[], + options: { paused?: boolean } = {}, ): Promise { + if (options.paused) return pausedSandboxResponse(); try { const diagnostics = await collectAgentDiagnostics(resolveHandle, secrets); return Response.json(redactEvent(diagnostics, secrets), { status: 200 }); diff --git a/packages/worker/src/orchestrator/sandbox-handlers.ts b/packages/worker/src/orchestrator/sandbox-handlers.ts index 709c96a..8bb9ba6 100644 --- a/packages/worker/src/orchestrator/sandbox-handlers.ts +++ b/packages/worker/src/orchestrator/sandbox-handlers.ts @@ -41,7 +41,8 @@ import { finishRunAndDrainQueue, setActiveRunState, } from "./agent-run-coordinator.js"; -import { sandboxSocketAttached } from "./sandbox-resume.js"; +import { drainQueuedAgentWorkIfReady } from "./sandbox-session-lifecycle.js"; +export { drainQueuedAgentWorkIfReady } from "./sandbox-session-lifecycle.js"; import { freezePlanRevision } from "./plan-revision-actions.js"; import { cancelOpenQuestions, @@ -86,7 +87,7 @@ export async function provisionSessionSandbox(host: OrchestratorHost): Promise 0 - && sandboxSocketAttached(host) - ) { - finishRunAndDrainQueue(host, "completed"); - } -} - async function restoreWorkspaceCacheBeforeStart( host: OrchestratorHost, sandbox: WorkspaceCacheSandbox, diff --git a/packages/worker/src/orchestrator/sandbox-resume.ts b/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts similarity index 71% rename from packages/worker/src/orchestrator/sandbox-resume.ts rename to packages/worker/src/orchestrator/sandbox-session-lifecycle.ts index 216384b..913be82 100644 --- a/packages/worker/src/orchestrator/sandbox-resume.ts +++ b/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts @@ -1,7 +1,9 @@ import { isTerminalState, safeExceptionAttributes } from "@codevil/shared"; import { redactEvent } from "../redaction.js"; +import { liveSandboxSockets } from "../sandbox-connection.js"; import { SandboxNotFoundError } from "../sandbox-provider/types.js"; import { sandboxProviderMaxLeaseMs } from "../sandbox-provider/index.js"; +import { finishRunAndDrainQueue } from "./agent-run-coordinator.js"; import type { OrchestratorHost } from "./host.js"; import { validatePreviewAccess } from "./preview.js"; import { listOpenQuestionIds } from "./questions-store.js"; @@ -37,7 +39,24 @@ export function isSandboxPausing(host: OrchestratorHost): boolean { /** A sandbox socket is attached and the sandbox is not about to be paused. */ export function sandboxSocketAttached(host: OrchestratorHost): boolean { - return host.ctx.getWebSockets("sandbox").length > 0 && !pausing.has(host); + return liveSandboxSockets(host.ctx).length > 0 && !pausing.has(host); +} + +/** + * Drains queued agent work once the session is usable; the cache job no longer + * gates agent work (snapshots are best-effort and validated on restore, so runs + * and backups may overlap). Lives here, not in sandbox-handlers, so pause + * recovery can drain without an import cycle. + */ +export function drainQueuedAgentWorkIfReady(host: OrchestratorHost): void { + if ( + host.meta?.state === "ready" + && !host.meta.active_run + && host.meta.queued_runs.length > 0 + && sandboxSocketAttached(host) + ) { + finishRunAndDrainQueue(host, "completed"); + } } function logFailure(host: OrchestratorHost, event: string, error: unknown, extra: Record = {}): void { @@ -47,15 +66,39 @@ function logFailure(host: OrchestratorHost, event: string, error: unknown, extra }); } -function providerSupportsPause(host: OrchestratorHost): boolean { +function providerCapabilities(host: OrchestratorHost): { pauseResume: boolean; leaseRenewal: boolean } { try { - return host.sandboxProvider().capabilities.pauseResume; + const { pauseResume, leaseRenewal } = host.sandboxProvider().capabilities; + return { pauseResume, leaseRenewal }; } catch { - return false; + return { pauseResume: false, leaseRenewal: false }; + } +} + +function providerSupportsPause(host: OrchestratorHost): boolean { + return providerCapabilities(host).pauseResume; +} + +const ACTIVITY_WRITE_INTERVAL_MS = 1_000; + +/** + * Counts a user-visible action toward the idle-pause clock. A no-op for + * providers that cannot pause (their meta stays untouched). Only the meta write + * is throttled; the alarm is always re-armed while an idle deadline exists so a + * run that finishes right after a request still gets its idle alarm. + */ +export function recordSessionActivity(host: OrchestratorHost, now: number = Date.now()): void { + const meta = host.meta; + if (!meta || !providerSupportsPause(host)) return; + const lastMs = meta.last_activity_at ? Date.parse(meta.last_activity_at) : Number.NaN; + if (!(Number.isFinite(lastMs) && now - lastMs >= 0 && now - lastMs < ACTIVITY_WRITE_INTERVAL_MS)) { + meta.last_activity_at = new Date(now).toISOString(); + host.saveMeta(); } + if (sandboxAlarmDeadlines(host).idlePauseAt !== null) void armAlarmSafely(host); } -function idlePauseInput(host: OrchestratorHost, now: number): IdlePauseInput | null { +export function idlePauseInput(host: OrchestratorHost, now: number): IdlePauseInput | null { const meta = host.meta; if (!meta) return null; const activeRunId = meta.active_run?.id; @@ -83,14 +126,14 @@ export function sandboxAlarmDeadlines(host: OrchestratorHost): { leaseRenewAt: number | null; } { const meta = host.meta; - if (!meta || isTerminalState(meta.state) || meta.sandbox_paused_at || !providerSupportsPause(host)) { + if (!meta || isTerminalState(meta.state) || meta.sandbox_paused_at) { return { idlePauseAt: null, leaseRenewAt: null }; } // Every pause condition except the clock: an infinite `now` passes the time check. const input = idlePauseInput(host, Number.POSITIVE_INFINITY); const idlePauseAt = input && shouldPauseSandbox(input) ? idlePauseDeadline(input) : null; - const leaseRenewAt = meta.sandbox_ref + const leaseRenewAt = meta.sandbox_ref && providerCapabilities(host).leaseRenewal ? leaseRenewDeadline({ renewedAt: meta.sandbox_lease_renewed_at, createdAt: meta.created_at }) : null; return { idlePauseAt, leaseRenewAt }; @@ -104,7 +147,13 @@ export function pauseIdleSandbox(host: OrchestratorHost, now: number): Promise pausing.delete(host)); + // Recovery runs after the marker is cleared so a drained run can reach the sandbox again. + const run = doPause(host, now) + .finally(() => pausing.delete(host)) + .then(async (paused) => { + if (!paused) await recoverAfterFailedPause(host); + return paused; + }); pausing.set(host, run); return run; } @@ -145,6 +194,26 @@ async function doPause(host: OrchestratorHost, now: number): Promise { return true; } +/** + * A failed pause leaves the sandbox running. While `expected_close` was set, a + * dropped socket was ignored, so if no live socket remains start the reconnect + * grace; then release work that queued during the pause and re-arm the alarm + * (which retries the pause) instead of waiting for the next tick. + */ +async function recoverAfterFailedPause(host: OrchestratorHost): Promise { + const meta = host.meta; + if (!meta || isTerminalState(meta.state) || meta.sandbox_paused_at) return; + if (liveSandboxSockets(host.ctx).length === 0 && !meta.sandbox_disconnected_at) { + meta.sandbox_disconnected_at = new Date().toISOString(); + host.saveMeta(); + host.appendAndBroadcast({ type: "status", message: "Sandbox connection interrupted. Reconnecting…" }); + host.updateDirectory({}); + } + drainQueuedAgentWorkIfReady(host); + flushPendingPreviewStart(host); + await armAlarmSafely(host); +} + /** Resumes a paused sandbox. Concurrent callers share one in-flight resume. */ export function resumeSandbox( host: OrchestratorHost, @@ -191,6 +260,8 @@ async function doResume(host: OrchestratorHost, retryDelaysMs: readonly number[] ...(secret ? { secret } : {}), }); registerSandboxSecret(host.redactionSecrets, handle.secret); + // Teardown may have run while connect was in flight; do not touch a sandbox that is going away. + if (!meta.sandbox_paused_at || isTerminalState(meta.state)) return; // The agent adopts a changed token file on its next reconnect; the // in-memory token it holds may have expired while paused. await handle.writeFile(SANDBOX_WS_TOKEN_FILE, await host.issueSandboxWebSocketToken(), { @@ -218,11 +289,12 @@ async function completeResume(host: OrchestratorHost, now: number): Promise { */ export async function terminateSandbox(host: OrchestratorHost, reason: string): Promise { if (!host.meta) return; + // A pause still in flight would otherwise land after the destroy and leave a + // paused sandbox that never expires. + await pausing.get(host)?.catch(() => false); host.meta.expected_close = true; host.meta.sandbox_paused_at = undefined; + host.meta.pending_preview_start = undefined; host.saveMeta(); await destroySandbox(() => host.sandboxHandle(), reason, (error) => { logFailure(host, "sandbox.stop.failed", error); @@ -368,6 +445,67 @@ export async function expireSessionAtMaxTime(host: OrchestratorHost, now: number return true; } +export function buildPreviewStartMessage( + meta: { plan_model: string; provider: string; prompt: string }, + appKey?: string, +) { + return { + type: "preview_start" as const, + model: meta.plan_model, + provider: meta.provider, + task_prompt: meta.prompt, + app_key: appKey, + }; +} + +/** + * A preview start requested while the sandbox is paused (or about to be) + * resumes it and is remembered; it is sent when the agent reconnects. + */ +export function deferPreviewStartWhilePaused(host: OrchestratorHost, appKey?: string): boolean { + const meta = host.meta; + if (!meta || (!meta.sandbox_paused_at && !pausing.has(host))) return false; + meta.pending_preview_start = appKey !== undefined ? { app_key: appKey } : {}; + host.saveMeta(); + host.recordActivity(); + host.requestSandboxResume(); + host.appendAndBroadcast({ + type: "status", + message: "Sandbox is resuming; the preview will start when it reconnects.", + }); + return true; +} + +/** True when there was nothing live to stop: a deferred start was cancelled or the sandbox is paused. */ +export function cancelDeferredPreviewStart(host: OrchestratorHost): boolean { + const meta = host.meta; + if (!meta) return false; + const hadPending = meta.pending_preview_start !== undefined; + if (hadPending) { + meta.pending_preview_start = undefined; + host.saveMeta(); + } + return hadPending || Boolean(meta.sandbox_paused_at) || pausing.has(host); +} + +/** Sends a deferred preview start once a live agent socket exists. */ +export function flushPendingPreviewStart(host: OrchestratorHost): boolean { + const meta = host.meta; + if ( + !meta?.pending_preview_start + || meta.sandbox_paused_at + || isTerminalState(meta.state) + || !sandboxSocketAttached(host) + ) { + return false; + } + const { app_key: appKey } = meta.pending_preview_start; + meta.pending_preview_start = undefined; + host.saveMeta(); + host.sendToSandbox(buildPreviewStartMessage(meta, appKey)); + return true; +} + /** * Runs only for an authenticated preview request: counts it as activity and * resumes a paused sandbox, answering 503 if that takes too long. diff --git a/packages/worker/src/sandbox-connection.ts b/packages/worker/src/sandbox-connection.ts index 9ac65cb..89f23bb 100644 --- a/packages/worker/src/sandbox-connection.ts +++ b/packages/worker/src/sandbox-connection.ts @@ -19,18 +19,75 @@ export function sandboxConnectionMode( return "reject"; } +/** The slice of a WebSocket the sandbox-socket helpers read; plain test doubles satisfy it. */ +export interface SandboxSocketLike { + readyState?: number; + deserializeAttachment?(): unknown; + serializeAttachment?(value: unknown): void; + close?(code?: number, reason?: string): void; +} + +const WEBSOCKET_OPEN = 1; + +/** + * A sandbox socket that can still carry traffic. A socket the DO closed (marked + * `closing` in its attachment before `close()`) can linger in `getWebSockets` + * while its paused peer has not answered the close handshake, and a socket that + * is not OPEN is on its way out; neither counts as an attached agent. + */ +export function isLiveSandboxSocket(socket: SandboxSocketLike): boolean { + if (typeof socket.readyState === "number" && socket.readyState !== WEBSOCKET_OPEN) return false; + let attachment: unknown; + try { + attachment = socket.deserializeAttachment?.(); + } catch { + attachment = undefined; + } + return !(typeof attachment === "object" && attachment !== null && (attachment as { closing?: unknown }).closing === true); +} + +export function liveSandboxSockets(ctx: { getWebSockets(tag?: string): T[] }): T[] { + return ctx.getWebSockets("sandbox").filter(isLiveSandboxSocket); +} + +/** Close every sandbox socket the DO holds, marking each `closing` first so it stops counting as attached at once. */ +export function closeSandboxSockets( + ctx: { getWebSockets(tag?: string): T[] }, + reason: string, +): void { + for (const socket of ctx.getWebSockets("sandbox")) { + try { + const attachment = socket.deserializeAttachment?.(); + socket.serializeAttachment?.({ + ...(typeof attachment === "object" && attachment !== null ? attachment : {}), + closing: true, + }); + } catch { + // A socket whose attachment cannot be updated is still closed below. + } + try { + socket.close?.(1000, reason); + } catch { + // Already closed. + } + } +} + /** * Whether a closed sandbox socket means the agent dropped. A close the DO - * initiated (pause, teardown) is expected, and a close that arrives after the - * agent already reconnected on a newer socket (a paused agent only answers the - * close handshake once resumed) must not mark the live connection interrupted. + * initiated (pause, teardown) is expected, and a close of an old socket that + * arrives after the agent already reconnected on a newer LIVE socket (a paused + * agent only answers the close handshake once resumed) must not mark the live + * connection interrupted. */ -export function isUnexpectedSandboxDisconnect(input: { +export function isUnexpectedSandboxDisconnect(input: { expectedClose?: boolean; state: SessionState; - otherSandboxSocketsAttached: boolean; + closedSocket: T; + sandboxSockets: readonly T[]; }): boolean { - return !input.expectedClose && !isTerminalState(input.state) && !input.otherSandboxSocketsAttached; + if (input.expectedClose || isTerminalState(input.state)) return false; + return !input.sandboxSockets.some((socket) => socket !== input.closedSocket && isLiveSandboxSocket(socket)); } export function sandboxReconnectExpired(disconnectedAt: string, now: number): boolean { diff --git a/packages/worker/src/sandbox-provider/cloudflare.ts b/packages/worker/src/sandbox-provider/cloudflare.ts index 641dda5..6f0bace 100644 --- a/packages/worker/src/sandbox-provider/cloudflare.ts +++ b/packages/worker/src/sandbox-provider/cloudflare.ts @@ -57,7 +57,7 @@ export function createCloudflareSandboxProvider( return { name: "cloudflare", - capabilities: { pauseResume: false, workspaceCache: true }, + capabilities: { pauseResume: false, workspaceCache: true, leaseRenewal: false }, async create({ sessionId }) { const sandbox = await lookup(sessionId); await retrySandboxOperation(() => diff --git a/packages/worker/src/sandbox-provider/e2b.ts b/packages/worker/src/sandbox-provider/e2b.ts index a416b7c..b68a91d 100644 --- a/packages/worker/src/sandbox-provider/e2b.ts +++ b/packages/worker/src/sandbox-provider/e2b.ts @@ -70,7 +70,7 @@ export function createE2BSandboxProvider(options: E2BSandboxProviderOptions): Sa return { name: "e2b", - capabilities: { pauseResume: true, workspaceCache: false }, + capabilities: { pauseResume: true, workspaceCache: false, leaseRenewal: true }, async create({ sessionId, leaseMs }) { // `secure` is ignored in e2b 2.x and a top-level `allowPublicTraffic` // is not honored: only `network.allowPublicTraffic` locks the sandbox. diff --git a/packages/worker/src/sandbox-provider/types.ts b/packages/worker/src/sandbox-provider/types.ts index edc566c..ec24b8c 100644 --- a/packages/worker/src/sandbox-provider/types.ts +++ b/packages/worker/src/sandbox-provider/types.ts @@ -9,7 +9,8 @@ export type SandboxLifecycleView = SandboxLifecycleSnapshot; export interface SandboxProvider { readonly name: SandboxProviderName; - readonly capabilities: { pauseResume: boolean; workspaceCache: boolean }; + /** `leaseRenewal`: the Orchestrator must periodically extend a timed lease (Cloudflare uses keepalive instead). */ + readonly capabilities: { pauseResume: boolean; workspaceCache: boolean; leaseRenewal: boolean }; create(options: { sessionId: string; leaseMs: number }): Promise; /** `secret` is the opaque value returned as `handle.secret` at create time. */ connect(ref: SandboxRef, options?: { leaseMs?: number; secret?: string }): Promise; diff --git a/packages/worker/test/helpers/fake-host.mjs b/packages/worker/test/helpers/fake-host.mjs index c8dabe7..73bee45 100644 --- a/packages/worker/test/helpers/fake-host.mjs +++ b/packages/worker/test/helpers/fake-host.mjs @@ -1,6 +1,10 @@ import { isValidTransition } from "../../../shared/dist/index.js"; import { createAgentRun } from "../../dist/agent-runs.js"; -import { terminateSandbox as terminateSandboxForHost } from "../../dist/orchestrator/sandbox-resume.js"; +import { + recordSessionActivity, + terminateSandbox as terminateSandboxForHost, +} from "../../dist/orchestrator/sandbox-session-lifecycle.js"; +import { closeSandboxSockets as closeSandboxSocketsOnCtx } from "../../dist/sandbox-connection.js"; const actor = { id: "usr_test", name: "Tester" }; @@ -84,7 +88,7 @@ export function createFakeSandboxHandle(overrides = {}) { export function createFakeSandboxProvider(overrides = {}) { return { name: "cloudflare", - capabilities: { pauseResume: false, workspaceCache: true }, + capabilities: { pauseResume: false, workspaceCache: true, leaseRenewal: false }, create: async () => createFakeSandboxHandle(), connect: async () => createFakeSandboxHandle(), ...overrides, @@ -99,6 +103,22 @@ export function createFakeTracer() { }; } +/** + * A Durable Object sandbox socket double. `close()` deliberately leaves + * `readyState` OPEN (the worst case for a socket whose paused peer has not yet + * answered the close handshake) so only the `closing` attachment marks it dead. + */ +export function createFakeSandboxSocket(attachment = { sandbox: { aud: "sandbox_ws", role: "sandbox" } }) { + let current = attachment; + return { + readyState: 1, + closeCalls: [], + serializeAttachment(value) { current = value; }, + deserializeAttachment() { return current; }, + close(code, reason) { this.closeCalls.push([code, reason]); }, + }; +} + /** A tracer that records `log` calls so tests can assert on (redacted) log output. */ export function createRecordingTracer() { const logs = []; @@ -140,7 +160,7 @@ export function createFakeE2BHandle(options = {}) { export function createFakeE2BProvider(handle, options = {}) { const provider = { name: "e2b", - capabilities: { pauseResume: true, workspaceCache: false }, + capabilities: { pauseResume: true, workspaceCache: false, leaseRenewal: true }, connectCalls: 0, connectOptions: [], create: async () => handle, @@ -167,7 +187,8 @@ export function createFakeHost(metaOverrides = {}, options = {}) { // `e2b: true` swaps in an E2B-like provider and a call-recording handle. const e2bHandle = options.e2b ? createFakeE2BHandle(options) : undefined; const e2bProvider = options.e2b ? createFakeE2BProvider(e2bHandle, options) : undefined; - const sandboxSockets = options.sandboxConnected === false ? [] : [{}]; + const sandboxSockets = options.sandboxConnected === false ? [] : [createFakeSandboxSocket()]; + let armCalls = 0; const closedSandboxSockets = []; const activity = { count: 0 }; const resumeRequests = { count: 0 }; @@ -261,20 +282,22 @@ export function createFakeHost(metaOverrides = {}, options = {}) { decisionRejection(_host, _action, fallbackMessage) { return { type: "error", message: fallbackMessage }; }, - armNextAlarm: async () => {}, + armNextAlarm: async () => { armCalls += 1; }, sandboxProvider() { return options.sandboxProvider ?? e2bProvider ?? createFakeSandboxProvider(); }, recordActivity() { activity.count += 1; - meta.last_activity_at = new Date().toISOString(); + recordSessionActivity(host); }, requestSandboxResume() { resumeRequests.count += 1; }, closeSandboxSockets(reason) { closedSandboxSockets.push(reason); - sandboxSockets.length = 0; + // `lingerClosedSockets`: closed sockets stay in getWebSockets, marked only by the closing attachment. + if (options.lingerClosedSockets) closeSandboxSocketsOnCtx(host.ctx, reason); + else sandboxSockets.length = 0; }, async issueSandboxWebSocketToken() { return "fresh_token"; @@ -283,6 +306,8 @@ export function createFakeHost(metaOverrides = {}, options = {}) { return terminateSandboxForHost(host, reason); }, async sandboxHandle() { + // Mirrors connectSandboxHandle: a paused sandbox is never woken by a plain handle lookup. + if (meta.sandbox_paused_at) return null; if (options.sandboxHandle !== undefined) return options.sandboxHandle; if (e2bHandle) return e2bHandle; return createFakeSandboxHandle({ @@ -305,6 +330,9 @@ export function createFakeHost(metaOverrides = {}, options = {}) { closedSandboxSockets, activity, resumeRequests, + get armCalls() { + return armCalls; + }, get saveMetaCalls() { return saveMetaCalls; }, diff --git a/packages/worker/test/question-answer.test.mjs b/packages/worker/test/question-answer.test.mjs index 6fa88b2..d5a7a23 100644 --- a/packages/worker/test/question-answer.test.mjs +++ b/packages/worker/test/question-answer.test.mjs @@ -43,7 +43,10 @@ function question(overrides = {}) { function fixture(row = question(), options = {}) { const broadcasts = []; const sandboxMessages = []; - const sandboxSockets = options.sandboxConnected === false ? [] : [{}]; + // `closingSocket`: the DO closed the agent's socket (pause) but it still lingers in getWebSockets. + const sandboxSockets = options.closingSocket + ? [{ deserializeAttachment: () => ({ sandbox: {}, closing: true }) }] + : options.sandboxConnected === false ? [] : [{}]; const host = { meta: { session_id: "ses_123", created_by: { id: "creator", name: "Creator" } }, ctx: { @@ -186,6 +189,17 @@ test("Slack answer validation rejects invalid ordinals and cardinality", async ( } }); +test("a lingering DO-closed sandbox socket does not count as an attached sandbox", async () => { + const state = fixture(question(), { closingSocket: true }); + const result = await answer(state.host, { + requestId: "question_1", + optionIndexes: [0], + actor: slackActor, + }); + assert.equal(result.status, "sandbox_unavailable"); + assert.equal(state.row.status, "open"); +}); + test("Slack answer fails without consuming the question when the sandbox is unavailable", async () => { const state = fixture(question(), { sandboxConnected: false }); const result = await answer(state.host, { diff --git a/packages/worker/test/sandbox-access.test.mjs b/packages/worker/test/sandbox-access.test.mjs index 917cfcc..006a0b3 100644 --- a/packages/worker/test/sandbox-access.test.mjs +++ b/packages/worker/test/sandbox-access.test.mjs @@ -22,7 +22,7 @@ function recordingProvider(name) { connects, provider: { name, - capabilities: { pauseResume: false, workspaceCache: false }, + capabilities: { pauseResume: false, workspaceCache: false, leaseRenewal: false }, create: async () => assert.fail("create must not be called"), connect: async (ref, options) => { connects.push([ref, options]); diff --git a/packages/worker/test/sandbox-handlers.test.mjs b/packages/worker/test/sandbox-handlers.test.mjs index d91d0e5..839cef5 100644 --- a/packages/worker/test/sandbox-handlers.test.mjs +++ b/packages/worker/test/sandbox-handlers.test.mjs @@ -490,7 +490,7 @@ test("provisionSessionSandbox creates through the provider, records the ref, res startProcess: async (command, options) => calls.push(["startProcess", options.processId, options.cwd]), }); const provider = createFakeSandboxProvider({ - create: async (options) => { calls.push(["create", options]); return handle; }, + create: async (options) => { calls.push(["create", options.sessionId]); return handle; }, }); const { host } = createFakeHost( { state: "initializing", max_time: "30m", created_at: new Date().toISOString() }, @@ -501,20 +501,45 @@ test("provisionSessionSandbox creates through the provider, records the ref, res assert.equal(host.meta.state, "provisioning_sandbox"); assert.deepEqual(host.meta.sandbox_ref, { provider: "cloudflare", id: "ses_test" }); - assert.ok(host.meta.sandbox_lease_renewed_at); - // The lease is the time left until max_time, measured from Session creation. - const [, createOptions] = calls[0]; - assert.ok(createOptions.leaseMs <= 30 * 60_000 && createOptions.leaseMs > 30 * 60_000 - 5_000); - calls[0][1] = { sessionId: createOptions.sessionId }; + // Cloudflare uses keepalive, not a timed lease: no renewal bookkeeping. + assert.equal(host.meta.sandbox_lease_renewed_at, undefined); assert.equal(host.meta.workspace_cache_restored, false); assert.equal(await host.ctx.storage.get("codevil:sandbox_secret"), "handle-secret"); assert.ok(host.redactionSecrets.includes("handle-secret")); assert.deepEqual(calls, [ - ["create", { sessionId: "ses_test" }], + ["create", "ses_test"], ["startProcess", "codevil-agent", "/workspace"], ]); }); +test("provisionSessionSandbox gives a lease-based provider the time left until max_time and records the renewal clock", async () => { + const leases = []; + const handle = createFakeSandboxHandle({ ref: { provider: "e2b", id: "sbx_1" } }); + const provider = createFakeSandboxProvider({ + name: "e2b", + capabilities: { pauseResume: true, workspaceCache: false, leaseRenewal: true }, + create: async (options) => { leases.push(options.leaseMs); return handle; }, + }); + const tenMinutesAgo = Date.now() - 10 * 60_000; + const fresh = createFakeHost( + { state: "initializing", max_time: "30m", created_at: new Date(Date.now()).toISOString() }, + { tracer: createFakeTracer(), sandboxProvider: provider, workerEnv: provisioningEnv() }, + ); + const older = createFakeHost( + { state: "initializing", max_time: "2h", created_at: new Date(tenMinutesAgo).toISOString() }, + { tracer: createFakeTracer(), sandboxProvider: provider, workerEnv: { ...provisioningEnv(), E2B_MAX_SANDBOX_SECONDS: "600" } }, + ); + + await provisionSessionSandbox(fresh.host); + await provisionSessionSandbox(older.host); + + // Remaining max_time (about 30m), and a 2h session clamped to the provider's cap. + assert.ok(leases[0] <= 30 * 60_000 && leases[0] > 30 * 60_000 - 5_000); + assert.equal(leases[1], 600_000); + assert.ok(fresh.host.meta.sandbox_lease_renewed_at); + assert.ok(older.host.meta.sandbox_lease_renewed_at); +}); + test("provisionSessionSandbox through the Cloudflare adapter keeps keepalive, then restore, then agent start", async () => { const calls = []; const sandbox = { @@ -559,7 +584,7 @@ test("provisionSessionSandbox skips the workspace cache when the provider has no workspaceCache: { restoreBackup: async () => { restored = true; }, createBackup: async () => ({}) }, }); const provider = createFakeSandboxProvider({ - capabilities: { pauseResume: false, workspaceCache: false }, + capabilities: { pauseResume: false, workspaceCache: false, leaseRenewal: false }, create: async () => handle, }); const { host } = createFakeHost( @@ -581,7 +606,7 @@ test("handleSandboxCloneComplete does not enqueue cache work for a provider with { sql, sandboxProvider: createFakeSandboxProvider({ - capabilities: { pauseResume: false, workspaceCache: false }, + capabilities: { pauseResume: false, workspaceCache: false, leaseRenewal: false }, }), }, ); diff --git a/packages/worker/test/sandbox-provider-cloudflare.test.mjs b/packages/worker/test/sandbox-provider-cloudflare.test.mjs index 09fa31c..0da184b 100644 --- a/packages/worker/test/sandbox-provider-cloudflare.test.mjs +++ b/packages/worker/test/sandbox-provider-cloudflare.test.mjs @@ -37,7 +37,7 @@ test("cloudflare create enables keepalive and uses the session id as the ref", a assert.deepEqual(handle.ref, { provider: "cloudflare", id: "ses_1" }); assert.deepEqual(seen, [["ses_1", { keepAlive: true }]]); assert.deepEqual(sandbox.calls, [["setKeepAlive", true], ["setCodevilKeepAlive", true, "session provisioning"]]); - assert.deepEqual(provider.capabilities, { pauseResume: false, workspaceCache: true }); + assert.deepEqual(provider.capabilities, { pauseResume: false, workspaceCache: true, leaseRenewal: false }); assert.equal(handle.pause, undefined); assert.equal(handle.workspaceCache, sandbox); }); diff --git a/packages/worker/test/sandbox-provider-e2b.test.mjs b/packages/worker/test/sandbox-provider-e2b.test.mjs index c33d232..49027f4 100644 --- a/packages/worker/test/sandbox-provider-e2b.test.mjs +++ b/packages/worker/test/sandbox-provider-e2b.test.mjs @@ -71,7 +71,7 @@ test("create locks public traffic, caps the lease, and returns the traffic token network: { allowPublicTraffic: false }, metadata: { codevil_session_id: "ses_1" }, }]); - assert.deepEqual(p.capabilities, { pauseResume: true, workspaceCache: false }); + assert.deepEqual(p.capabilities, { pauseResume: true, workspaceCache: false, leaseRenewal: true }); }); test("create keeps a lease below the cap", async () => { diff --git a/packages/worker/test/sandbox-resume.test.mjs b/packages/worker/test/sandbox-session-lifecycle.test.mjs similarity index 61% rename from packages/worker/test/sandbox-resume.test.mjs rename to packages/worker/test/sandbox-session-lifecycle.test.mjs index af169a4..c7e86dc 100644 --- a/packages/worker/test/sandbox-resume.test.mjs +++ b/packages/worker/test/sandbox-session-lifecycle.test.mjs @@ -3,8 +3,13 @@ import test from "node:test"; import { handleAgentRequest } from "../dist/orchestrator/cli-handlers.js"; import { hashPreviewToken, proxyPreviewRequest } from "../dist/orchestrator/preview.js"; import { drainQueuedAgentWorkIfReady } from "../dist/orchestrator/sandbox-handlers.js"; +import { connectSandboxHandle, sandboxDiagnosticsResponse, sandboxLogsResponse } from "../dist/orchestrator/sandbox-access.js"; +import { handlePreviewStart, handlePreviewStop } from "../dist/orchestrator/cli-handlers.js"; import { clearStalePausedMarker, + flushPendingPreviewStart, + idlePauseInput, + recordSessionActivity, expireSessionAtMaxTime, isSandboxPausing, pauseIdleSandbox, @@ -14,11 +19,24 @@ import { sandboxAlarmDeadlines, sandboxSocketAttached, terminateSandbox, -} from "../dist/orchestrator/sandbox-resume.js"; -import { isUnexpectedSandboxDisconnect, sandboxConnectionMode } from "../dist/sandbox-connection.js"; +} from "../dist/orchestrator/sandbox-session-lifecycle.js"; +import { + closeSandboxSockets, + isLiveSandboxSocket, + isUnexpectedSandboxDisconnect, + liveSandboxSockets, + sandboxConnectionMode, +} from "../dist/sandbox-connection.js"; import { SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; import { sandboxProviderMaxLeaseMs } from "../dist/sandbox-provider/index.js"; -import { actor, createFakeHost, createRecordingTracer } from "./helpers/fake-host.mjs"; +import { + actor, + createFakeHost, + createFakeSandboxSocket, + createFakeSandboxProvider, + createFakeSql, + createRecordingTracer, +} from "./helpers/fake-host.mjs"; const T0 = Date.parse("2026-10-01T00:00:00.000Z"); const idleMeta = { @@ -293,13 +311,16 @@ test("a session stopped mid-resume stays failed and is not resurrected", async ( const resuming = resumeSandbox(host); await new Promise((resolve) => setImmediate(resolve)); - host.meta.state = "failed"; - host.meta.sandbox_paused_at = undefined; + // Stop the session the way the Orchestrator does: fail it, then tear the sandbox down. + host.transition("failed"); + await terminateSandbox(host, "stopped by user"); release(); await resuming; assert.equal(host.meta.state, "failed"); + assert.equal(host.meta.sandbox_paused_at, undefined); assert.equal(host.meta.expected_close, true); + assert.ok(!callNames(handle).includes("writeFile"), "no token is written for a resume that lost the race"); }); test("after resume the queued run starts exactly once when the agent reconnects", async () => { @@ -330,14 +351,37 @@ test("a paused sandbox reconnects in resume mode", () => { assert.equal(sandboxConnectionMode("ready", undefined, 1), "reject"); }); -test("a late close of a pause-closed socket is not an interruption", () => { - const base = { state: "ready", otherSandboxSocketsAttached: false }; - assert.equal(isUnexpectedSandboxDisconnect({ ...base }), true); - // Closed by the DO (pause or teardown). - assert.equal(isUnexpectedSandboxDisconnect({ ...base, expectedClose: true }), false); - // The agent already reconnected on a newer socket; this is the old socket's late close. - assert.equal(isUnexpectedSandboxDisconnect({ ...base, otherSandboxSocketsAttached: true }), false); - assert.equal(isUnexpectedSandboxDisconnect({ ...base, state: "failed" }), false); +test("a late close of the old socket is ignored only when a newer LIVE socket exists", () => { + const oldSocket = createFakeSandboxSocket(); + const base = { state: "ready", closedSocket: oldSocket }; + + // The agent dropped and nothing replaced it: a real interruption (current socket's close is handled). + assert.equal(isUnexpectedSandboxDisconnect({ ...base, sandboxSockets: [oldSocket] }), true); + // The agent already reconnected on a newer live socket: the old socket's late close is stale. + assert.equal(isUnexpectedSandboxDisconnect({ ...base, sandboxSockets: [oldSocket, createFakeSandboxSocket()] }), false); + // A newer socket the DO already closed, or one that is not OPEN, is not a live replacement. + const closing = createFakeSandboxSocket({ closing: true }); + const closed = Object.assign(createFakeSandboxSocket(), { readyState: 3 }); + assert.equal(isUnexpectedSandboxDisconnect({ ...base, sandboxSockets: [oldSocket, closing, closed] }), true); + // Closes the DO initiated, and closes after the session ended, are never interruptions. + assert.equal(isUnexpectedSandboxDisconnect({ ...base, sandboxSockets: [oldSocket], expectedClose: true }), false); + assert.equal(isUnexpectedSandboxDisconnect({ ...base, sandboxSockets: [oldSocket], state: "failed" }), false); +}); + +test("live sandbox sockets exclude sockets the DO closed or that are not OPEN", () => { + const live = createFakeSandboxSocket(); + const closing = createFakeSandboxSocket(); + const notOpen = Object.assign(createFakeSandboxSocket(), { readyState: 2 }); + const bare = {}; + const ctx = { getWebSockets: (tag) => (tag === "sandbox" ? [live, closing, notOpen, bare] : []) }; + + closeSandboxSockets({ getWebSockets: () => [closing] }, "sandbox paused"); + + assert.deepEqual(closing.closeCalls, [[1000, "sandbox paused"]]); + // The closing flag merges into the existing attachment instead of replacing it. + assert.deepEqual(closing.deserializeAttachment(), { sandbox: { aud: "sandbox_ws", role: "sandbox" }, closing: true }); + assert.equal(isLiveSandboxSocket(closing), false); + assert.deepEqual(liveSandboxSockets(ctx), [live, bare]); }); test("a stale paused marker with a live agent socket is cleared; a real pause is not", () => { @@ -576,6 +620,17 @@ test("an authenticated preview request resumes a paused sandbox, records activit assert.equal(fake.activity.count, 1); }); +test("the preview resume path writes a fresh ws token before the agent reconnects", async () => { + const fake = await previewHost({}); + await proxy(fake, PREVIEW_TOKEN); + assert.deepEqual(fake.handle.calls.find(([name]) => name === "writeFile"), [ + "writeFile", + "/run/codevil/ws-token", + "fresh_token", + { mode: 0o600, owner: "codevil" }, + ]); +}); + test("an authenticated preview request on a running sandbox records activity without resuming", async () => { const fake = await previewHost({ sandbox_paused_at: undefined, expected_close: false }); const response = await proxy(fake, PREVIEW_TOKEN); @@ -627,3 +682,292 @@ test("a preview request on a session whose resume failed answers 410, not a prox assert.equal(response.status, 410); assert.equal(fake.host.meta.state, "failed"); }); + +// --- live sockets: a DO-closed socket may linger while its paused peer is frozen --- + +for (const linger of [false, true]) { + const mode = linger ? "closed sockets linger in getWebSockets" : "closed sockets are removed"; + + test(`pause, request, resume, reconnect (${mode})`, async () => { + const { host, sandboxSockets, sandboxMessages, resumeRequests } = createFakeHost(idleMeta, { + e2b: true, + sandboxConnected: true, + lingerClosedSockets: linger, + }); + + assert.equal(await pauseIdleSandbox(host, T0 + 600_000), true); + assert.equal(host.ctx.getWebSockets("sandbox").length, linger ? 1 : 0); + assert.equal(liveSandboxSockets(host.ctx).length, 0); + assert.equal(sandboxSocketAttached(host), false); + + handleAgentRequest(host, "wake up", actor, false); + assert.equal(host.meta.queued_runs.length, 1); + assert.equal(resumeRequests.count, 1); + drainQueuedAgentWorkIfReady(host); + assert.deepEqual(sandboxMessages, []); + + await resumeSandbox(host); + // The lingering socket must not suppress the reconnect grace. + assert.ok(host.meta.sandbox_disconnected_at); + // The agent's reconnect is judged by live sockets only, so it is accepted in resume mode. + assert.equal( + sandboxConnectionMode(host.meta.state, host.meta.sandbox_disconnected_at, liveSandboxSockets(host.ctx).length), + "resume", + ); + assert.equal( + sandboxConnectionMode(host.meta.state, undefined, liveSandboxSockets(host.ctx).length), + "resume", + ); + drainQueuedAgentWorkIfReady(host); + assert.deepEqual(sandboxMessages, []); + + sandboxSockets.push(createFakeSandboxSocket()); + drainQueuedAgentWorkIfReady(host); + drainQueuedAgentWorkIfReady(host); + assert.deepEqual(sandboxMessages.map((message) => message.type), ["agent_turn"]); + assert.equal(host.meta.queued_runs.length, 0); + assert.ok(host.meta.active_run); + }); +} + +// --- the paused VM is never woken by a plain handle lookup --- + +test("a handle lookup on a paused session returns null without connecting", async () => { + let connects = 0; + const provider = createFakeSandboxProvider({ name: "e2b", connect: async () => { connects += 1; return {}; } }); + const meta = { session_id: "ses_test", sandbox_ref: { provider: "e2b", id: "sbx_1" } }; + const paused = await connectSandboxHandle({ + meta: { ...meta, sandbox_paused_at: "2026-10-01T00:10:00.000Z" }, + provider, + readSecret: async () => undefined, + }); + assert.equal(paused, null); + assert.equal(connects, 0); + + await connectSandboxHandle({ meta, provider, readSecret: async () => undefined }); + assert.equal(connects, 1); +}); + +test("logs and diagnostics of a paused sandbox answer paused without waking the VM", async () => { + const resolve = async () => assert.fail("must not resolve the sandbox"); + for (const respond of [sandboxLogsResponse, sandboxDiagnosticsResponse]) { + const response = await respond(resolve, [], { paused: true }); + assert.equal(response.status, 200); + assert.equal((await response.json()).paused, true); + } +}); + +// --- teardown races --- + +test("stop during an in-flight pause waits for it and then destroys the sandbox", async () => { + const { host, handle } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + let release; + handle.pause = () => new Promise((resolve) => { release = resolve; handle.calls.push(["pause"]); }); + + const pausing = pauseIdleSandbox(host, T0 + 600_000); + await new Promise((resolve) => setImmediate(resolve)); + host.transition("failed"); + const terminating = terminateSandbox(host, "stopped by user"); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(!callNames(handle).includes("destroy"), "destroy must wait for the pause to settle"); + + release(); + assert.equal(await pausing, false); + await terminating; + assert.deepEqual(callNames(handle), ["pause", "destroy"]); + assert.equal(host.meta.sandbox_paused_at, undefined); +}); + +// --- failed pause recovery --- + +test("a failed pause releases a run that queued during it and re-arms the alarm", async () => { + const fake = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + const { host, handle, sandboxMessages } = fake; + let fail; + handle.pause = () => new Promise((_, reject) => { fail = reject; }); + + const pausing = pauseIdleSandbox(host, T0 + 600_000); + await new Promise((resolve) => setImmediate(resolve)); + handleAgentRequest(host, "queued mid-pause", actor, false); + assert.equal(host.meta.queued_runs.length, 1); + assert.deepEqual(sandboxMessages, []); + const armsBefore = fake.armCalls; + + fail(new Error("pause rejected")); + assert.equal(await pausing, false); + + assert.equal(host.meta.expected_close, false); + assert.equal(host.meta.queued_runs.length, 0); + assert.deepEqual(sandboxMessages.map((message) => message.type), ["agent_turn"]); + assert.ok(fake.armCalls > armsBefore); +}); + +test("a failed pause with no live socket left starts the reconnect grace", async () => { + const fake = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + const { host, handle, sandboxSockets, broadcasts } = fake; + // The agent dropped while expected_close was set, so the drop was ignored. + handle.pause = async () => { + sandboxSockets.length = 0; + throw new Error("pause rejected"); + }; + const armsBefore = fake.armCalls; + + assert.equal(await pauseIdleSandbox(host, T0 + 600_000), false); + + assert.equal(host.meta.expected_close, false); + assert.ok(host.meta.sandbox_disconnected_at); + assert.ok(broadcasts.some((event) => event.type === "status" && /interrupted/.test(event.message))); + assert.ok(fake.armCalls > armsBefore); +}); + +test("a failed pause with a live socket does not start the reconnect grace", async () => { + const { host } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true, pauseError: new Error("boom") }); + await pauseIdleSandbox(host, T0 + 600_000); + assert.equal(host.meta.sandbox_disconnected_at, undefined); +}); + +// --- preview_start / preview_stop while paused --- + +test("preview_start on a paused session resumes, defers, and sends once the agent reconnects", async () => { + const { host, resumeRequests, activity, broadcasts, sandboxMessages, sandboxSockets } = createFakeHost(pausedMeta, { + e2b: true, + sandboxConnected: false, + }); + + await handlePreviewStart(host, "web"); + + assert.equal(resumeRequests.count, 1); + assert.equal(activity.count, 1); + assert.deepEqual(host.meta.pending_preview_start, { app_key: "web" }); + assert.deepEqual(sandboxMessages, []); + assert.ok(broadcasts.some((event) => event.type === "status" && event.message === "Sandbox is resuming; the preview will start when it reconnects.")); + assert.ok(!broadcasts.some((event) => event.type === "error")); + + await resumeSandbox(host); + // Resumed, but the agent has not reconnected yet. + assert.equal(flushPendingPreviewStart(host), false); + sandboxSockets.push(createFakeSandboxSocket()); + assert.equal(flushPendingPreviewStart(host), true); + assert.equal(flushPendingPreviewStart(host), false); + assert.deepEqual(sandboxMessages.map((message) => [message.type, message.app_key]), [["preview_start", "web"]]); + assert.equal(host.meta.pending_preview_start, undefined); +}); + +test("preview_start on a running session is sent straight away", async () => { + const { host, sandboxMessages, resumeRequests } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + await handlePreviewStart(host, "web"); + assert.deepEqual(sandboxMessages.map((message) => message.type), ["preview_start"]); + assert.equal(resumeRequests.count, 0); + assert.equal(host.meta.pending_preview_start, undefined); +}); + +test("preview_start during an in-flight pause is sent after the pause fails", async () => { + const { host, handle, sandboxMessages } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + let fail; + handle.pause = () => new Promise((_, reject) => { fail = reject; }); + + const pausing = pauseIdleSandbox(host, T0 + 600_000); + await new Promise((resolve) => setImmediate(resolve)); + await handlePreviewStart(host); + assert.ok(host.meta.pending_preview_start); + assert.deepEqual(sandboxMessages, []); + + fail(new Error("pause rejected")); + await pausing; + assert.deepEqual(sandboxMessages.map((message) => message.type), ["preview_start"]); + assert.equal(host.meta.pending_preview_start, undefined); +}); + +test("preview_stop on a paused session clears the deferred start and does not error", async () => { + const { host, broadcasts, sandboxMessages } = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: false }); + await handlePreviewStart(host, "web"); + await handlePreviewStop(host); + assert.equal(host.meta.pending_preview_start, undefined); + assert.deepEqual(sandboxMessages, []); + assert.ok(!broadcasts.some((event) => event.type === "error")); +}); + +test("a deferred preview start is dropped when the session fails", async () => { + const { host } = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: false, connectError: new SandboxNotFoundError() }); + await handlePreviewStart(host, "web"); + await resumeSandbox(host); + assert.equal(host.meta.state, "failed"); + assert.equal(host.meta.pending_preview_start, undefined); +}); + +// --- activity --- + +test("recordSessionActivity throttles only the write, never the alarm re-arm", () => { + const fake = createFakeHost( + { ...idleMeta, last_activity_at: "2026-10-01T00:00:00.000Z" }, + { e2b: true, sandboxConnected: true }, + ); + const { host } = fake; + + recordSessionActivity(host, T0 + 5_000); + assert.equal(host.meta.last_activity_at, new Date(T0 + 5_000).toISOString()); + const writes = fake.saveMetaCalls; + const arms = fake.armCalls; + + // Within the throttle window: no write, but the idle alarm is still re-armed. + recordSessionActivity(host, T0 + 5_400); + assert.equal(host.meta.last_activity_at, new Date(T0 + 5_000).toISOString()); + assert.equal(fake.saveMetaCalls, writes); + assert.equal(fake.armCalls, arms + 1); +}); + +test("recordSessionActivity does not re-arm when no idle deadline applies", () => { + const fake = createFakeHost( + { ...idleMeta, active_run: { id: "run_1", state: "thinking" } }, + { e2b: true, sandboxConnected: true }, + ); + recordSessionActivity(fake.host, T0 + 5_000); + assert.equal(fake.armCalls, 0); +}); + +test("recordSessionActivity leaves a Cloudflare session untouched", () => { + const fake = createFakeHost( + { state: "ready", last_activity_at: "2026-10-01T00:00:00.000Z" }, + { sandboxConnected: true }, + ); + recordSessionActivity(fake.host, T0 + 60_000); + assert.equal(fake.host.meta.last_activity_at, "2026-10-01T00:00:00.000Z"); + assert.equal(fake.saveMetaCalls, 0); + assert.equal(fake.armCalls, 0); +}); + +test("an open question blocks the pause", () => { + const sql = createFakeSql({ questions: [{ request_id: "q1", run_id: "run_1", status: "open" }] }); + const withRun = createFakeHost( + { ...idleMeta, active_run: { id: "run_1", state: "thinking" } }, + { e2b: true, sandboxConnected: true, sql }, + ); + assert.equal(idlePauseInput(withRun.host, T0 + 3_600_000).openQuestions, 1); + + // Questions only exist for a run; with no active run nothing counts. + const noRun = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true, sql }); + assert.equal(idlePauseInput(noRun.host, T0 + 3_600_000).openQuestions, 0); +}); + +// --- lease renewal is its own capability --- + +test("lease renewal follows the leaseRenewal capability, not pauseResume", async () => { + const withCapabilities = (capabilities) => { + const fake = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + const provider = fake.provider; + fake.host.sandboxProvider = () => ({ ...provider, capabilities: { workspaceCache: false, ...capabilities } }); + return fake; + }; + + const pauseOnly = withCapabilities({ pauseResume: true, leaseRenewal: false }); + await renewSandboxLeaseIfDue(pauseOnly.host, T0 + 60 * 60_000); + assert.equal(pauseOnly.handle.calls.length, 0); + assert.equal(pauseOnly.host.meta.sandbox_lease_renewed_at, undefined); + assert.equal(sandboxAlarmDeadlines(pauseOnly.host).leaseRenewAt, null); + assert.equal(sandboxAlarmDeadlines(pauseOnly.host).idlePauseAt, T0 + 600_000); + + const leaseOnly = withCapabilities({ pauseResume: false, leaseRenewal: true }); + await renewSandboxLeaseIfDue(leaseOnly.host, T0 + 5 * 60_000); + assert.deepEqual(callNames(leaseOnly.handle), ["renewLease"]); + assert.equal(sandboxAlarmDeadlines(leaseOnly.host).idlePauseAt, null); +}); From 65ff66bbc83871d3b41c2fe21372df9497128d0d Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 08:13:45 +0530 Subject: [PATCH 20/32] fix(sandbox): flush deferred preview after resume and cover the reconnect window --- packages/worker/src/orchestrator.ts | 4 +- packages/worker/src/orchestrator/host.ts | 2 +- .../orchestrator/sandbox-session-lifecycle.ts | 45 ++++++- packages/worker/src/sandbox-connection.ts | 9 +- packages/worker/test/helpers/fake-host.mjs | 4 +- .../test/sandbox-session-lifecycle.test.mjs | 120 ++++++++++++++++++ 6 files changed, 172 insertions(+), 12 deletions(-) diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index 2d4b3ab..ae6ab71 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -785,8 +785,8 @@ export class Orchestrator extends DurableObject implements OrchestratorHost await terminateSandboxFn(this, reason); } - recordActivity(): void { - recordSessionActivity(this); + recordActivity(source?: "event" | "preview"): void { + recordSessionActivity(this, Date.now(), source); } requestSandboxResume(): void { diff --git a/packages/worker/src/orchestrator/host.ts b/packages/worker/src/orchestrator/host.ts index 7f54e8b..03b1500 100644 --- a/packages/worker/src/orchestrator/host.ts +++ b/packages/worker/src/orchestrator/host.ts @@ -52,7 +52,7 @@ export interface OrchestratorHost { armNextAlarm(now?: number): Promise; sandboxProvider(): SandboxProvider; /** Records user-visible activity (Agent Request, run finish, authenticated preview) for the idle-pause clock. */ - recordActivity(): void; + recordActivity(source?: "event" | "preview"): void; /** Starts a background resume of a paused sandbox; concurrent calls share one resume. */ requestSandboxResume(): void; /** Closes the DO's own sandbox sockets (callers set `expected_close` first when the close is intentional). */ diff --git a/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts b/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts index 913be82..5e17387 100644 --- a/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts +++ b/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts @@ -81,20 +81,33 @@ function providerSupportsPause(host: OrchestratorHost): boolean { const ACTIVITY_WRITE_INTERVAL_MS = 1_000; +/** `preview`: high-frequency authenticated preview traffic; `event`: everything else. */ +export type ActivitySource = "event" | "preview"; + /** * Counts a user-visible action toward the idle-pause clock. A no-op for * providers that cannot pause (their meta stays untouched). Only the meta write * is throttled; the alarm is always re-armed while an idle deadline exists so a * run that finishes right after a request still gets its idle alarm. */ -export function recordSessionActivity(host: OrchestratorHost, now: number = Date.now()): void { +export function recordSessionActivity( + host: OrchestratorHost, + now: number = Date.now(), + source: ActivitySource = "event", +): void { const meta = host.meta; if (!meta || !providerSupportsPause(host)) return; const lastMs = meta.last_activity_at ? Date.parse(meta.last_activity_at) : Number.NaN; - if (!(Number.isFinite(lastMs) && now - lastMs >= 0 && now - lastMs < ACTIVITY_WRITE_INTERVAL_MS)) { + const throttled = Number.isFinite(lastMs) && now - lastMs >= 0 && now - lastMs < ACTIVITY_WRITE_INTERVAL_MS; + if (!throttled) { meta.last_activity_at = new Date(now).toISOString(); host.saveMeta(); } + // Discrete events (requests, run finishes) can change whether an idle deadline + // applies, so they always re-arm. Preview traffic arrives in bursts and cannot + // change eligibility, so it re-arms only when the idle deadline actually moved + // (a write happened). + if (source === "preview" && throttled) return; if (sandboxAlarmDeadlines(host).idlePauseAt !== null) void armAlarmSafely(host); } @@ -300,6 +313,10 @@ async function completeResume(host: OrchestratorHost, now: number): Promise { - host.recordActivity(); + host.recordActivity("preview"); if (!host.meta?.sandbox_paused_at && !pausing.has(host)) return null; const resume = resumeSandbox(host); diff --git a/packages/worker/src/sandbox-connection.ts b/packages/worker/src/sandbox-connection.ts index 89f23bb..de15eeb 100644 --- a/packages/worker/src/sandbox-connection.ts +++ b/packages/worker/src/sandbox-connection.ts @@ -37,13 +37,18 @@ const WEBSOCKET_OPEN = 1; */ export function isLiveSandboxSocket(socket: SandboxSocketLike): boolean { if (typeof socket.readyState === "number" && socket.readyState !== WEBSOCKET_OPEN) return false; + return !isClosingSandboxSocket(socket); +} + +/** True for a socket the DO closed on purpose (see `closeSandboxSockets`). */ +export function isClosingSandboxSocket(socket: SandboxSocketLike): boolean { let attachment: unknown; try { attachment = socket.deserializeAttachment?.(); } catch { attachment = undefined; } - return !(typeof attachment === "object" && attachment !== null && (attachment as { closing?: unknown }).closing === true); + return typeof attachment === "object" && attachment !== null && (attachment as { closing?: unknown }).closing === true; } export function liveSandboxSockets(ctx: { getWebSockets(tag?: string): T[] }): T[] { @@ -87,6 +92,8 @@ export function isUnexpectedSandboxDisconnect(input sandboxSockets: readonly T[]; }): boolean { if (input.expectedClose || isTerminalState(input.state)) return false; + // The DO closed this socket itself; its close may land after `expected_close` was reset (post-resume). + if (isClosingSandboxSocket(input.closedSocket)) return false; return !input.sandboxSockets.some((socket) => socket !== input.closedSocket && isLiveSandboxSocket(socket)); } diff --git a/packages/worker/test/helpers/fake-host.mjs b/packages/worker/test/helpers/fake-host.mjs index 73bee45..b11240c 100644 --- a/packages/worker/test/helpers/fake-host.mjs +++ b/packages/worker/test/helpers/fake-host.mjs @@ -286,9 +286,9 @@ export function createFakeHost(metaOverrides = {}, options = {}) { sandboxProvider() { return options.sandboxProvider ?? e2bProvider ?? createFakeSandboxProvider(); }, - recordActivity() { + recordActivity(source) { activity.count += 1; - recordSessionActivity(host); + recordSessionActivity(host, Date.now(), source); }, requestSandboxResume() { resumeRequests.count += 1; diff --git a/packages/worker/test/sandbox-session-lifecycle.test.mjs b/packages/worker/test/sandbox-session-lifecycle.test.mjs index c7e86dc..5f57c81 100644 --- a/packages/worker/test/sandbox-session-lifecycle.test.mjs +++ b/packages/worker/test/sandbox-session-lifecycle.test.mjs @@ -971,3 +971,123 @@ test("lease renewal follows the leaseRenewal capability, not pauseResume", async assert.deepEqual(callNames(leaseOnly.handle), ["renewLease"]); assert.equal(sandboxAlarmDeadlines(leaseOnly.host).idlePauseAt, null); }); + +// --- follow-ups: resume/reconnect interleaving, reconnect window, closing flag, preview bursts --- + +test("an agent that reconnects mid-resume still gets the deferred preview start and queued run", async () => { + const { host, provider, handle, sandboxSockets, sandboxMessages } = createFakeHost( + { ...pausedMeta, queued_runs: [queuedRun()] }, + { e2b: true, sandboxConnected: false }, + ); + let release; + provider.connect = () => new Promise((resolve) => { release = () => resolve(handle); }); + await handlePreviewStart(host, "web"); + const resuming = resumeSandbox(host); + await new Promise((resolve) => setImmediate(resolve)); + + // connect woke the VM; the agent reconnects with its old token before completeResume runs. + // The accept path drains and flushes, but the paused marker is still set. + sandboxSockets.push(createFakeSandboxSocket()); + assert.equal(flushPendingPreviewStart(host), false); + + release(); + await resuming; + + assert.equal(host.meta.pending_preview_start, undefined); + assert.deepEqual(sandboxMessages.map((message) => message.type).sort(), ["agent_turn", "preview_start"]); + // A later stop is a real stop, not swallowed as a cancelled deferred start. + await handlePreviewStop(host); + assert.ok(sandboxMessages.some((message) => message.type === "preview_stop")); +}); + +test("preview_start in the reconnect window after a resume is deferred until the agent returns", async () => { + const { host, sandboxMessages, sandboxSockets, broadcasts, resumeRequests } = createFakeHost( + { ...idleMeta, sandbox_disconnected_at: "2026-10-01T00:10:01.000Z" }, + { e2b: true, sandboxConnected: false }, + ); + + await handlePreviewStart(host, "web"); + + assert.deepEqual(host.meta.pending_preview_start, { app_key: "web" }); + assert.deepEqual(sandboxMessages, []); + assert.equal(resumeRequests.count, 1); + assert.ok(broadcasts.some((event) => event.type === "status" && event.message === "Sandbox is reconnecting; the preview will start when it reconnects.")); + assert.ok(!broadcasts.some((event) => event.type === "error")); + + sandboxSockets.push(createFakeSandboxSocket()); + host.meta.sandbox_disconnected_at = undefined; // completeSandboxReconnect + assert.equal(flushPendingPreviewStart(host), true); + assert.deepEqual(sandboxMessages.map((message) => message.type), ["preview_start"]); +}); + +test("preview_stop in the reconnect window has nothing to stop and does not error", async () => { + const { host, sandboxMessages, broadcasts } = createFakeHost( + { ...idleMeta, sandbox_disconnected_at: "2026-10-01T00:10:01.000Z" }, + { e2b: true, sandboxConnected: false }, + ); + await handlePreviewStop(host); + assert.deepEqual(sandboxMessages, []); + assert.ok(!broadcasts.some((event) => event.type === "error")); +}); + +test("preview_start is not deferred when the agent is attached, or for a provider that cannot pause", async () => { + const attached = createFakeHost( + { ...idleMeta, sandbox_disconnected_at: "2026-10-01T00:10:01.000Z" }, + { e2b: true, sandboxConnected: true }, + ); + await handlePreviewStart(attached.host, "web"); + assert.deepEqual(attached.sandboxMessages.map((message) => message.type), ["preview_start"]); + + // Cloudflare behavior is unchanged: the command goes straight to sendToSandbox. + const cloudflare = createFakeHost( + { state: "ready", sandbox_disconnected_at: "2026-10-01T00:10:01.000Z" }, + { sandboxConnected: false }, + ); + await handlePreviewStart(cloudflare.host, "web"); + assert.deepEqual(cloudflare.sandboxMessages.map((message) => message.type), ["preview_start"]); + assert.equal(cloudflare.host.meta.pending_preview_start, undefined); + assert.equal(cloudflare.resumeRequests.count, 0); + assert.equal(cloudflare.activity.count, 0); +}); + +test("a close of a socket the DO closed on purpose is never an interruption, even after expected_close reset", () => { + const closedByDo = createFakeSandboxSocket(); + closeSandboxSockets({ getWebSockets: () => [closedByDo] }, "sandbox paused"); + assert.equal( + isUnexpectedSandboxDisconnect({ expectedClose: false, state: "ready", closedSocket: closedByDo, sandboxSockets: [closedByDo] }), + false, + ); + // The agent's own socket dropping is still an interruption. + const dropped = createFakeSandboxSocket(); + assert.equal( + isUnexpectedSandboxDisconnect({ expectedClose: false, state: "ready", closedSocket: dropped, sandboxSockets: [dropped] }), + true, + ); +}); + +test("bursts of preview traffic re-arm the idle alarm only when the idle deadline moved", () => { + const fake = createFakeHost( + { ...idleMeta, last_activity_at: "2026-10-01T00:00:00.000Z" }, + { e2b: true, sandboxConnected: true }, + ); + const { host } = fake; + + recordSessionActivity(host, T0 + 5_000, "preview"); + assert.equal(fake.armCalls, 1); + for (const offset of [5_100, 5_300, 5_900]) recordSessionActivity(host, T0 + offset, "preview"); + assert.equal(fake.armCalls, 1, "requests inside the throttle window do not re-arm"); + + recordSessionActivity(host, T0 + 6_100, "preview"); + assert.equal(fake.armCalls, 2, "a request that moves the idle deadline re-arms"); + + // A discrete event can change idle eligibility, so it re-arms even inside the window. + recordSessionActivity(host, T0 + 6_200, "event"); + assert.equal(fake.armCalls, 3); +}); + +test("authenticated preview requests in a burst do not re-arm the alarm each time", async () => { + const fake = await previewHost({ sandbox_paused_at: undefined, expected_close: false, last_activity_at: "2026-10-01T00:00:00.000Z" }, { sandboxConnected: true }); + for (let i = 0; i < 5; i += 1) assert.equal((await proxy(fake, PREVIEW_TOKEN)).status, 200); + assert.equal(fake.activity.count, 5); + assert.equal(fake.armCalls, 1); +}); From 99787bc724fecf9120dea085fe72fc9aa1b3a378 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 08:16:29 +0530 Subject: [PATCH 21/32] fix(sandbox): replay the latest preview intent after resume --- packages/shared/src/session-meta-schema.ts | 9 +- packages/worker/src/orchestrator.ts | 4 +- .../worker/src/orchestrator/cli-handlers.ts | 9 +- .../orchestrator/sandbox-session-lifecycle.ts | 60 ++++----- .../worker/src/orchestrator/session-meta.ts | 11 +- .../test/sandbox-session-lifecycle.test.mjs | 123 ++++++++++++++---- 6 files changed, 155 insertions(+), 61 deletions(-) diff --git a/packages/shared/src/session-meta-schema.ts b/packages/shared/src/session-meta-schema.ts index afae068..ad83538 100644 --- a/packages/shared/src/session-meta-schema.ts +++ b/packages/shared/src/session-meta-schema.ts @@ -43,8 +43,13 @@ export const SessionMetaSchema = z.object({ last_activity_at: z.string().optional(), sandbox_paused_at: z.string().optional(), sandbox_lease_renewed_at: z.string().optional(), - /** A preview start requested while the sandbox was paused; sent once the agent reconnects. */ - pending_preview_start: z.object({ app_key: z.string().optional() }).optional(), + /** The latest preview command requested while the sandbox was paused or reconnecting; replayed once the agent reconnects. */ + pending_preview_action: z.object({ + type: z.enum(["start", "stop"]), + app_key: z.string().optional(), + }).optional(), + /** @deprecated Legacy start-only flag; migrated to `pending_preview_action` when meta is loaded. */ + pending_preview_start: z.union([z.literal(true), z.object({ app_key: z.string().optional() })]).optional(), last_decision: LastDecisionSchema.optional(), }); diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index ae6ab71..86d1d4d 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -130,7 +130,7 @@ import { renewSandboxLeaseIfDue, resumeSandbox, recordSessionActivity, - flushPendingPreviewStart, + flushPendingPreviewAction, sandboxAlarmDeadlines, terminateSandbox as terminateSandboxFn, } from "./orchestrator/sandbox-session-lifecycle.js"; @@ -459,7 +459,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost this.armNextAlarmSafe(); } // A preview requested while the sandbox was paused starts now that the agent is back. - flushPendingPreviewStart(this); + flushPendingPreviewAction(this); return new Response(null, { status: 101, webSocket: client }); } diff --git a/packages/worker/src/orchestrator/cli-handlers.ts b/packages/worker/src/orchestrator/cli-handlers.ts index 5cc414b..91dcdec 100644 --- a/packages/worker/src/orchestrator/cli-handlers.ts +++ b/packages/worker/src/orchestrator/cli-handlers.ts @@ -37,8 +37,7 @@ import { } from "./plan-revision-actions.js"; import { buildPreviewStartMessage, - cancelDeferredPreviewStart, - deferPreviewStartWhilePaused, + deferPreviewCommandWhileUnavailable, sandboxSocketAttached, } from "./sandbox-session-lifecycle.js"; @@ -503,13 +502,13 @@ export function cancelOpenQuestions(host: OrchestratorHost, runId: string, reaso export async function handlePreviewStart(host: OrchestratorHost, appKey?: string): Promise { if (!host.meta) return; - if (deferPreviewStartWhilePaused(host, appKey)) return; + if (deferPreviewCommandWhileUnavailable(host, "start", appKey)) return; host.sendToSandbox(buildPreviewStartMessage(host.meta, appKey)); } export async function handlePreviewStop(host: OrchestratorHost): Promise { - // A deferred start never reached the sandbox and a paused sandbox has no preview to stop. - if (cancelDeferredPreviewStart(host)) return; + // A paused or reconnecting sandbox may still run the dev server; replay the stop once it is back. + if (deferPreviewCommandWhileUnavailable(host, "stop")) return; host.sendToSandbox({ type: "preview_stop" }); } diff --git a/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts b/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts index 5e17387..cc3aa68 100644 --- a/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts +++ b/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts @@ -223,7 +223,7 @@ async function recoverAfterFailedPause(host: OrchestratorHost): Promise { host.updateDirectory({}); } drainQueuedAgentWorkIfReady(host); - flushPendingPreviewStart(host); + flushPendingPreviewAction(host); await armAlarmSafely(host); } @@ -316,7 +316,7 @@ async function completeResume(host: OrchestratorHost, now: number): Promise false); host.meta.expected_close = true; host.meta.sandbox_paused_at = undefined; - host.meta.pending_preview_start = undefined; + host.meta.pending_preview_action = undefined; host.saveMeta(); await destroySandbox(() => host.sandboxHandle(), reason, (error) => { logFailure(host, "sandbox.stop.failed", error); @@ -477,20 +477,34 @@ export function buildPreviewStartMessage( } /** - * A preview start requested while the sandbox is paused (or about to be) - * resumes it and is remembered; it is sent when the agent reconnects. + * A preview command (start or stop) issued while the sandbox is paused, about + * to be paused, or reconnecting after a resume resumes it and is remembered + * (the latest command wins); it is replayed once the agent reconnects. Returns + * false when the command can go straight to the agent. */ -export function deferPreviewStartWhilePaused(host: OrchestratorHost, appKey?: string): boolean { +export function deferPreviewCommandWhileUnavailable( + host: OrchestratorHost, + type: "start" | "stop", + appKey?: string, +): boolean { const meta = host.meta; + if (!meta) return false; const unavailable = previewUnavailableReason(host); - if (!meta || !unavailable) return false; - meta.pending_preview_start = appKey !== undefined ? { app_key: appKey } : {}; + if (!unavailable) { + // The command is about to reach the agent and supersedes anything deferred earlier. + if (meta.pending_preview_action) { + meta.pending_preview_action = undefined; + host.saveMeta(); + } + return false; + } + meta.pending_preview_action = { type, ...(type === "start" && appKey !== undefined ? { app_key: appKey } : {}) }; host.saveMeta(); host.recordActivity(); host.requestSandboxResume(); host.appendAndBroadcast({ type: "status", - message: `Sandbox is ${unavailable}; the preview will start when it reconnects.`, + message: `Sandbox is ${unavailable}; the preview will ${type} when it reconnects.`, }); return true; } @@ -509,33 +523,21 @@ function previewUnavailableReason(host: OrchestratorHost): "resuming" | "reconne return null; } -/** True when there was nothing live to stop: a deferred start was cancelled or the sandbox is paused. */ -export function cancelDeferredPreviewStart(host: OrchestratorHost): boolean { - const meta = host.meta; - if (!meta) return false; - const hadPending = meta.pending_preview_start !== undefined; - if (hadPending) { - meta.pending_preview_start = undefined; - host.saveMeta(); - } - return hadPending || previewUnavailableReason(host) !== null; -} - -/** Sends a deferred preview start once a live agent socket exists. */ -export function flushPendingPreviewStart(host: OrchestratorHost): boolean { +/** Replays the deferred preview command exactly once, when a live agent socket exists. */ +export function flushPendingPreviewAction(host: OrchestratorHost): boolean { const meta = host.meta; if ( - !meta?.pending_preview_start + !meta?.pending_preview_action || meta.sandbox_paused_at || isTerminalState(meta.state) || !sandboxSocketAttached(host) ) { return false; } - const { app_key: appKey } = meta.pending_preview_start; - meta.pending_preview_start = undefined; + const { type, app_key: appKey } = meta.pending_preview_action; + meta.pending_preview_action = undefined; host.saveMeta(); - host.sendToSandbox(buildPreviewStartMessage(meta, appKey)); + host.sendToSandbox(type === "start" ? buildPreviewStartMessage(meta, appKey) : { type: "preview_stop" }); return true; } diff --git a/packages/worker/src/orchestrator/session-meta.ts b/packages/worker/src/orchestrator/session-meta.ts index 0e24c77..d62083e 100644 --- a/packages/worker/src/orchestrator/session-meta.ts +++ b/packages/worker/src/orchestrator/session-meta.ts @@ -43,9 +43,18 @@ export function loadSessionMeta( break; } - store.meta = parsed.data; + store.meta = migrateLegacyPendingPreview(parsed.data); break; } store.eventLog.hydrateFromSql(); } + +/** A legacy start-only `pending_preview_start` becomes a "start" `pending_preview_action` (an explicit action wins). */ +export function migrateLegacyPendingPreview(meta: SessionMeta): SessionMeta { + const { pending_preview_start: legacy, ...rest } = meta; + if (legacy === undefined) return meta; + if (rest.pending_preview_action) return rest; + const appKey = typeof legacy === "object" ? legacy.app_key : undefined; + return { ...rest, pending_preview_action: { type: "start", ...(appKey !== undefined ? { app_key: appKey } : {}) } }; +} diff --git a/packages/worker/test/sandbox-session-lifecycle.test.mjs b/packages/worker/test/sandbox-session-lifecycle.test.mjs index 5f57c81..5a2f99a 100644 --- a/packages/worker/test/sandbox-session-lifecycle.test.mjs +++ b/packages/worker/test/sandbox-session-lifecycle.test.mjs @@ -7,7 +7,7 @@ import { connectSandboxHandle, sandboxDiagnosticsResponse, sandboxLogsResponse } import { handlePreviewStart, handlePreviewStop } from "../dist/orchestrator/cli-handlers.js"; import { clearStalePausedMarker, - flushPendingPreviewStart, + flushPendingPreviewAction, idlePauseInput, recordSessionActivity, expireSessionAtMaxTime, @@ -29,8 +29,10 @@ import { } from "../dist/sandbox-connection.js"; import { SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; import { sandboxProviderMaxLeaseMs } from "../dist/sandbox-provider/index.js"; +import { loadSessionMeta } from "../dist/orchestrator/session-meta.js"; import { actor, + createDefaultMeta, createFakeHost, createFakeSandboxSocket, createFakeSandboxProvider, @@ -838,19 +840,19 @@ test("preview_start on a paused session resumes, defers, and sends once the agen assert.equal(resumeRequests.count, 1); assert.equal(activity.count, 1); - assert.deepEqual(host.meta.pending_preview_start, { app_key: "web" }); + assert.deepEqual(host.meta.pending_preview_action, { type: "start", app_key: "web" }); assert.deepEqual(sandboxMessages, []); assert.ok(broadcasts.some((event) => event.type === "status" && event.message === "Sandbox is resuming; the preview will start when it reconnects.")); assert.ok(!broadcasts.some((event) => event.type === "error")); await resumeSandbox(host); // Resumed, but the agent has not reconnected yet. - assert.equal(flushPendingPreviewStart(host), false); + assert.equal(flushPendingPreviewAction(host), false); sandboxSockets.push(createFakeSandboxSocket()); - assert.equal(flushPendingPreviewStart(host), true); - assert.equal(flushPendingPreviewStart(host), false); + assert.equal(flushPendingPreviewAction(host), true); + assert.equal(flushPendingPreviewAction(host), false); assert.deepEqual(sandboxMessages.map((message) => [message.type, message.app_key]), [["preview_start", "web"]]); - assert.equal(host.meta.pending_preview_start, undefined); + assert.equal(host.meta.pending_preview_action, undefined); }); test("preview_start on a running session is sent straight away", async () => { @@ -858,7 +860,7 @@ test("preview_start on a running session is sent straight away", async () => { await handlePreviewStart(host, "web"); assert.deepEqual(sandboxMessages.map((message) => message.type), ["preview_start"]); assert.equal(resumeRequests.count, 0); - assert.equal(host.meta.pending_preview_start, undefined); + assert.equal(host.meta.pending_preview_action, undefined); }); test("preview_start during an in-flight pause is sent after the pause fails", async () => { @@ -869,22 +871,91 @@ test("preview_start during an in-flight pause is sent after the pause fails", as const pausing = pauseIdleSandbox(host, T0 + 600_000); await new Promise((resolve) => setImmediate(resolve)); await handlePreviewStart(host); - assert.ok(host.meta.pending_preview_start); + assert.ok(host.meta.pending_preview_action); assert.deepEqual(sandboxMessages, []); fail(new Error("pause rejected")); await pausing; assert.deepEqual(sandboxMessages.map((message) => message.type), ["preview_start"]); - assert.equal(host.meta.pending_preview_start, undefined); + assert.equal(host.meta.pending_preview_action, undefined); }); -test("preview_stop on a paused session clears the deferred start and does not error", async () => { - const { host, broadcasts, sandboxMessages } = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: false }); - await handlePreviewStart(host, "web"); +test("preview_stop on a paused session resumes, defers, and is replayed exactly once on reconnect", async () => { + const { host, resumeRequests, activity, broadcasts, sandboxMessages, sandboxSockets } = createFakeHost(pausedMeta, { + e2b: true, + sandboxConnected: false, + }); + await handlePreviewStop(host); - assert.equal(host.meta.pending_preview_start, undefined); + + assert.equal(resumeRequests.count, 1); + assert.equal(activity.count, 1); + assert.deepEqual(host.meta.pending_preview_action, { type: "stop" }); assert.deepEqual(sandboxMessages, []); + assert.ok(broadcasts.some((event) => event.type === "status" && event.message === "Sandbox is resuming; the preview will stop when it reconnects.")); assert.ok(!broadcasts.some((event) => event.type === "error")); + + await resumeSandbox(host); + assert.equal(flushPendingPreviewAction(host), false); + sandboxSockets.push(createFakeSandboxSocket()); + assert.equal(flushPendingPreviewAction(host), true); + assert.equal(flushPendingPreviewAction(host), false); + assert.deepEqual(sandboxMessages, [{ type: "preview_stop" }]); + assert.equal(host.meta.pending_preview_action, undefined); +}); + +test("the latest preview command wins while the sandbox is unavailable", async () => { + const startThenStop = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: false }); + await handlePreviewStart(startThenStop.host, "web"); + await handlePreviewStop(startThenStop.host); + assert.deepEqual(startThenStop.host.meta.pending_preview_action, { type: "stop" }); + startThenStop.sandboxSockets.push(createFakeSandboxSocket()); + await resumeSandbox(startThenStop.host); + assert.deepEqual(startThenStop.sandboxMessages.map((message) => message.type), ["preview_stop"]); + + const stopThenStart = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: false }); + await handlePreviewStop(stopThenStart.host); + await handlePreviewStart(stopThenStart.host, "web"); + assert.deepEqual(stopThenStart.host.meta.pending_preview_action, { type: "start", app_key: "web" }); + stopThenStart.sandboxSockets.push(createFakeSandboxSocket()); + await resumeSandbox(stopThenStart.host); + assert.deepEqual(stopThenStart.sandboxMessages.map((message) => [message.type, message.app_key]), [["preview_start", "web"]]); +}); + +test("a preview command that reaches a live agent supersedes a stale deferred one", async () => { + const { host, sandboxMessages } = createFakeHost( + { ...idleMeta, pending_preview_action: { type: "start", app_key: "old" } }, + { e2b: true, sandboxConnected: true }, + ); + await handlePreviewStop(host); + assert.deepEqual(sandboxMessages, [{ type: "preview_stop" }]); + assert.equal(host.meta.pending_preview_action, undefined); +}); + +test("a stale paused marker cleared by the alarm replays a deferred preview stop", async () => { + const { host, sandboxMessages } = createFakeHost( + { ...pausedMeta, pending_preview_action: { type: "stop" } }, + { e2b: true, sandboxConnected: true }, + ); + assert.equal(clearStalePausedMarker(host), true); + assert.deepEqual(sandboxMessages, [{ type: "preview_stop" }]); +}); + +test("legacy pending_preview_start meta loads as a start action", () => { + const load = (legacy, extra = {}) => { + const meta = createDefaultMeta({ pending_preview_start: legacy, ...extra }); + const store = { meta: null, eventLog: { hydrateFromSql() {} } }; + loadSessionMeta({ exec: () => [{ value: JSON.stringify(meta) }] }, store); + return store.meta; + }; + + const flag = load(true); + assert.deepEqual(flag.pending_preview_action, { type: "start" }); + assert.equal(flag.pending_preview_start, undefined); + + assert.deepEqual(load({ app_key: "web" }).pending_preview_action, { type: "start", app_key: "web" }); + // An explicit action is never overwritten by the legacy flag. + assert.deepEqual(load(true, { pending_preview_action: { type: "stop" } }).pending_preview_action, { type: "stop" }); }); test("a deferred preview start is dropped when the session fails", async () => { @@ -892,7 +963,7 @@ test("a deferred preview start is dropped when the session fails", async () => { await handlePreviewStart(host, "web"); await resumeSandbox(host); assert.equal(host.meta.state, "failed"); - assert.equal(host.meta.pending_preview_start, undefined); + assert.equal(host.meta.pending_preview_action, undefined); }); // --- activity --- @@ -988,12 +1059,12 @@ test("an agent that reconnects mid-resume still gets the deferred preview start // connect woke the VM; the agent reconnects with its old token before completeResume runs. // The accept path drains and flushes, but the paused marker is still set. sandboxSockets.push(createFakeSandboxSocket()); - assert.equal(flushPendingPreviewStart(host), false); + assert.equal(flushPendingPreviewAction(host), false); release(); await resuming; - assert.equal(host.meta.pending_preview_start, undefined); + assert.equal(host.meta.pending_preview_action, undefined); assert.deepEqual(sandboxMessages.map((message) => message.type).sort(), ["agent_turn", "preview_start"]); // A later stop is a real stop, not swallowed as a cancelled deferred start. await handlePreviewStop(host); @@ -1008,7 +1079,7 @@ test("preview_start in the reconnect window after a resume is deferred until the await handlePreviewStart(host, "web"); - assert.deepEqual(host.meta.pending_preview_start, { app_key: "web" }); + assert.deepEqual(host.meta.pending_preview_action, { type: "start", app_key: "web" }); assert.deepEqual(sandboxMessages, []); assert.equal(resumeRequests.count, 1); assert.ok(broadcasts.some((event) => event.type === "status" && event.message === "Sandbox is reconnecting; the preview will start when it reconnects.")); @@ -1016,18 +1087,25 @@ test("preview_start in the reconnect window after a resume is deferred until the sandboxSockets.push(createFakeSandboxSocket()); host.meta.sandbox_disconnected_at = undefined; // completeSandboxReconnect - assert.equal(flushPendingPreviewStart(host), true); + assert.equal(flushPendingPreviewAction(host), true); assert.deepEqual(sandboxMessages.map((message) => message.type), ["preview_start"]); }); -test("preview_stop in the reconnect window has nothing to stop and does not error", async () => { - const { host, sandboxMessages, broadcasts } = createFakeHost( +test("preview_stop in the reconnect window is deferred and replayed once the agent returns", async () => { + const { host, sandboxMessages, broadcasts, sandboxSockets } = createFakeHost( { ...idleMeta, sandbox_disconnected_at: "2026-10-01T00:10:01.000Z" }, { e2b: true, sandboxConnected: false }, ); await handlePreviewStop(host); assert.deepEqual(sandboxMessages, []); + assert.deepEqual(host.meta.pending_preview_action, { type: "stop" }); + assert.ok(broadcasts.some((event) => event.type === "status" && event.message === "Sandbox is reconnecting; the preview will stop when it reconnects.")); assert.ok(!broadcasts.some((event) => event.type === "error")); + + sandboxSockets.push(createFakeSandboxSocket()); + host.meta.sandbox_disconnected_at = undefined; // completeSandboxReconnect + assert.equal(flushPendingPreviewAction(host), true); + assert.deepEqual(sandboxMessages, [{ type: "preview_stop" }]); }); test("preview_start is not deferred when the agent is attached, or for a provider that cannot pause", async () => { @@ -1044,8 +1122,9 @@ test("preview_start is not deferred when the agent is attached, or for a provide { sandboxConnected: false }, ); await handlePreviewStart(cloudflare.host, "web"); - assert.deepEqual(cloudflare.sandboxMessages.map((message) => message.type), ["preview_start"]); - assert.equal(cloudflare.host.meta.pending_preview_start, undefined); + await handlePreviewStop(cloudflare.host); + assert.deepEqual(cloudflare.sandboxMessages.map((message) => message.type), ["preview_start", "preview_stop"]); + assert.equal(cloudflare.host.meta.pending_preview_action, undefined); assert.equal(cloudflare.resumeRequests.count, 0); assert.equal(cloudflare.activity.count, 0); }); From 1b5799b9d6c90fb3ef334699818aaac8c1bfaaf2 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 08:20:42 +0530 Subject: [PATCH 22/32] feat(sandbox-image): share Dockerfile across providers and publish E2B template Adds SANDBOX_BASE build arg (default cloudflare/sandbox:0.12.7), installs bun when missing, prepares /var/log/codevil and /run/codevil, and an e2b:template script (docker build -> push -> Template.build via fromImage). Template was not published and no image was built or pushed (Docker daemon unavailable; publishing is deferred). --- Dockerfile.sandbox | 8 +- README.md | 33 ++++ packages/sandbox-image/package.json | 4 +- .../sandbox-image/scripts/e2b-template.mjs | 141 ++++++++++++++++++ .../sandbox-image/test/dockerfile.test.mjs | 22 ++- .../sandbox-image/test/e2b-template.test.mjs | 78 ++++++++++ pnpm-lock.yaml | 3 + 7 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 packages/sandbox-image/scripts/e2b-template.mjs create mode 100644 packages/sandbox-image/test/e2b-template.test.mjs diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index 02e4e75..dc5c87f 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -1,4 +1,5 @@ ARG CODEVIL_SANDBOX_PLATFORM=linux/amd64 +ARG SANDBOX_BASE=docker.io/cloudflare/sandbox:0.12.7 FROM --platform=$CODEVIL_SANDBOX_PLATFORM node:22-slim AS builder RUN npm install -g pnpm@10.28.1 @@ -16,7 +17,7 @@ RUN pnpm install --filter @codevil/sandbox-image... --prod=false \ RUN rm -rf node_modules \ && pnpm install --filter @codevil/sandbox-image... --prod -FROM --platform=$CODEVIL_SANDBOX_PLATFORM docker.io/cloudflare/sandbox:0.12.7 +FROM --platform=$CODEVIL_SANDBOX_PLATFORM ${SANDBOX_BASE} USER root @@ -37,6 +38,7 @@ RUN apt-get update \ && npm install -g pnpm@10.28.1 \ && node -e 'const [major, minor] = process.versions.node.split(".").map(Number); if (major < 22 || (major === 22 && minor < 19)) throw new Error(`Node ${process.versions.node} does not satisfy >=22.19`); console.log(process.version)' \ && git --version \ + && (command -v bun >/dev/null || npm install -g bun) \ && bun --version \ && setpriv --version @@ -46,8 +48,8 @@ COPY packages/sandbox-image/skills /opt/codevil/pi-agent/skills RUN groupadd --gid 10001 codevil \ && useradd --uid 10001 --gid codevil --home-dir /home/codevil --create-home codevil \ - && mkdir -p /workspace \ - && chown codevil:codevil /workspace + && mkdir -p /workspace /var/log/codevil /run/codevil \ + && chown codevil:codevil /workspace /run/codevil WORKDIR /workspace diff --git a/README.md b/README.md index 638878e..94b8d14 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,39 @@ In the Google OAuth client, add the deployed Worker origin as an authorized Java Open the Worker URL, sign in with Google, claim the first owner account using `CODEVIL_SETUP_TOKEN`, and invite the rest of the team. +## Sandbox provider + +Codevil runs each session in either an E2B sandbox (the default) or a Cloudflare Sandbox container. Configure the provider with Worker vars in `packages/worker/wrangler.toml` (or an untracked `wrangler.operator.toml` overlay): + +| Name | Kind | Default | Purpose | +| --- | --- | --- | --- | +| `SANDBOX_PROVIDER` | var | `e2b` | `e2b` or `cloudflare`. | +| `E2B_API_KEY` | secret | none | Required when `SANDBOX_PROVIDER=e2b`. Upload with `pnpm exec wrangler secret put E2B_API_KEY`; never put it in `wrangler.toml`. | +| `E2B_TEMPLATE_ID` | var | `codevil-sandbox` | E2B template the sandboxes start from. | +| `E2B_MAX_SANDBOX_SECONDS` | var | `3600` | Maximum continuous sandbox runtime; the default matches the E2B Hobby limit. | + +### Publishing the E2B template + +Both providers share `Dockerfile.sandbox`. The Cloudflare build uses its default `cloudflare/sandbox` base; the E2B template is built from the same file on a plain `node:22-slim` base. E2B's Template SDK cannot read multi-stage Dockerfiles, so the publish script builds the image with local Docker, pushes it to a registry, and then registers the pushed image as an E2B template with 2 vCPU / 4096 MiB: + +```sh +export E2B_API_KEY=... # E2B account key +export CODEVIL_SANDBOX_IMAGE=ghcr.io//codevil-sandbox: +export CODEVIL_REGISTRY_USERNAME=... # private registries only +export CODEVIL_REGISTRY_PASSWORD=... # private registries only +pnpm --filter @codevil/sandbox-image e2b:template -- --dry-run # print the plan without running it +pnpm --filter @codevil/sandbox-image e2b:template # build -> push -> template +``` + +| Name | Required | Purpose | +| --- | --- | --- | +| `E2B_API_KEY` | yes | Authenticates the template build. | +| `CODEVIL_SANDBOX_IMAGE` | yes | Full registry reference that is built, pushed, and used as the template base. | +| `E2B_TEMPLATE_ID` | no | Template name to publish (default `codevil-sandbox`); keep it equal to the Worker's `E2B_TEMPLATE_ID`. | +| `CODEVIL_REGISTRY_USERNAME`, `CODEVIL_REGISTRY_PASSWORD` | no | Registry credentials, needed together when the image is private. | + +Registry credentials come only from the environment of the person publishing the template. The password is passed to `docker login` over stdin (never as an argument), is never printed, and is never committed or stored in wrangler config. The machine running the script needs Docker, access to push to the registry, and the built image must be pullable by E2B (public, or private with the credentials above). + ## Slack integration The first Slack integration supports one statically configured Slack workspace per Codevil deployment. Any non-bot member of that workspace can configure a channel repository and invoke Codevil, but an Agent Request is created only when `@codevil` is explicitly mentioned. diff --git a/packages/sandbox-image/package.json b/packages/sandbox-image/package.json index 458f70a..fa7b294 100644 --- a/packages/sandbox-image/package.json +++ b/packages/sandbox-image/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", - "test": "pnpm run build && node --test test/*.test.mjs" + "test": "pnpm run build && node --test test/*.test.mjs", + "e2b:template": "node scripts/e2b-template.mjs" }, "dependencies": { "@codevil/shared": "workspace:*", @@ -17,6 +18,7 @@ "devDependencies": { "@types/node": "^24.0.0", "@types/ws": "^8.5.0", + "e2b": "^2.51.0", "typescript": "^5.8.0" } } diff --git a/packages/sandbox-image/scripts/e2b-template.mjs b/packages/sandbox-image/scripts/e2b-template.mjs new file mode 100644 index 0000000..086ef12 --- /dev/null +++ b/packages/sandbox-image/scripts/e2b-template.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Build the shared Dockerfile.sandbox on a plain node:22-slim base, push it to a +// registry, and publish it as an E2B template. +// +// E2B's Template SDK `fromDockerfile` rejects multi-stage Dockerfiles, so the +// route is: docker build -> docker push -> Template().fromImage() -> +// Template.build(). Everything is configured through environment variables; +// secrets are never printed and the registry password reaches docker only via +// `--password-stdin`. +// +// E2B_API_KEY required +// CODEVIL_SANDBOX_IMAGE required, full registry ref to build/push +// E2B_TEMPLATE_ID optional, default "codevil-sandbox" +// CODEVIL_REGISTRY_USERNAME optional, private registry login (with password) +// CODEVIL_REGISTRY_PASSWORD optional, private registry login (with username) +// +// Pass --dry-run to print the planned steps without executing anything. +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SANDBOX_BASE = "node:22-slim"; +const DOCKERFILE = "Dockerfile.sandbox"; +const DEFAULT_TEMPLATE_ID = "codevil-sandbox"; +const CPU_COUNT = 2; +const MEMORY_MB = 4096; +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); + +function fail(message) { + console.error(`e2b:template: ${message}`); + process.exit(1); +} + +function readEnv(name) { + const value = process.env[name]?.trim(); + return value ? value : undefined; +} + +/** Registry host of an image ref, or undefined for Docker Hub refs. */ +function registryHost(image) { + const first = image.split("/")[0]; + if (image.includes("/") && (first.includes(".") || first.includes(":") || first === "localhost")) { + return first; + } + return undefined; +} + +function loadConfig() { + const apiKey = readEnv("E2B_API_KEY"); + const image = readEnv("CODEVIL_SANDBOX_IMAGE"); + const username = readEnv("CODEVIL_REGISTRY_USERNAME"); + const password = readEnv("CODEVIL_REGISTRY_PASSWORD"); + const missing = []; + if (!apiKey) missing.push("E2B_API_KEY"); + if (!image) missing.push("CODEVIL_SANDBOX_IMAGE (full registry image ref to build and push, e.g. ghcr.io//codevil-sandbox:)"); + if (missing.length > 0) { + fail(`missing required environment variable(s):\n - ${missing.join("\n - ")}`); + } + if (Boolean(username) !== Boolean(password)) { + fail( + `set both CODEVIL_REGISTRY_USERNAME and CODEVIL_REGISTRY_PASSWORD for a private registry, or neither (missing ${ + username ? "CODEVIL_REGISTRY_PASSWORD" : "CODEVIL_REGISTRY_USERNAME" + })`, + ); + } + return { + apiKey, + image, + templateId: readEnv("E2B_TEMPLATE_ID") ?? DEFAULT_TEMPLATE_ID, + credentials: username && password ? { username, password } : undefined, + }; +} + +function printPlan(config) { + const host = registryHost(config.image); + const lines = [ + `docker build --build-arg SANDBOX_BASE=${SANDBOX_BASE} -f ${DOCKERFILE} -t ${config.image} .`, + ]; + if (config.credentials) { + lines.push( + `docker login${host ? ` ${host}` : ""} -u "$CODEVIL_REGISTRY_USERNAME" --password-stdin # password piped from $CODEVIL_REGISTRY_PASSWORD`, + ); + } + lines.push(`docker push ${config.image}`); + lines.push( + `E2B Template.build(Template().fromImage("${config.image}"${ + config.credentials ? ", { username: $CODEVIL_REGISTRY_USERNAME, password: $CODEVIL_REGISTRY_PASSWORD }" : "" + }), template "${config.templateId}", { cpuCount: ${CPU_COUNT}, memoryMB: ${MEMORY_MB} }) # authenticated with $E2B_API_KEY`, + ); + return lines; +} + +function docker(args, options = {}) { + const result = spawnSync("docker", args, { + cwd: repoRoot, + stdio: ["pipe", "inherit", "inherit"], + ...options, + }); + if (result.error) fail(`could not run docker: ${result.error.message}`); + if (result.status !== 0) fail(`docker ${args[0]} failed with exit code ${result.status}`); +} + +async function main() { + const dryRun = process.argv.slice(2).includes("--dry-run"); + const config = loadConfig(); + + if (dryRun) { + console.log("e2b:template dry run (nothing will be executed):"); + printPlan(config).forEach((line, index) => console.log(` ${index + 1}. ${line}`)); + return; + } + + console.log(`Building ${config.image} on ${SANDBOX_BASE}`); + docker(["build", "--build-arg", `SANDBOX_BASE=${SANDBOX_BASE}`, "-f", DOCKERFILE, "-t", config.image, "."]); + + if (config.credentials) { + const host = registryHost(config.image); + docker(["login", ...(host ? [host] : []), "-u", config.credentials.username, "--password-stdin"], { + input: config.credentials.password, + }); + } + + console.log(`Pushing ${config.image}`); + docker(["push", config.image]); + + const { Template, defaultBuildLogger } = await import("e2b"); + console.log(`Publishing E2B template "${config.templateId}" (${CPU_COUNT} vCPU / ${MEMORY_MB} MiB)`); + const template = Template().fromImage(config.image, config.credentials); + await Template.build(template, config.templateId, { + apiKey: config.apiKey, + cpuCount: CPU_COUNT, + memoryMB: MEMORY_MB, + onBuildLogs: defaultBuildLogger(), + }); + console.log(`E2B template ready: ${config.templateId}`); +} + +main().catch((error) => { + // Report the message only; never dump option/env objects that hold secrets. + fail(error instanceof Error ? error.message : String(error)); +}); diff --git a/packages/sandbox-image/test/dockerfile.test.mjs b/packages/sandbox-image/test/dockerfile.test.mjs index 5cadb5b..dc176da 100644 --- a/packages/sandbox-image/test/dockerfile.test.mjs +++ b/packages/sandbox-image/test/dockerfile.test.mjs @@ -9,10 +9,8 @@ const dockerfile = await readFile(dockerfilePath, "utf8"); const runtime = dockerfile.slice(dockerfile.lastIndexOf("FROM --platform=$CODEVIL_SANDBOX_PLATFORM")); test("sandbox runtime preserves Cloudflare backup support and Node development tools", () => { - assert.match( - runtime, - /^FROM --platform=\$CODEVIL_SANDBOX_PLATFORM docker\.io\/cloudflare\/sandbox:0\.12\.7/m, - ); + assert.match(dockerfile, /^ARG SANDBOX_BASE=docker\.io\/cloudflare\/sandbox:0\.12\.7$/m); + assert.match(runtime, /^FROM --platform=\$CODEVIL_SANDBOX_PLATFORM \$\{SANDBOX_BASE\}$/m); assert.doesNotMatch(runtime, /COPY --from=sandbox-runtime \/container-server\/sandbox \/sandbox/); assert.doesNotMatch(runtime, /FROM --platform=\$CODEVIL_SANDBOX_PLATFORM node:22-slim/); for (const tool of ["git", "curl", "wget", "jq", "zip", "unzip", "file", "procps"]) { @@ -39,3 +37,19 @@ test("sandbox server keeps its supported runtime while Codevil workspace access assert.doesNotMatch(runtime, /USER codevil/); assert.match(runtime, /WORKDIR \/workspace/); }); + +test("runtime stage base is configurable and defaults to the Cloudflare sandbox image", () => { + assert.match(dockerfile, /^ARG SANDBOX_BASE=docker\.io\/cloudflare\/sandbox:0\.12\.7$/m); + assert.match(dockerfile, /^FROM --platform=\$CODEVIL_SANDBOX_PLATFORM \$\{SANDBOX_BASE\}$/m); + // The ARG must be declared before the first FROM so the FROM line can expand it. + assert.ok(dockerfile.indexOf("ARG SANDBOX_BASE=") < dockerfile.indexOf("FROM ")); +}); + +test("runtime stage installs bun when the base image lacks it", () => { + assert.match(runtime, /command -v bun >\/dev\/null \|\| npm install -g bun/); +}); + +test("runtime stage prepares the agent log and token directories", () => { + assert.match(runtime, /mkdir -p \/workspace \/var\/log\/codevil \/run\/codevil/); + assert.match(runtime, /chown codevil:codevil \/workspace \/run\/codevil/); +}); diff --git a/packages/sandbox-image/test/e2b-template.test.mjs b/packages/sandbox-image/test/e2b-template.test.mjs new file mode 100644 index 0000000..1269aa6 --- /dev/null +++ b/packages/sandbox-image/test/e2b-template.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const script = resolve(dirname(fileURLToPath(import.meta.url)), "../scripts/e2b-template.mjs"); + +const FAKE_API_KEY = "e2b_FAKE_API_KEY_do_not_leak_123"; +const FAKE_PASSWORD = "FAKE_REGISTRY_PASSWORD_do_not_leak_456"; +const FAKE_USERNAME = "fake-registry-user"; + +function run(args, env) { + // Start from a minimal environment so the developer's real E2B_* / registry + // variables can never influence (or leak into) the test. + return spawnSync(process.execPath, [script, ...args], { + env: { PATH: process.env.PATH ?? "", ...env }, + encoding: "utf8", + }); +} + +test("dry run prints the build, login, push and template steps with secrets redacted", () => { + const result = run(["--dry-run"], { + E2B_API_KEY: FAKE_API_KEY, + E2B_TEMPLATE_ID: "codevil-sandbox-test", + CODEVIL_SANDBOX_IMAGE: "registry.example.com/acme/codevil-sandbox:1.2.3", + CODEVIL_REGISTRY_USERNAME: FAKE_USERNAME, + CODEVIL_REGISTRY_PASSWORD: FAKE_PASSWORD, + }); + assert.equal(result.status, 0, result.stderr); + const output = `${result.stdout}\n${result.stderr}`; + + assert.match( + result.stdout, + /docker build --build-arg SANDBOX_BASE=node:22-slim -f Dockerfile\.sandbox -t registry\.example\.com\/acme\/codevil-sandbox:1\.2\.3 \./, + ); + assert.match(result.stdout, /docker login registry\.example\.com .*--password-stdin/); + assert.match(result.stdout, /docker push registry\.example\.com\/acme\/codevil-sandbox:1\.2\.3/); + assert.match(result.stdout, /codevil-sandbox-test/); + assert.match(result.stdout, /cpuCount: 2/); + assert.match(result.stdout, /memoryMB: 4096/); + + assert.ok(!output.includes(FAKE_API_KEY), "E2B API key must not be printed"); + assert.ok(!output.includes(FAKE_PASSWORD), "registry password must not be printed"); + assert.ok(!output.includes(FAKE_USERNAME), "registry username must not be printed"); + // The password may only reach docker through stdin, never as an argument. + assert.doesNotMatch(result.stdout, /(?:^|\s)(?:-p|--password)(?:\s|=)/m); +}); + +test("dry run defaults the template id and skips docker login without registry credentials", () => { + const result = run(["--dry-run"], { + E2B_API_KEY: FAKE_API_KEY, + CODEVIL_SANDBOX_IMAGE: "ghcr.io/acme/codevil-sandbox:latest", + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /template "codevil-sandbox"/); + assert.doesNotMatch(result.stdout, /docker login/); + assert.match(result.stdout, /docker push ghcr\.io\/acme\/codevil-sandbox:latest/); + assert.ok(!result.stdout.includes(FAKE_API_KEY)); +}); + +test("missing required variables fail with a clear message that lists them", () => { + const result = run(["--dry-run"], {}); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /E2B_API_KEY/); + assert.match(result.stderr, /CODEVIL_SANDBOX_IMAGE/); +}); + +test("a registry username without a password (or vice versa) is rejected without echoing secrets", () => { + const result = run(["--dry-run"], { + E2B_API_KEY: FAKE_API_KEY, + CODEVIL_SANDBOX_IMAGE: "ghcr.io/acme/codevil-sandbox:latest", + CODEVIL_REGISTRY_PASSWORD: FAKE_PASSWORD, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /CODEVIL_REGISTRY_USERNAME/); + assert.ok(!`${result.stdout}${result.stderr}`.includes(FAKE_PASSWORD)); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c813c01..06600e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: '@types/ws': specifier: ^8.5.0 version: 8.18.1 + e2b: + specifier: ^2.51.0 + version: 2.51.0 typescript: specifier: ^5.8.0 version: 5.9.3 From 610467b724dfc676f6b5bef10fcb7565ebb96d99 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 12:16:18 +0530 Subject: [PATCH 23/32] fix(sandbox-image): test the real template publish path and tighten image setup --- Dockerfile.sandbox | 4 +- .../sandbox-image/scripts/e2b-template.mjs | 111 ++++++++++----- .../sandbox-image/test/dockerfile.test.mjs | 12 +- .../sandbox-image/test/e2b-template.test.mjs | 134 +++++++++++++++++- 4 files changed, 220 insertions(+), 41 deletions(-) diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index dc5c87f..96a48cb 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -38,7 +38,7 @@ RUN apt-get update \ && npm install -g pnpm@10.28.1 \ && node -e 'const [major, minor] = process.versions.node.split(".").map(Number); if (major < 22 || (major === 22 && minor < 19)) throw new Error(`Node ${process.versions.node} does not satisfy >=22.19`); console.log(process.version)' \ && git --version \ - && (command -v bun >/dev/null || npm install -g bun) \ + && (command -v bun >/dev/null || npm install -g bun@1) \ && bun --version \ && setpriv --version @@ -49,7 +49,7 @@ COPY packages/sandbox-image/skills /opt/codevil/pi-agent/skills RUN groupadd --gid 10001 codevil \ && useradd --uid 10001 --gid codevil --home-dir /home/codevil --create-home codevil \ && mkdir -p /workspace /var/log/codevil /run/codevil \ - && chown codevil:codevil /workspace /run/codevil + && chown codevil:codevil /workspace WORKDIR /workspace diff --git a/packages/sandbox-image/scripts/e2b-template.mjs b/packages/sandbox-image/scripts/e2b-template.mjs index 086ef12..72bb843 100644 --- a/packages/sandbox-image/scripts/e2b-template.mjs +++ b/packages/sandbox-image/scripts/e2b-template.mjs @@ -14,7 +14,13 @@ // CODEVIL_REGISTRY_USERNAME optional, private registry login (with password) // CODEVIL_REGISTRY_PASSWORD optional, private registry login (with username) // -// Pass --dry-run to print the planned steps without executing anything. +// Pass --dry-run to print the planned steps without executing anything. The +// printed plan and the executed commands come from the same step list +// (buildSteps), so they cannot drift apart. +// +// Test hook: CODEVIL_E2B_TEMPLATE_SKIP_BUILD=1 runs the docker steps for real +// but skips the final E2B Template.build call (tests put a fake `docker` first +// on PATH and have no E2B account). Never set it when publishing. import { spawnSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -45,6 +51,13 @@ function registryHost(image) { return undefined; } +/** Image refs and template ids end up in argv/API calls: no option-lookalikes or whitespace. */ +function assertSafeValue(name, value) { + if (value.startsWith("-") || /\s/.test(value)) { + fail(`${name} must not start with "-" or contain whitespace`); + } +} + function loadConfig() { const apiKey = readEnv("E2B_API_KEY"); const image = readEnv("CODEVIL_SANDBOX_IMAGE"); @@ -63,65 +76,97 @@ function loadConfig() { })`, ); } + const templateId = readEnv("E2B_TEMPLATE_ID") ?? DEFAULT_TEMPLATE_ID; + assertSafeValue("CODEVIL_SANDBOX_IMAGE", image); + assertSafeValue("E2B_TEMPLATE_ID", templateId); return { apiKey, image, - templateId: readEnv("E2B_TEMPLATE_ID") ?? DEFAULT_TEMPLATE_ID, + templateId, credentials: username && password ? { username, password } : undefined, }; } -function printPlan(config) { +/** + * The single source of truth for the docker steps: each step carries the exact + * argv that is spawned, an optional stdin payload (only the registry password), + * and a redacted display form that is the only thing ever printed. + */ +function buildSteps(config) { const host = registryHost(config.image); - const lines = [ - `docker build --build-arg SANDBOX_BASE=${SANDBOX_BASE} -f ${DOCKERFILE} -t ${config.image} .`, + const steps = [ + { + argv: ["docker", "build", "--build-arg", `SANDBOX_BASE=${SANDBOX_BASE}`, "-f", DOCKERFILE, "-t", config.image, "."], + }, ]; if (config.credentials) { - lines.push( - `docker login${host ? ` ${host}` : ""} -u "$CODEVIL_REGISTRY_USERNAME" --password-stdin # password piped from $CODEVIL_REGISTRY_PASSWORD`, - ); + steps.push({ + argv: ["docker", "login", ...(host ? [host] : []), "-u", config.credentials.username, "--password-stdin"], + stdin: config.credentials.password, + display: `docker login${host ? ` ${host}` : ""} -u "$CODEVIL_REGISTRY_USERNAME" --password-stdin # password piped on stdin from $CODEVIL_REGISTRY_PASSWORD`, + }); } - lines.push(`docker push ${config.image}`); - lines.push( - `E2B Template.build(Template().fromImage("${config.image}"${ - config.credentials ? ", { username: $CODEVIL_REGISTRY_USERNAME, password: $CODEVIL_REGISTRY_PASSWORD }" : "" - }), template "${config.templateId}", { cpuCount: ${CPU_COUNT}, memoryMB: ${MEMORY_MB} }) # authenticated with $E2B_API_KEY`, - ); - return lines; + steps.push({ argv: ["docker", "push", config.image] }); + return steps; } -function docker(args, options = {}) { - const result = spawnSync("docker", args, { +const displayOf = (step) => step.display ?? step.argv.join(" "); + +function templateDisplay(config) { + return `E2B Template.build(Template().fromImage("${config.image}"${ + config.credentials ? ", { username: $CODEVIL_REGISTRY_USERNAME, password: $CODEVIL_REGISTRY_PASSWORD }" : "" + }), template "${config.templateId}", { cpuCount: ${CPU_COUNT}, memoryMB: ${MEMORY_MB} }) # authenticated with $E2B_API_KEY`; +} + +/** Docker never needs the E2B key or registry secrets in its environment. */ +function dockerEnv() { + const env = { ...process.env }; + for (const name of ["E2B_API_KEY", "CODEVIL_REGISTRY_USERNAME", "CODEVIL_REGISTRY_PASSWORD"]) delete env[name]; + return env; +} + +function runStep(step) { + const [command, ...args] = step.argv; + const result = spawnSync(command, args, { cwd: repoRoot, - stdio: ["pipe", "inherit", "inherit"], - ...options, + env: dockerEnv(), + stdio: [step.stdin === undefined ? "ignore" : "pipe", "inherit", "inherit"], + input: step.stdin, }); - if (result.error) fail(`could not run docker: ${result.error.message}`); - if (result.status !== 0) fail(`docker ${args[0]} failed with exit code ${result.status}`); + if (result.error) fail(`could not run ${command}: ${result.error.message}`); + if (result.status !== 0) fail(`${command} ${args[0]} failed with exit code ${result.status}`); +} + +function parseArgs(argv) { + let dryRun = false; + for (const arg of argv) { + if (arg === "--") continue; // package-manager separator + if (arg === "--dry-run") dryRun = true; + else fail(`unknown argument "${arg}" (supported: --dry-run)`); + } + return { dryRun }; } async function main() { - const dryRun = process.argv.slice(2).includes("--dry-run"); + const { dryRun } = parseArgs(process.argv.slice(2)); const config = loadConfig(); + const steps = buildSteps(config); if (dryRun) { console.log("e2b:template dry run (nothing will be executed):"); - printPlan(config).forEach((line, index) => console.log(` ${index + 1}. ${line}`)); + [...steps.map(displayOf), templateDisplay(config)].forEach((line, index) => console.log(` ${index + 1}. ${line}`)); return; } - console.log(`Building ${config.image} on ${SANDBOX_BASE}`); - docker(["build", "--build-arg", `SANDBOX_BASE=${SANDBOX_BASE}`, "-f", DOCKERFILE, "-t", config.image, "."]); - - if (config.credentials) { - const host = registryHost(config.image); - docker(["login", ...(host ? [host] : []), "-u", config.credentials.username, "--password-stdin"], { - input: config.credentials.password, - }); + for (const step of steps) { + console.log(`> ${displayOf(step)}`); + runStep(step); } - console.log(`Pushing ${config.image}`); - docker(["push", config.image]); + if (process.env.CODEVIL_E2B_TEMPLATE_SKIP_BUILD === "1") { + console.log("E2B template build skipped (CODEVIL_E2B_TEMPLATE_SKIP_BUILD=1)"); + return; + } const { Template, defaultBuildLogger } = await import("e2b"); console.log(`Publishing E2B template "${config.templateId}" (${CPU_COUNT} vCPU / ${MEMORY_MB} MiB)`); diff --git a/packages/sandbox-image/test/dockerfile.test.mjs b/packages/sandbox-image/test/dockerfile.test.mjs index dc176da..368fb19 100644 --- a/packages/sandbox-image/test/dockerfile.test.mjs +++ b/packages/sandbox-image/test/dockerfile.test.mjs @@ -9,8 +9,6 @@ const dockerfile = await readFile(dockerfilePath, "utf8"); const runtime = dockerfile.slice(dockerfile.lastIndexOf("FROM --platform=$CODEVIL_SANDBOX_PLATFORM")); test("sandbox runtime preserves Cloudflare backup support and Node development tools", () => { - assert.match(dockerfile, /^ARG SANDBOX_BASE=docker\.io\/cloudflare\/sandbox:0\.12\.7$/m); - assert.match(runtime, /^FROM --platform=\$CODEVIL_SANDBOX_PLATFORM \$\{SANDBOX_BASE\}$/m); assert.doesNotMatch(runtime, /COPY --from=sandbox-runtime \/container-server\/sandbox \/sandbox/); assert.doesNotMatch(runtime, /FROM --platform=\$CODEVIL_SANDBOX_PLATFORM node:22-slim/); for (const tool of ["git", "curl", "wget", "jq", "zip", "unzip", "file", "procps"]) { @@ -18,7 +16,8 @@ test("sandbox runtime preserves Cloudflare backup support and Node development t } assert.match(runtime, /\butil-linux\b/); assert.match(runtime, /npm install -g pnpm@10\.28\.1/); - assert.doesNotMatch(runtime, /npm install -g bun@/); + // Only the conditional fallback may install bun; it is pinned to the major the Cloudflare base ships (oven/bun:1). + assert.doesNotMatch(runtime, /npm install -g bun(?!@1\))/); assert.match(runtime, /major < 22 \|\| \(major === 22 && minor < 19\)/); for (const command of ["git --version", "bun --version", "setpriv --version"]) { assert.match(runtime, new RegExp(command)); @@ -46,10 +45,13 @@ test("runtime stage base is configurable and defaults to the Cloudflare sandbox }); test("runtime stage installs bun when the base image lacks it", () => { - assert.match(runtime, /command -v bun >\/dev\/null \|\| npm install -g bun/); + assert.match(runtime, /command -v bun >\/dev\/null \|\| npm install -g bun@1\)/); }); test("runtime stage prepares the agent log and token directories", () => { assert.match(runtime, /mkdir -p \/workspace \/var\/log\/codevil \/run\/codevil/); - assert.match(runtime, /chown codevil:codevil \/workspace \/run\/codevil/); + // /run/codevil and /var/log/codevil stay root-owned (only /workspace is handed to codevil). + assert.match(runtime, /chown codevil:codevil \/workspace(?! \/run\/codevil)/); + assert.doesNotMatch(runtime, /chown[^\n]*\/run\/codevil/); + assert.doesNotMatch(runtime, /chown[^\n]*\/var\/log\/codevil/); }); diff --git a/packages/sandbox-image/test/e2b-template.test.mjs b/packages/sandbox-image/test/e2b-template.test.mjs index 1269aa6..4737004 100644 --- a/packages/sandbox-image/test/e2b-template.test.mjs +++ b/packages/sandbox-image/test/e2b-template.test.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; @@ -76,3 +78,133 @@ test("a registry username without a password (or vice versa) is rejected without assert.match(result.stderr, /CODEVIL_REGISTRY_USERNAME/); assert.ok(!`${result.stdout}${result.stderr}`.includes(FAKE_PASSWORD)); }); + +test("dry run accepts a leading bare -- separator and rejects unknown arguments", () => { + const env = { E2B_API_KEY: FAKE_API_KEY, CODEVIL_SANDBOX_IMAGE: "ghcr.io/acme/codevil-sandbox:latest" }; + const withSeparator = run(["--", "--dry-run"], env); + assert.equal(withSeparator.status, 0, withSeparator.stderr); + assert.match(withSeparator.stdout, /dry run/); + const without = run(["--dry-run"], env); + assert.equal(without.stdout, withSeparator.stdout); + + const unknown = run(["--dry-run", "--dryrun"], env); + assert.notEqual(unknown.status, 0); + assert.match(unknown.stderr, /unknown argument/); + // A bare "--" alone is not a dry run: it must try to execute (docker is absent from PATH here). + const bare = run(["--"], { ...env, PATH: "" }); + assert.notEqual(bare.status, 0); + assert.doesNotMatch(bare.stdout, /dry run/); +}); + +for (const [name, env, pattern] of [ + ["image starting with a dash", { CODEVIL_SANDBOX_IMAGE: "--privileged" }, /CODEVIL_SANDBOX_IMAGE/], + ["image containing whitespace", { CODEVIL_SANDBOX_IMAGE: "ghcr.io/acme/x:1 --pull" }, /CODEVIL_SANDBOX_IMAGE/], + ["template id starting with a dash", { E2B_TEMPLATE_ID: "-bad" }, /E2B_TEMPLATE_ID/], + ["template id containing whitespace", { E2B_TEMPLATE_ID: "my template" }, /E2B_TEMPLATE_ID/], +]) { + test(`rejects ${name}`, () => { + const result = run(["--dry-run"], { + E2B_API_KEY: FAKE_API_KEY, + CODEVIL_SANDBOX_IMAGE: "ghcr.io/acme/codevil-sandbox:latest", + ...env, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, pattern); + assert.match(result.stderr, /must not start with "-" or contain whitespace/); + assert.doesNotMatch(result.stdout, /dry run/); + }); +} + +/** + * Runs the script for real (no --dry-run) against a fake `docker` first on PATH. + * The shim records argv, stdin and the names of the environment variables it was + * given, then exits with `shimExit`. The final E2B Template.build call is skipped + * through the script's test-only CODEVIL_E2B_TEMPLATE_SKIP_BUILD=1 switch. + */ +function runWithDockerShim(env, { shimExit = 0 } = {}) { + const dir = mkdtempSync(join(tmpdir(), "e2b-template-shim-")); + try { + const log = join(dir, "calls.jsonl"); + const shim = join(dir, "docker"); + writeFileSync( + shim, + `#!${process.execPath} +const fs = require("node:fs"); +const stdin = fs.readFileSync(0, "utf8"); +fs.appendFileSync(process.env.SHIM_LOG, JSON.stringify({ argv: process.argv.slice(2), stdin, envKeys: Object.keys(process.env) }) + "\\n"); +process.exit(${shimExit}); +`, + ); + chmodSync(shim, 0o755); + const result = spawnSync(process.execPath, [script], { + env: { PATH: `${dir}:${process.env.PATH ?? ""}`, SHIM_LOG: log, CODEVIL_E2B_TEMPLATE_SKIP_BUILD: "1", ...env }, + encoding: "utf8", + }); + let calls = []; + try { + calls = readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); + } catch { + // no docker call was made + } + return { result, calls }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test("real run executes build, login, push through docker with the password only on login stdin", () => { + const { result, calls } = runWithDockerShim({ + E2B_API_KEY: FAKE_API_KEY, + CODEVIL_SANDBOX_IMAGE: "registry.example.com/acme/codevil-sandbox:1.2.3", + CODEVIL_REGISTRY_USERNAME: FAKE_USERNAME, + CODEVIL_REGISTRY_PASSWORD: FAKE_PASSWORD, + }); + assert.equal(result.status, 0, result.stderr); + + assert.deepEqual( + calls.map((call) => call.argv), + [ + ["build", "--build-arg", "SANDBOX_BASE=node:22-slim", "-f", "Dockerfile.sandbox", "-t", "registry.example.com/acme/codevil-sandbox:1.2.3", "."], + ["login", "registry.example.com", "-u", FAKE_USERNAME, "--password-stdin"], + ["push", "registry.example.com/acme/codevil-sandbox:1.2.3"], + ], + ); + + // The password reaches docker on login's stdin and nowhere else. + for (const call of calls) { + assert.ok(!call.argv.some((arg) => arg.includes(FAKE_PASSWORD)), "password must never be in argv"); + assert.ok(!call.argv.some((arg) => arg === "-p" || arg === "--password")); + } + assert.deepEqual(calls.map((call) => call.stdin), ["", FAKE_PASSWORD, ""]); + + // Docker children get a scrubbed environment. + for (const call of calls) { + for (const secret of ["E2B_API_KEY", "CODEVIL_REGISTRY_PASSWORD", "CODEVIL_REGISTRY_USERNAME"]) { + assert.ok(!call.envKeys.includes(secret), `${secret} must not be passed to docker`); + } + } + + const output = `${result.stdout}\n${result.stderr}`; + for (const secret of [FAKE_API_KEY, FAKE_PASSWORD, FAKE_USERNAME]) { + assert.ok(!output.includes(secret), "no secret may appear in output"); + } +}); + +test("real run without registry credentials skips docker login", () => { + const { result, calls } = runWithDockerShim({ + E2B_API_KEY: FAKE_API_KEY, + CODEVIL_SANDBOX_IMAGE: "ghcr.io/acme/codevil-sandbox:latest", + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(calls.map((call) => call.argv[0]), ["build", "push"]); +}); + +test("real run stops and exits non-zero when a docker step fails", () => { + const { result, calls } = runWithDockerShim( + { E2B_API_KEY: FAKE_API_KEY, CODEVIL_SANDBOX_IMAGE: "ghcr.io/acme/codevil-sandbox:latest" }, + { shimExit: 3 }, + ); + assert.notEqual(result.status, 0); + assert.equal(calls.length, 1); + assert.match(result.stderr, /docker build failed with exit code 3/); +}); From 4892a9f23cd4ebb885289ee13a1ee8e2e6faf695 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 12:18:28 +0530 Subject: [PATCH 24/32] fix(sandbox-image): make skipped template publish unmistakable --- packages/sandbox-image/scripts/e2b-template.mjs | 2 +- packages/sandbox-image/test/e2b-template.test.mjs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/sandbox-image/scripts/e2b-template.mjs b/packages/sandbox-image/scripts/e2b-template.mjs index 72bb843..0a8b082 100644 --- a/packages/sandbox-image/scripts/e2b-template.mjs +++ b/packages/sandbox-image/scripts/e2b-template.mjs @@ -164,7 +164,7 @@ async function main() { } if (process.env.CODEVIL_E2B_TEMPLATE_SKIP_BUILD === "1") { - console.log("E2B template build skipped (CODEVIL_E2B_TEMPLATE_SKIP_BUILD=1)"); + console.error("E2B template NOT published: CODEVIL_E2B_TEMPLATE_SKIP_BUILD=1 is set (test-only switch)"); return; } diff --git a/packages/sandbox-image/test/e2b-template.test.mjs b/packages/sandbox-image/test/e2b-template.test.mjs index 4737004..fb5da5c 100644 --- a/packages/sandbox-image/test/e2b-template.test.mjs +++ b/packages/sandbox-image/test/e2b-template.test.mjs @@ -160,6 +160,11 @@ test("real run executes build, login, push through docker with the password only CODEVIL_REGISTRY_PASSWORD: FAKE_PASSWORD, }); assert.equal(result.status, 0, result.stderr); + assert.match( + result.stderr, + /E2B template NOT published: CODEVIL_E2B_TEMPLATE_SKIP_BUILD=1 is set \(test-only switch\)/, + ); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /E2B template ready/); assert.deepEqual( calls.map((call) => call.argv), From 46de0ae4ed410cf9d6d2d64d0f2eb00a21b23a9e Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 12:20:51 +0530 Subject: [PATCH 25/32] docs(sandbox): record E2B provider verification results --- .../2026-10-01-e2b-sandbox-provider-design.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md b/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md index cd941e9..50f3bd5 100644 --- a/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md +++ b/docs/superpowers/specs/2026-10-01-e2b-sandbox-provider-design.md @@ -300,3 +300,31 @@ Run 2 (`network: { allowPublicTraffic: false }`; sandbox `ipv0doc3yywqgunnnhbva` ``` A final `Sandbox.list` for the key returned 0 running sandboxes. + +## Verification Results + +Date: 2026-10-02. Commit tested: `4892a9f`. Command: `pnpm verify` from the repo root (`pnpm typecheck && pnpm test`), exit code 0. + +**Typecheck/build:** all packages built and typechecked cleanly (shared, site, admin-cli, sandbox-image, cli, web, worker). + +| Package | Runner | Result | +| --- | --- | --- | +| `@codevil/shared` | node:test | tests 212, pass 212, fail 0 | +| `@codevil/site` | vitest | Test Files 1 passed (1); Tests 7 passed (7) | +| `@codevil/admin-cli` | node:test | tests 34, pass 34, fail 0 | +| `@codevil/cli` | node:test | tests 28, pass 28, fail 0 | +| `@codevil/sandbox-image` | node:test | tests 131, pass 131, fail 0 | +| `@codevil/web` | vitest | Test Files 25 passed (25); Tests 291 passed (291) | +| `@codevil/worker` | node:test | tests 656, pass 656, fail 0 | + +Total: 1359 tests, 0 failures. Expected noise only: structured `wide_event` log lines (including intentional ERROR/WARN lines emitted by failure-path tests) and a Vite chunk-size warning for the web bundle. + +### Manual E2E + +Manual E2E: not run yet — pending user setup (Docker, registry, template publish approval, worker secrets). It was not claimed as passing. + +- [ ] 1. Create a Session on a real repo; clone and `npm install` complete — not run +- [ ] 2. Start preview; hot reload works through the Codevil preview URL — not run +- [ ] 3. Idle past `max_idle_time` (use `2m`); the sandbox pauses — not run +- [ ] 4. Resume once with a prompt and once by opening the preview; dev server still running and agent continues — not run +- [ ] 5. Stop the Session; the sandbox is destroyed in the E2B dashboard — not run From 654dc5e577df826b9166166201845fc9cd78da96 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 12:35:51 +0530 Subject: [PATCH 26/32] fix(sandbox): unbounded E2B agent process, create retries, static destroy, and Cloudflare production default - background agent start passes timeoutMs: 0 so the SDK's 60s deadline cannot kill it - retry E2B create on 429/5xx/network errors; stop forwarding inbound request.cf - token parent directory via install -d -m 0755 - optional SandboxProvider.destroyByRef (E2B static kill) - wrangler.toml pins SANDBOX_PROVIDER=cloudflare until E2B is ready; README and env example document E2B_API_KEY --- README.md | 10 +- packages/worker/.env.example | 4 + packages/worker/src/sandbox-provider/e2b.ts | 72 +++++++++++-- packages/worker/src/sandbox-provider/types.ts | 5 + .../worker/test/deployment-config.test.mjs | 10 ++ .../worker/test/sandbox-provider-e2b.test.mjs | 101 +++++++++++++++++- packages/worker/wrangler.toml | 6 +- 7 files changed, 188 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 94b8d14..8c5f392 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ [![CI](https://github.com/skrishnan22/codevil/actions/workflows/ci.yml/badge.svg)](https://github.com/skrishnan22/codevil/actions/workflows/ci.yml) -Codevil is a self-hosted AI coding agent platform. Each coding session runs in an isolated Cloudflare Sandbox and streams its progress to a collaborative web UI. +Codevil is a self-hosted AI coding agent platform. Each coding session runs in an isolated sandbox (an E2B sandbox or a Cloudflare Sandbox container, selectable per deployment) and streams its progress to a collaborative web UI. ## Self-hosting -Prerequisites: Node.js 20+, pnpm 10, a Cloudflare account with Workers Containers access, a Google OAuth client, at least one supported provider API key, and a fine-grained GitHub token. +Prerequisites: Node.js 20+, pnpm 10, a Cloudflare account (with Workers Containers access for the Cloudflare sandbox provider), an E2B account and API key if you use the E2B provider, a Google OAuth client, at least one supported provider API key, and a fine-grained GitHub token. Install dependencies, authenticate Wrangler, and prepare the auth, GitHub, and bootstrap secrets: @@ -22,7 +22,7 @@ Set `GITHUB_PAT`, `GOOGLE_CLIENT_ID`, and `GOOGLE_CLIENT_SECRET` in `packages/wo openssl rand -hex 32 ``` -Replace every `REPLACE_ME` placeholder before upload. Never upload the example placeholders unchanged. Once all seven values are set, upload the file through Wrangler's existing bootstrap path: +Replace every `REPLACE_ME` placeholder before upload. Never upload the example placeholders unchanged. Add `E2B_API_KEY` to the file as well if you will run sessions on E2B (see [Sandbox provider](#sandbox-provider)); leave it out for Cloudflare-only deployments. Once all values are set, upload the file through Wrangler's existing bootstrap path: ```sh cd packages/worker @@ -56,11 +56,11 @@ Open the Worker URL, sign in with Google, claim the first owner account using `C ## Sandbox provider -Codevil runs each session in either an E2B sandbox (the default) or a Cloudflare Sandbox container. Configure the provider with Worker vars in `packages/worker/wrangler.toml` (or an untracked `wrangler.operator.toml` overlay): +Codevil runs each session in either an E2B sandbox or a Cloudflare Sandbox container. The shipped `wrangler.toml` pins `SANDBOX_PROVIDER = "cloudflare"` so a deploy never switches to E2B before it is ready; to move to E2B, (1) publish the template, (2) set the `E2B_API_KEY` secret, (3) pass the manual end-to-end check, then set `SANDBOX_PROVIDER = "e2b"`. The Worker's code default (no var set) is `e2b`. Configure the provider with Worker vars in `packages/worker/wrangler.toml` (or an untracked `wrangler.operator.toml` overlay): | Name | Kind | Default | Purpose | | --- | --- | --- | --- | -| `SANDBOX_PROVIDER` | var | `e2b` | `e2b` or `cloudflare`. | +| `SANDBOX_PROVIDER` | var | `e2b` in code; `cloudflare` in the shipped `wrangler.toml` | `e2b` or `cloudflare`. | | `E2B_API_KEY` | secret | none | Required when `SANDBOX_PROVIDER=e2b`. Upload with `pnpm exec wrangler secret put E2B_API_KEY`; never put it in `wrangler.toml`. | | `E2B_TEMPLATE_ID` | var | `codevil-sandbox` | E2B template the sandboxes start from. | | `E2B_MAX_SANDBOX_SECONDS` | var | `3600` | Maximum continuous sandbox runtime; the default matches the E2B Hobby limit. | diff --git a/packages/worker/.env.example b/packages/worker/.env.example index 5d6d724..644811b 100644 --- a/packages/worker/.env.example +++ b/packages/worker/.env.example @@ -10,3 +10,7 @@ BETTER_AUTH_SECRET=REPLACE_ME_WITH_A_FOURTH_INDEPENDENT_RANDOM_VALUE GITHUB_PAT=REPLACE_ME_WITH_YOUR_FINE_GRAINED_GITHUB_TOKEN GOOGLE_CLIENT_ID=REPLACE_ME_WITH_YOUR_GOOGLE_OAUTH_CLIENT_ID GOOGLE_CLIENT_SECRET=REPLACE_ME_WITH_YOUR_GOOGLE_OAUTH_CLIENT_SECRET + +# Only needed when SANDBOX_PROVIDER=e2b (see README "Sandbox provider"). Delete this +# line for a Cloudflare-only deployment: wrangler secret bulk uploads it verbatim. +E2B_API_KEY=REPLACE_ME_WITH_YOUR_E2B_API_KEY diff --git a/packages/worker/src/sandbox-provider/e2b.ts b/packages/worker/src/sandbox-provider/e2b.ts index b68a91d..b573c49 100644 --- a/packages/worker/src/sandbox-provider/e2b.ts +++ b/packages/worker/src/sandbox-provider/e2b.ts @@ -6,6 +6,7 @@ import { type ShellResult, } from "./types.js"; import { OWNER_IDS, shellQuote } from "./shell.js"; +import { retrySandboxOperation } from "../sandbox.js"; export const E2B_TRAFFIC_TOKEN_HEADER = "e2b-traffic-access-token"; @@ -50,6 +51,8 @@ export interface E2BSdk { metadata: Record; }): Promise; connect(sandboxId: string, options: { apiKey: string; timeoutMs?: number }): Promise; + /** Kills a sandbox by id without resuming it; resolves false when it does not exist. */ + kill(sandboxId: string, options: { apiKey: string }): Promise; isNotFound(error: unknown): boolean; } @@ -61,6 +64,35 @@ export interface E2BSandboxProviderOptions { /** Injected in tests; production lazily imports `e2b`. */ sdk?: E2BSdk; fetch?: typeof fetch; + /** Delay between create retries; injected in tests. */ + sleep?: (ms: number) => Promise; +} + +const CREATE_ATTEMPTS = 3; +const CREATE_RETRY_BASE_DELAY_MS = 500; +const TRANSIENT_NETWORK_CODES = new Set([ + "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND", "EPIPE", + "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", +]); + +/** Rate limits (429), server errors (5xx) and network failures; never other 4xx (bad key, bad template). */ +export function isRetryableE2BCreateError(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const candidate = error as { statusCode?: unknown; message?: unknown; name?: unknown; code?: unknown; cause?: unknown }; + const message = typeof candidate.message === "string" ? candidate.message : ""; + const status = typeof candidate.statusCode === "number" + ? candidate.statusCode + : Number(/^(\d{3}):/.exec(message)?.[1]); + if (Number.isFinite(status)) return status === 429 || status >= 500; + if (candidate.name === "RateLimitError") return true; + const cause = typeof candidate.cause === "object" && candidate.cause !== null + ? (candidate.cause as { code?: unknown }) + : undefined; + for (const code of [candidate.code, cause?.code]) { + if (typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code)) return true; + } + // undici surfaces connection failures as `TypeError: fetch failed`. + return candidate.name === "TypeError" && /fetch failed|network/i.test(message); } export function createE2BSandboxProvider(options: E2BSandboxProviderOptions): SandboxProvider { @@ -74,12 +106,20 @@ export function createE2BSandboxProvider(options: E2BSandboxProviderOptions): Sa async create({ sessionId, leaseMs }) { // `secure` is ignored in e2b 2.x and a top-level `allowPublicTraffic` // is not honored: only `network.allowPublicTraffic` locks the sandbox. - const sandbox = await sdk.create(options.templateId, { - apiKey: options.apiKey, - timeoutMs: cap(leaseMs), - network: { allowPublicTraffic: false }, - metadata: { codevil_session_id: sessionId }, - }); + const sandbox = await retrySandboxOperation( + () => sdk.create(options.templateId, { + apiKey: options.apiKey, + timeoutMs: cap(leaseMs), + network: { allowPublicTraffic: false }, + metadata: { codevil_session_id: sessionId }, + }), + { + attempts: CREATE_ATTEMPTS, + baseDelayMs: CREATE_RETRY_BASE_DELAY_MS, + retryOn: isRetryableE2BCreateError, + ...(options.sleep ? { sleep: options.sleep } : {}), + }, + ); if (!sandbox.trafficAccessToken) { // Without a token the sandbox may be publicly reachable; never use it. const cleanupFailed = await sandbox.kill().then(() => false, () => true); @@ -109,6 +149,14 @@ export function createE2BSandboxProvider(options: E2BSandboxProviderOptions): Sa const secret = connectOptions?.secret ?? sandbox.trafficAccessToken; return e2bHandle({ sandbox, ref, ...(secret ? { secret } : {}), sdk, cap, fetchImpl }); }, + async destroyByRef(ref: SandboxRef) { + // Static kill: never resumes a paused sandbox. A missing sandbox is already gone. + try { + await sdk.kill(ref.id, { apiKey: options.apiKey }); + } catch (error) { + if (!sdk.isNotFound(error)) throw error; + } + }, }; } @@ -164,7 +212,9 @@ function e2bHandle(context: { const steps: string[] = []; if (fileOptions?.mode !== undefined) steps.push(`chmod ${fileOptions.mode.toString(8)} ${shellQuote(stage)}`); if (fileOptions?.owner !== undefined) steps.push(`chown ${OWNER_IDS[fileOptions.owner]} ${shellQuote(stage)}`); - steps.push(`mkdir -p ${shellQuote(parentDirectory(path))}`); + // `install -d` (not `mkdir -p`) so a parent created here is root-owned 0755 + // whatever the umask: the agent may traverse it but never replace its entries. + steps.push(`install -d -m 0755 ${shellQuote(parentDirectory(path))}`); steps.push(`mv -fT ${shellQuote(stage)} ${shellQuote(path)}`); assertOk(await exec(steps.join(" && ")), `Failed to write ${path}`); } catch (error) { @@ -182,7 +232,8 @@ function e2bHandle(context: { const redirected = `sh -c ${shellQuote(command)} >${LOG_DIR}/${processId}.out 2>${LOG_DIR}/${processId}.err`; let handle: { disconnect(): Promise }; try { - handle = await sandbox.commands.run(redirected, { background: true, user: "root", cwd, envs: env }); + // `timeoutMs: 0` disables the SDK's 60 s default deadline, which would otherwise kill the long-lived agent. + handle = await sandbox.commands.run(redirected, { background: true, user: "root", cwd, envs: env, timeoutMs: 0 }); } catch (error) { throw mapNotFound(sdk, error); } @@ -214,7 +265,6 @@ function e2bHandle(context: { body: request.body, redirect: "manual", signal: request.signal, - ...(request.cf ? { cf: request.cf } : {}), ...(request.body ? { duplex: "half" as const } : {}), }; return context.fetchImpl(new Request(target, init)); @@ -289,6 +339,10 @@ function lazyE2BSdk(): E2BSdk { const { Sandbox } = await load(); return (await Sandbox.connect(sandboxId, connectOptions)) as unknown as E2BSdkSandbox; }, + async kill(sandboxId, killOptions) { + const { Sandbox } = await load(); + return Sandbox.kill(sandboxId, killOptions); + }, // Synchronous by contract: any error the SDK can throw implies it is already loaded. isNotFound: (error) => notFoundClass !== undefined && error instanceof notFoundClass, }; diff --git a/packages/worker/src/sandbox-provider/types.ts b/packages/worker/src/sandbox-provider/types.ts index ec24b8c..6889e07 100644 --- a/packages/worker/src/sandbox-provider/types.ts +++ b/packages/worker/src/sandbox-provider/types.ts @@ -14,6 +14,11 @@ export interface SandboxProvider { create(options: { sessionId: string; leaseMs: number }): Promise; /** `secret` is the opaque value returned as `handle.secret` at create time. */ connect(ref: SandboxRef, options?: { leaseMs?: number; secret?: string }): Promise; + /** + * Kills a sandbox without connecting to it, so a paused sandbox is not resumed + * first. A missing sandbox counts as success. Providers that cannot pause omit it. + */ + destroyByRef?(ref: SandboxRef): Promise; } export interface SandboxHandle { diff --git a/packages/worker/test/deployment-config.test.mjs b/packages/worker/test/deployment-config.test.mjs index 40305f3..332703b 100644 --- a/packages/worker/test/deployment-config.test.mjs +++ b/packages/worker/test/deployment-config.test.mjs @@ -22,6 +22,16 @@ test("checked-in Worker config is portable and defaults to workers.dev", async ( assert.match(config, /binding = "BACKUP_BUCKET"/); }); +test("checked-in Worker config keeps production on the Cloudflare sandbox until E2B is ready", async () => { + const [config, envExample] = await Promise.all([ + readFile(resolve(workerRoot, "wrangler.toml"), "utf8"), + readFile(resolve(workerRoot, ".env.example"), "utf8"), + ]); + assert.match(config, /^SANDBOX_PROVIDER = "cloudflare"$/m); + assert.doesNotMatch(config, /E2B_API_KEY/); + assert.match(envExample, /^E2B_API_KEY=/m); +}); + test("operator config and local variables are templates, not deployment credentials", async () => { const [ignore, operatorTemplate, varsTemplate] = await Promise.all([ readFile(resolve(repoRoot, ".gitignore"), "utf8"), diff --git a/packages/worker/test/sandbox-provider-e2b.test.mjs b/packages/worker/test/sandbox-provider-e2b.test.mjs index 49027f4..7032ad5 100644 --- a/packages/worker/test/sandbox-provider-e2b.test.mjs +++ b/packages/worker/test/sandbox-provider-e2b.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { createE2BSandboxProvider, E2B_TRAFFIC_TOKEN_HEADER } from "../dist/sandbox-provider/e2b.js"; +import { createE2BSandboxProvider, E2B_TRAFFIC_TOKEN_HEADER, isRetryableE2BCreateError } from "../dist/sandbox-provider/e2b.js"; import { SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; import { collectWorkerSecretValues } from "../dist/worker-env.js"; import { configuredSandboxProviderName, e2bMaxLeaseMs, resolveSandboxProvider } from "../dist/sandbox-provider/index.js"; @@ -39,7 +39,16 @@ function fakeSdk(options = {}) { kill: async () => { log.push(["kill"]); if (options.killError) throw options.killError; }, }; const sdk = { - create: async (template, opts) => { log.push(["create", template, opts]); return sandbox; }, + create: async (template, opts) => { + log.push(["create", template, opts]); + if (options.createErrors?.length) throw options.createErrors.shift(); + return sandbox; + }, + kill: async (id, opts) => { + log.push(["staticKill", id, opts]); + if (options.staticKillError) throw options.staticKillError; + return true; + }, connect: async (id, opts) => { log.push(["connect", id, opts]); if (id === "gone") throw new NotFound("sandbox gone"); @@ -160,7 +169,7 @@ test("startProcess redirects output to per-process log files and readProcessLogs const background = fake.log.find(([kind, , opts]) => kind === "run" && opts?.background); assert.match(background[1], /^sh -c '.*' >\/var\/log\/codevil\/codevil-agent\.out 2>\/var\/log\/codevil\/codevil-agent\.err$/); assert.ok(background[1].includes(`node /app/x.js --name '\\''a b'\\''`)); - assert.deepEqual(background[2], { background: true, user: "root", cwd: "/workspace", envs: { A: "1" } }); + assert.deepEqual(background[2], { background: true, user: "root", cwd: "/workspace", envs: { A: "1" }, timeoutMs: 0 }); const mkdirIndex = fake.log.findIndex(([, cmd]) => cmd === "mkdir -p /var/log/codevil"); assert.ok(mkdirIndex >= 0 && mkdirIndex < fake.log.indexOf(background)); assert.deepEqual(fake.log[fake.log.indexOf(background) + 1], ["disconnect"]); @@ -182,7 +191,7 @@ test("writeFile stages as root, permissions the staged file, then renames over t assert.equal(fake.log[writeIndex - 1][1], "mkdir -p -m 700 /run/.codevil-stage && chmod 700 /run/.codevil-stage && chown 0:0 /run/.codevil-stage"); assert.equal( fake.log[writeIndex + 1][1], - `chmod 600 '${stage}' && chown 10001:10001 '${stage}' && mkdir -p '/run/codevil' && mv -fT '${stage}' '/run/codevil/ws-token'`, + `chmod 600 '${stage}' && chown 10001:10001 '${stage}' && install -d -m 0755 '/run/codevil' && mv -fT '${stage}' '/run/codevil/ws-token'`, ); }); @@ -215,7 +224,7 @@ test("writeFile quotes hostile paths and skips chmod/chown when not requested", const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); await handle.writeFile("/run/it's/x y", "tok"); const final = fake.log.at(-1)[1]; - assert.match(final, /^mkdir -p '\/run\/it'\\''s' && mv -fT '\/run\/\.codevil-stage\/[0-9a-f-]{36}' '\/run\/it'\\''s\/x y'$/); + assert.match(final, /^install -d -m 0755 '\/run\/it'\\''s' && mv -fT '\/run\/\.codevil-stage\/[0-9a-f-]{36}' '\/run\/it'\\''s\/x y'$/); }); test("writeFile throws on any failing step and removes the staged file", async () => { @@ -320,3 +329,85 @@ test("resolveSandboxProvider builds the e2b provider from env", () => { const provider = resolveSandboxProvider({ Sandbox: {}, E2B_API_KEY: "e2b_key" }, "e2b"); assert.equal(provider.name, "e2b"); }); + +test("the background agent start disables the SDK's default 60s command deadline", async () => { + const { p, fake } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + await handle.startProcess("node agent.js", { processId: "codevil-agent", cwd: "/workspace", env: {} }); + const background = fake.log.find(([kind, , opts]) => kind === "run" && opts?.background); + assert.strictEqual(background[2].timeoutMs, 0); +}); + +test("create retries a 429, a 5xx and a network failure, then succeeds", async () => { + const sleeps = []; + const rateLimited = Object.assign(new Error("slow down"), { name: "RateLimitError", statusCode: 429 }); + const serverError = Object.assign(new Error("502: bad gateway"), { statusCode: 502 }); + const { p, fake } = provider( + { sleep: async (ms) => { sleeps.push(ms); } }, + { createErrors: [rateLimited, serverError] }, + ); + const handle = await p.create({ sessionId: "ses_1", leaseMs: 600_000 }); + assert.equal(handle.ref.id, "sbx_1"); + assert.equal(fake.log.filter(([kind]) => kind === "create").length, 3); + assert.equal(sleeps.length, 2); + + const network = provider( + { sleep: async () => {} }, + { createErrors: [Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } })] }, + ); + await network.p.create({ sessionId: "ses_1", leaseMs: 600_000 }); + assert.equal(network.fake.log.filter(([kind]) => kind === "create").length, 2); +}); + +test("create gives up after three attempts and surfaces the last error", async () => { + const errors = [1, 2, 3, 4].map((n) => Object.assign(new Error(`503 try ${n}`), { statusCode: 503 })); + const { p, fake } = provider({ sleep: async () => {} }, { createErrors: errors }); + await assert.rejects(p.create({ sessionId: "ses_1", leaseMs: 600_000 }), /503 try 3/); + assert.equal(fake.log.filter(([kind]) => kind === "create").length, 3); +}); + +test("create does not retry 4xx errors", async () => { + for (const statusCode of [400, 401, 403, 404]) { + const { p, fake } = provider( + { sleep: async () => assert.fail("must not sleep") }, + { createErrors: [Object.assign(new Error("denied"), { statusCode })] }, + ); + await assert.rejects(p.create({ sessionId: "ses_1", leaseMs: 600_000 }), /denied/); + assert.equal(fake.log.filter(([kind]) => kind === "create").length, 1); + } + assert.equal(isRetryableE2BCreateError(new Error("template invalid")), false); + assert.equal(isRetryableE2BCreateError("x"), false); +}); + +test("fetchPort does not forward the inbound request's cf options", async () => { + const { p } = provider(); + const handle = await p.connect({ provider: "e2b", id: "sbx_1" }); + const inbound = new Request("http://localhost/"); + Object.defineProperty(inbound, "cf", { value: { colo: "SFO", cacheEverything: true } }); + const NativeRequest = globalThis.Request; + const outboundInits = []; + globalThis.Request = class extends NativeRequest { + constructor(input, init) { + super(input, init); + if (typeof input === "string") outboundInits.push(init); + } + }; + try { + await handle.fetchPort(3000, inbound); + } finally { + globalThis.Request = NativeRequest; + } + assert.equal(outboundInits.length, 1); + assert.equal("cf" in outboundInits[0], false); +}); + +test("destroyByRef kills statically without connecting; not-found is success", async () => { + const { p, fake } = provider(); + await p.destroyByRef({ provider: "e2b", id: "sbx_9" }); + assert.deepEqual(fake.log, [["staticKill", "sbx_9", { apiKey: "e2b_key" }]]); + + const gone = provider({}, { staticKillError: new NotFound("gone") }); + await gone.p.destroyByRef({ provider: "e2b", id: "sbx_9" }); + const broken = provider({}, { staticKillError: new Error("boom") }); + await assert.rejects(broken.p.destroyByRef({ provider: "e2b", id: "sbx_9" }), /boom/); +}); diff --git a/packages/worker/wrangler.toml b/packages/worker/wrangler.toml index d54fc92..cfa35bd 100644 --- a/packages/worker/wrangler.toml +++ b/packages/worker/wrangler.toml @@ -7,7 +7,11 @@ upload_source_maps = true [vars] EMAIL_PROVIDER = "none" -SANDBOX_PROVIDER = "e2b" +# Production stays on Cloudflare until E2B is ready. Switch to "e2b" only after +# publishing the template (`pnpm --filter @codevil/sandbox-image e2b:template`), +# setting the E2B_API_KEY secret, and passing the manual E2B end-to-end check. +# (Code default with no var at all is "e2b"; keep this explicit.) +SANDBOX_PROVIDER = "cloudflare" E2B_TEMPLATE_ID = "codevil-sandbox" CODEVIL_WEB_ORIGIN = "http://localhost:5173,http://localhost:8787" # Leaving CODEVIL_PREVIEW_ORIGIN unset keeps preview URLs on the portable From 7d95e37d425f7649c99632e025ddf4427d6ed641 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 12:36:02 +0530 Subject: [PATCH 27/32] test(worker): assert no E2B_API_KEY assignment in wrangler.toml --- packages/worker/test/deployment-config.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/worker/test/deployment-config.test.mjs b/packages/worker/test/deployment-config.test.mjs index 332703b..c458011 100644 --- a/packages/worker/test/deployment-config.test.mjs +++ b/packages/worker/test/deployment-config.test.mjs @@ -28,7 +28,7 @@ test("checked-in Worker config keeps production on the Cloudflare sandbox until readFile(resolve(workerRoot, ".env.example"), "utf8"), ]); assert.match(config, /^SANDBOX_PROVIDER = "cloudflare"$/m); - assert.doesNotMatch(config, /E2B_API_KEY/); + assert.doesNotMatch(config, /^E2B_API_KEY\s*=/m); assert.match(envExample, /^E2B_API_KEY=/m); }); From 79a910075fd3e2b53afbe51fe51c3c138f9de67c Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 12:38:23 +0530 Subject: [PATCH 28/32] feat(sandbox): allow the E2B preview host, adopt resumed tokens immediately, and build the E2B image in CI - worker passes CODEVIL_SANDBOX_PROVIDER; preview manager sets __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=.e2b.app on e2b - agent polls the ws-token file every 2s and reconnects at once when a new token is adopted - readTokenFile non-ENOENT WARN branch is tested - CI sandbox-image job also builds and smoke-tests the E2B variant --- .github/workflows/ci.yml | 7 ++ packages/sandbox-image/src/entrypoint.ts | 42 +++++++ packages/sandbox-image/src/preview-manager.ts | 9 +- packages/sandbox-image/src/preview-spawn.ts | 29 +++++ packages/sandbox-image/src/socket-client.ts | 20 ++++ .../sandbox-image/test/preview-spawn.test.mjs | 28 +++++ .../sandbox-image/test/socket-client.test.mjs | 43 +++++++ .../sandbox-image/test/ws-token-file.test.mjs | 113 +++++++++++++++++- .../src/orchestrator/sandbox-handlers.ts | 1 + packages/worker/src/sandbox.ts | 3 + .../worker/test/deployment-config.test.mjs | 8 ++ packages/worker/test/sandbox.test.mjs | 11 ++ 12 files changed, 306 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 148cd3a..6e172b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,13 @@ jobs: - run: node packages/sandbox-image/test/sandbox-image-smoke.mjs + # The E2B template is built from the same Dockerfile on a plain Node base. + - run: docker build -f Dockerfile.sandbox --build-arg SANDBOX_BASE=node:22-slim -t codevil-sandbox-e2b:ci . + + - run: node packages/sandbox-image/test/sandbox-image-smoke.mjs + env: + CODEVIL_SANDBOX_IMAGE: codevil-sandbox-e2b:ci + deploy: if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: diff --git a/packages/sandbox-image/src/entrypoint.ts b/packages/sandbox-image/src/entrypoint.ts index acfe015..814830b 100644 --- a/packages/sandbox-image/src/entrypoint.ts +++ b/packages/sandbox-image/src/entrypoint.ts @@ -122,6 +122,18 @@ export async function startEntrypoint( }); const dispatch = createSandboxMessageDispatcher(runtime); + watchSandboxWsToken({ + tokenSource: wsTokenSource, + getUrl: () => wsUrl, + setUrl: (url) => { wsUrl = url; }, + // A resumed sandbox's old socket is dead but only noticed after the heartbeat + // timeout; the new token is the signal to replace it now. + onTokenAdopted: () => { + sandboxLogger().log("INFO", "sandbox.ws_token_file.adopted"); + connection.reconnectNow(); + }, + }); + connection = new ReconnectingWebSocketClient({ createSocket: () => { // A resumed sandbox may have an expired in-memory token; the Orchestrator @@ -204,6 +216,36 @@ export function createSandboxWsTokenSource( }; } +export const WS_TOKEN_POLL_INTERVAL_MS = 2_000; + +/** + * Polls the token file and calls `onTokenAdopted` when it supplied a token the + * process had not used yet (an unchanged file is ignored, per the token + * source's rules). Returns a function that stops the polling. The timer never + * keeps the process alive and nothing here logs the token. + */ +export function watchSandboxWsToken(options: { + tokenSource: SandboxWsTokenSource; + getUrl(): string; + setUrl(url: string): void; + onTokenAdopted(): void; + intervalMs?: number; + setInterval?: (callback: () => void, delayMs: number) => { unref?: () => unknown }; + clearInterval?: (timer: never) => void; +}): () => void { + const schedule = options.setInterval ?? ((callback, delayMs) => setInterval(callback, delayMs)); + const cancel = options.clearInterval ?? ((timer: never) => clearInterval(timer)); + const timer = schedule(() => { + const current = options.getUrl(); + const next = options.tokenSource.applyTo(current); + if (next === current) return; + options.setUrl(next); + options.onTokenAdopted(); + }, options.intervalMs ?? WS_TOKEN_POLL_INTERVAL_MS); + timer.unref?.(); + return () => cancel(timer as never); +} + export function withSandboxWebSocketToken(wsUrl: string, token: string | undefined): string { if (!token) return wsUrl; const url = new URL(wsUrl); diff --git a/packages/sandbox-image/src/preview-manager.ts b/packages/sandbox-image/src/preview-manager.ts index b14b095..f7428c0 100644 --- a/packages/sandbox-image/src/preview-manager.ts +++ b/packages/sandbox-image/src/preview-manager.ts @@ -8,7 +8,7 @@ import type { PreviewApp, PreviewFramework } from "@codevil/shared"; import { sandboxLogException, sandboxLogger } from "./logging.js"; import { detectPackageManager } from "./package-manager.js"; -import { PreviewCommandRejectedError, resolvePreviewSpawn } from "./preview-spawn.js"; +import { PreviewCommandRejectedError, previewProcessEnv, resolvePreviewSpawn } from "./preview-spawn.js"; export interface PreviewCommand { command: string; @@ -115,12 +115,7 @@ export class PreviewManager { detached: process.platform !== "win32", shell: false, stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - HOST: "0.0.0.0", - HOSTNAME: "0.0.0.0", - PORT: String(command.port), - }, + env: previewProcessEnv(process.env, command.port), }); this.state = { state: "starting", command, child }; collectLines(child.stdout, recordLog); diff --git a/packages/sandbox-image/src/preview-spawn.ts b/packages/sandbox-image/src/preview-spawn.ts index 35fdfe1..5d4c06a 100644 --- a/packages/sandbox-image/src/preview-spawn.ts +++ b/packages/sandbox-image/src/preview-spawn.ts @@ -121,3 +121,32 @@ function containsForbiddenOutsideQuotes(input: string): boolean { return flush(); } + +const VITE_ALLOWED_HOSTS_ENV = "__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS"; +const E2B_PREVIEW_HOST_SUFFIX = ".e2b.app"; + +/** + * Environment for the preview dev server. On E2B the server is reached as + * `-.e2b.app`, which Vite 5.4.12+/6.0.9+ rejects with 403 unless the + * host is allowed; the allowed-hosts variable is merged with any existing value. + */ +export function previewProcessEnv( + base: NodeJS.ProcessEnv, + port: number, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...base, + HOST: "0.0.0.0", + HOSTNAME: "0.0.0.0", + PORT: String(port), + }; + if (base.CODEVIL_SANDBOX_PROVIDER === "e2b") { + const existing = (base[VITE_ALLOWED_HOSTS_ENV] ?? "") + .split(",") + .map((host) => host.trim()) + .filter(Boolean); + if (!existing.includes(E2B_PREVIEW_HOST_SUFFIX)) existing.push(E2B_PREVIEW_HOST_SUFFIX); + env[VITE_ALLOWED_HOSTS_ENV] = existing.join(","); + } + return env; +} diff --git a/packages/sandbox-image/src/socket-client.ts b/packages/sandbox-image/src/socket-client.ts index 009a664..c8f4339 100644 --- a/packages/sandbox-image/src/socket-client.ts +++ b/packages/sandbox-image/src/socket-client.ts @@ -76,6 +76,26 @@ export class ReconnectingWebSocketClient { this.socket = undefined; } + /** + * Drops the current socket (live, half-dead, or mid-backoff) and reconnects + * at once with a fresh `createSocket()` call, e.g. after the credentials the + * old socket was opened with changed. Queued messages are kept for the new socket. + */ + reconnectNow(): void { + if (this.stopped) return; + this.clearConnectionTimers(); + if (this.reconnectTimer !== undefined) { + this.options.clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + const previous = this.socket; + // Detach first: every handler ignores a socket that is no longer current. + this.socket = undefined; + previous?.terminate(); + this.reconnectAttempt = 0; + this.connect(); + } + send(value: string): void { if (this.socket?.readyState === OPEN) { this.socket.send(value); diff --git a/packages/sandbox-image/test/preview-spawn.test.mjs b/packages/sandbox-image/test/preview-spawn.test.mjs index ae9b90c..acdcb29 100644 --- a/packages/sandbox-image/test/preview-spawn.test.mjs +++ b/packages/sandbox-image/test/preview-spawn.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { PreviewCommandRejectedError, + previewProcessEnv, resolvePreviewSpawn, tokenizeCommandLine, } from "../dist/preview-spawn.js"; @@ -41,3 +42,30 @@ test("resolvePreviewSpawn accepts node one-liners", () => { assert.equal(spawn.executable, "node"); assert.equal(spawn.argv[0], "-e"); }); + +test("previewProcessEnv allows the E2B preview host for Vite only on the e2b provider", () => { + const e2b = previewProcessEnv({ CODEVIL_SANDBOX_PROVIDER: "e2b", KEEP: "1" }, 5173); + assert.equal(e2b.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS, ".e2b.app"); + assert.equal(e2b.PORT, "5173"); + assert.equal(e2b.HOST, "0.0.0.0"); + assert.equal(e2b.KEEP, "1"); + + for (const provider of ["cloudflare", undefined]) { + const env = previewProcessEnv(provider ? { CODEVIL_SANDBOX_PROVIDER: provider } : {}, 3000); + assert.equal("__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS" in env, false); + assert.equal(env.PORT, "3000"); + } +}); + +test("previewProcessEnv merges with an existing allowed-hosts value without duplicating", () => { + const merged = previewProcessEnv({ + CODEVIL_SANDBOX_PROVIDER: "e2b", + __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: "example.test, other.test", + }, 3000); + assert.equal(merged.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS, "example.test,other.test,.e2b.app"); + const again = previewProcessEnv({ + CODEVIL_SANDBOX_PROVIDER: "e2b", + __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: ".e2b.app", + }, 3000); + assert.equal(again.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS, ".e2b.app"); +}); diff --git a/packages/sandbox-image/test/socket-client.test.mjs b/packages/sandbox-image/test/socket-client.test.mjs index 9e22890..dc4bab0 100644 --- a/packages/sandbox-image/test/socket-client.test.mjs +++ b/packages/sandbox-image/test/socket-client.test.mjs @@ -147,3 +147,46 @@ test("buffers outbound messages and flushes them after reconnect", () => { assert.deepEqual(sockets[1].sent, ["during reconnect"]); }); + +test("reconnectNow drops the live socket and reconnects immediately with a fresh socket", () => { + const { client, sockets, scheduler, events } = createHarness(); + client.start(); + sockets[0].open(); + client.send("before"); + assert.equal(sockets.length, 1); + + client.reconnectNow(); + + assert.equal(sockets[0].terminated, true); + assert.equal(sockets.length, 2); + // The replaced socket's late close must not trigger a second reconnect. + sockets[0].disconnect(1006, "late"); + assert.equal(sockets.length, 2); + assert.deepEqual(scheduler.delays(), []); + assert.deepEqual(events.filter((event) => event.startsWith("close")), []); + + sockets[1].open(); + client.send("after"); + assert.deepEqual(sockets[1].sent.slice(-1), ["after"]); +}); + +test("reconnectNow also cuts a pending backoff short", () => { + const { client, sockets, scheduler } = createHarness(); + client.start(); + sockets[0].open(); + sockets[0].disconnect(); + assert.deepEqual(scheduler.delays().includes(1_000), true); + + client.reconnectNow(); + + assert.equal(sockets.length, 2); + assert.equal(scheduler.delays().includes(1_000), false); +}); + +test("reconnectNow is a no-op once the client is stopped", () => { + const { client, sockets } = createHarness(); + client.start(); + client.stop(); + client.reconnectNow(); + assert.equal(sockets.length, 1); +}); diff --git a/packages/sandbox-image/test/ws-token-file.test.mjs b/packages/sandbox-image/test/ws-token-file.test.mjs index ed5b0b6..93d2fd5 100644 --- a/packages/sandbox-image/test/ws-token-file.test.mjs +++ b/packages/sandbox-image/test/ws-token-file.test.mjs @@ -1,11 +1,13 @@ import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { defaultTracerSink, setTracerSink } from "@codevil/shared"; import { createSandboxWsTokenSource, readTokenFile, + watchSandboxWsToken, withSandboxWebSocketToken, } from "../dist/entrypoint.js"; @@ -47,3 +49,112 @@ test("readTokenFile reads a file and returns undefined for a missing path", () = assert.equal(readTokenFile(path)?.trim(), "fresh"); assert.equal(readTokenFile(join(dir, "missing")), undefined); }); + +function captureLogs(run) { + const lines = []; + setTracerSink((line) => lines.push(line)); + try { + run(); + } finally { + setTracerSink(defaultTracerSink); + } + return lines; +} + +test("readTokenFile stays quiet for a missing file", () => { + const dir = mkdtempSync(join(tmpdir(), "ws-token-")); + const lines = captureLogs(() => { + assert.equal(readTokenFile(join(dir, "missing")), undefined); + }); + assert.deepEqual(lines, []); +}); + +test("readTokenFile logs a WARN with the error code, never the content, when the read fails for another reason", () => { + const dir = mkdtempSync(join(tmpdir(), "ws-token-")); + const secretPath = join(dir, "secret-token"); + writeFileSync(secretPath, "very-secret-token\n"); + chmodSync(secretPath, 0o000); + const unreadable = (() => { + try { writeFileSync(secretPath, "x", { flag: "r" }); } catch { /* expected */ } + return process.getuid?.() !== 0; + })(); + // A directory is unreadable as a file for every user, root included (EISDIR). + const directoryPath = join(dir, "is-a-dir"); + mkdirSync(directoryPath); + + const cases = [[directoryPath, "EISDIR"]]; + if (unreadable) cases.push([secretPath, "EACCES"]); + for (const [path, code] of cases) { + const lines = captureLogs(() => { + assert.equal(readTokenFile(path), undefined); + }); + assert.equal(lines.length, 1); + assert.equal(lines[0].severity, "WARN"); + assert.equal(lines[0].operation, "sandbox.ws_token_file.read_failed"); + assert.match(JSON.stringify(lines[0]), new RegExp(code)); + assert.doesNotMatch(JSON.stringify(lines[0]), /very-secret-token/); + } + chmodSync(secretPath, 0o600); +}); + +function watcherHarness(initialUrl = BASE) { + let fileContent; + let wsUrl = initialUrl; + const adopted = []; + const timers = []; + const cleared = []; + const unrefs = []; + const stop = watchSandboxWsToken({ + tokenSource: createSandboxWsTokenSource(() => fileContent), + getUrl: () => wsUrl, + setUrl: (url) => { wsUrl = url; }, + onTokenAdopted: () => adopted.push(wsUrl), + setInterval: (callback, delay) => { + const timer = { callback, delay, unref: () => unrefs.push(true) }; + timers.push(timer); + return timer; + }, + clearInterval: (timer) => cleared.push(timer), + }); + return { + adopted, timers, cleared, unrefs, stop, + tick: () => timers[0].callback(), + write: (value) => { fileContent = value; }, + get url() { return wsUrl; }, + }; +} + +test("token watcher polls every 2s on an unref'd timer and adopts only changed tokens", () => { + const h = watcherHarness(); + assert.equal(h.timers.length, 1); + assert.equal(h.timers[0].delay, 2_000); + assert.equal(h.unrefs.length, 1); + + h.tick(); // no file + assert.deepEqual(h.adopted, []); + + h.write("fresh-1\n"); + h.tick(); + assert.equal(tokenOf(h.url), "fresh-1"); + assert.equal(h.adopted.length, 1); + + h.tick(); // unchanged file: no second adoption + h.tick(); + assert.equal(h.adopted.length, 1); + + h.write("fresh-2\n"); + h.tick(); + assert.equal(tokenOf(h.url), "fresh-2"); + assert.equal(h.adopted.length, 2); + + h.stop(); + assert.equal(h.cleared.length, 1); +}); + +test("token watcher does not reconnect when the file token is the one already in the URL", () => { + const h = watcherHarness(withSandboxWebSocketToken(BASE, "same")); + h.write("same\n"); + h.tick(); + h.tick(); + assert.deepEqual(h.adopted, []); +}); diff --git a/packages/worker/src/orchestrator/sandbox-handlers.ts b/packages/worker/src/orchestrator/sandbox-handlers.ts index 8bb9ba6..6a903e9 100644 --- a/packages/worker/src/orchestrator/sandbox-handlers.ts +++ b/packages/worker/src/orchestrator/sandbox-handlers.ts @@ -105,6 +105,7 @@ export async function provisionSessionSandbox(host: OrchestratorHost): Promise 0 ? { CODEVIL_PROVIDER_CONFIG: JSON.stringify(options.providerConfig) } : {}), diff --git a/packages/worker/test/deployment-config.test.mjs b/packages/worker/test/deployment-config.test.mjs index c458011..f10375e 100644 --- a/packages/worker/test/deployment-config.test.mjs +++ b/packages/worker/test/deployment-config.test.mjs @@ -32,6 +32,14 @@ test("checked-in Worker config keeps production on the Cloudflare sandbox until assert.match(envExample, /^E2B_API_KEY=/m); }); +test("CI builds and smoke-tests both the Cloudflare and the E2B sandbox image variants", async () => { + const workflow = await readFile(resolve(repoRoot, ".github/workflows/ci.yml"), "utf8"); + assert.match(workflow, /docker build -f Dockerfile\.sandbox -t codevil-sandbox:ci \./); + assert.match(workflow, /--build-arg SANDBOX_BASE=node:22-slim -t codevil-sandbox-e2b:ci/); + assert.match(workflow, /CODEVIL_SANDBOX_IMAGE: codevil-sandbox-e2b:ci/); + assert.equal(workflow.match(/sandbox-image-smoke\.mjs/g)?.length, 2); +}); + test("operator config and local variables are templates, not deployment credentials", async () => { const [ignore, operatorTemplate, varsTemplate] = await Promise.all([ readFile(resolve(repoRoot, ".gitignore"), "utf8"), diff --git a/packages/worker/test/sandbox.test.mjs b/packages/worker/test/sandbox.test.mjs index 99d819b..e4aae9e 100644 --- a/packages/worker/test/sandbox.test.mjs +++ b/packages/worker/test/sandbox.test.mjs @@ -482,3 +482,14 @@ test("does not retry non-transient sandbox failures", async () => { assert.equal(calls, 1); }); + +test("sandbox process environment names the sandbox provider only when one is given", () => { + const base = { + wsUrl: "wss://codevil.example.com/sessions/ses_123/sandbox/ws", + wsToken: "token", + provider: "anthropic", + }; + assert.equal(sandboxProcessEnv({ ...base, sandboxProvider: "e2b" }).CODEVIL_SANDBOX_PROVIDER, "e2b"); + assert.equal(sandboxProcessEnv({ ...base, sandboxProvider: "cloudflare" }).CODEVIL_SANDBOX_PROVIDER, "cloudflare"); + assert.equal("CODEVIL_SANDBOX_PROVIDER" in sandboxProcessEnv(base), false); +}); From d4624d88dbd8f84f57f7f063344ee993019a9d23 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 12:43:21 +0530 Subject: [PATCH 29/32] fix(sandbox): fail fast on a missing provider key, surface provisioning reasons, and clean up on failure - createSession/handleCreateSession resolve the provider before the D1 insert (missing E2B_API_KEY fails clearly, no row) - provisioning failure broadcasts the redacted reason and destroys an already-created sandbox - agent error outside a run destroys the sandbox - warn when max_time exceeds the E2B continuous runtime limit - answering a question counts as activity --- packages/worker/src/http-handlers.ts | 11 +- .../src/orchestrator/question-answer.ts | 2 + .../src/orchestrator/sandbox-handlers.ts | 34 ++++- packages/worker/src/session-service.ts | 4 +- .../worker/test/logging-redaction.test.mjs | 1 + packages/worker/test/question-answer.test.mjs | 18 ++- .../worker/test/sandbox-handlers.test.mjs | 128 ++++++++++++++++++ packages/worker/test/session-service.test.mjs | 73 ++++++++++ 8 files changed, 267 insertions(+), 4 deletions(-) diff --git a/packages/worker/src/http-handlers.ts b/packages/worker/src/http-handlers.ts index fe48f59..1b2d5a2 100644 --- a/packages/worker/src/http-handlers.ts +++ b/packages/worker/src/http-handlers.ts @@ -49,7 +49,7 @@ import { import { configuredWebOrigins, missingAuthConfigKeys } from "./auth-config.js"; import { createEmailProvider } from "./email.js"; import { can, type AuthAction, CreateInvitationRequestSchema, SetupClaimRequestSchema } from "@codevil/shared"; -import { configuredSandboxProviderName } from "./sandbox-provider/index.js"; +import { configuredSandboxProviderName, resolveSandboxProvider } from "./sandbox-provider/index.js"; import type { Env } from "./worker-env.js"; import type { SocketAuthContext } from "./ws-authorization.js"; import { createSocketAuthToken } from "./ws-token.js"; @@ -528,6 +528,15 @@ export async function handleCreateSession( } catch { return json({ error: "Sandbox provider is not configured" }, 500); } + try { + // Fails on a missing credential such as E2B_API_KEY; the message is a fixed, non-secret string. + resolveSandboxProvider(env, sandboxProvider); + } catch (error) { + return json({ + error: "Sandbox provider is not configured", + detail: error instanceof Error ? error.message : "Sandbox provider is unavailable", + }, 500); + } const sessionId = `ses_${crypto.randomUUID().replace(/-/g, "")}`; const now = new Date().toISOString(); diff --git a/packages/worker/src/orchestrator/question-answer.ts b/packages/worker/src/orchestrator/question-answer.ts index 25290c0..f6f2dbf 100644 --- a/packages/worker/src/orchestrator/question-answer.ts +++ b/packages/worker/src/orchestrator/question-answer.ts @@ -124,6 +124,8 @@ export function applyQuestionAnswer( ...(selection.freeform !== undefined ? { freeform: selection.freeform } : {}), answered_by: input.actor, }); + // Answering is user activity: it restarts the idle-pause clock. + host.recordActivity(); return { ok: true, status: "answered", diff --git a/packages/worker/src/orchestrator/sandbox-handlers.ts b/packages/worker/src/orchestrator/sandbox-handlers.ts index 6a903e9..d620a99 100644 --- a/packages/worker/src/orchestrator/sandbox-handlers.ts +++ b/packages/worker/src/orchestrator/sandbox-handlers.ts @@ -58,6 +58,7 @@ export async function provisionSessionSandbox(host: OrchestratorHost): Promise MAX_FAILURE_REASON_LENGTH ? `${text.slice(0, MAX_FAILURE_REASON_LENGTH)}...` : text; +} + +/** E2B caps continuous runtime (Hobby: 1h); a longer Session may be cut off by the provider. */ +function warnWhenSessionOutlivesProviderLimit(host: OrchestratorHost): void { + const meta = host.meta; + if (!meta) return; + const provider = host.sandboxProvider(); + if (provider.name !== "e2b") return; + const maxTimeMs = parseMaxTimeMs(meta.max_time); + const limitMs = sandboxProviderMaxLeaseMs(host.workerEnv, provider.name); + if (maxTimeMs === null || maxTimeMs <= limitMs) return; + host.appendAndBroadcast({ + type: "status", + message: + `This session's max time (${meta.max_time}) is longer than the sandbox provider's continuous runtime limit ` + + `(${Math.round(limitMs / 60_000)} minutes on the E2B Hobby plan). The session may be cut off at that limit.`, + }); +} + export function initializeSandboxConnection( host: OrchestratorHost, ws: WebSocket, @@ -260,6 +290,8 @@ export async function dispatchSandboxSocketMessage( cancelOpenQuestions(host, activeRunId, "session failed"); } host.appendAndBroadcast({ type: "error", message: parsed.message }); + // The Session is over: a leased/paused provider sandbox must not outlive it. + if (host.meta.sandbox_ref) await host.terminateSandbox("agent error"); } return; case "status": diff --git a/packages/worker/src/session-service.ts b/packages/worker/src/session-service.ts index 414740b..a15ff02 100644 --- a/packages/worker/src/session-service.ts +++ b/packages/worker/src/session-service.ts @@ -8,7 +8,7 @@ import { type NormalizedCreateSession, type SessionDirectoryRow, } from "./session-directory.js"; -import { configuredSandboxProviderName } from "./sandbox-provider/index.js"; +import { configuredSandboxProviderName, resolveSandboxProvider } from "./sandbox-provider/index.js"; import type { Env } from "./worker-env.js"; export interface SessionCreator { @@ -36,6 +36,8 @@ export async function createSession( : normalizeCreateSessionBody(input); // Resolve before touching D1 so a bad deployment setting leaves no Session row behind. const sandboxProvider = configuredSandboxProviderName(env); + // A missing E2B key or similar fails here, clearly, instead of as an opaque provisioning error later. + resolveSandboxProvider(env, sandboxProvider); const sessionId = `ses_${crypto.randomUUID().replace(/-/g, "")}`; const now = new Date().toISOString(); const origin = new URL(requestUrlOrOrigin).origin; diff --git a/packages/worker/test/logging-redaction.test.mjs b/packages/worker/test/logging-redaction.test.mjs index 1121816..5eaa7b6 100644 --- a/packages/worker/test/logging-redaction.test.mjs +++ b/packages/worker/test/logging-redaction.test.mjs @@ -72,6 +72,7 @@ test("session initialization failure logging redacts Worker deployment secrets", try { const env = { CODEVIL_API_KEY: "worker-deployment-secret", + E2B_API_KEY: "e2b-test-key", DB: { prepare: () => ({ bind: () => ({ diff --git a/packages/worker/test/question-answer.test.mjs b/packages/worker/test/question-answer.test.mjs index d5a7a23..2dbdfa7 100644 --- a/packages/worker/test/question-answer.test.mjs +++ b/packages/worker/test/question-answer.test.mjs @@ -43,6 +43,7 @@ function question(overrides = {}) { function fixture(row = question(), options = {}) { const broadcasts = []; const sandboxMessages = []; + const activity = { count: 0 }; // `closingSocket`: the DO closed the agent's socket (pause) but it still lingers in getWebSockets. const sandboxSockets = options.closingSocket ? [{ deserializeAttachment: () => ({ sandbox: {}, closing: true }) }] @@ -75,8 +76,9 @@ function fixture(row = question(), options = {}) { }, appendAndBroadcast(event) { broadcasts.push(event); }, sendToSandbox(message) { sandboxMessages.push(message); }, + recordActivity() { activity.count += 1; }, }; - return { host, row, broadcasts, sandboxMessages }; + return { host, row, broadcasts, sandboxMessages, activity }; } const slackActor = { id: "external:slack:U123", name: "krish" }; @@ -291,3 +293,17 @@ test("Slack integration answers intentionally allow any human for every web answ assert.equal(result.status, "answered"); } }); + +test("answering a question records activity once; rejected or repeated answers do not", async () => { + const state = fixture(); + await answer(state.host, { requestId: "question_1", optionIndexes: [0], actor: slackActor }); + assert.equal(state.activity.count, 1); + + // A retry of the accepted answer is not new activity. + await answer(state.host, { requestId: "question_1", optionIndexes: [1], actor: slackActor }); + assert.equal(state.activity.count, 1); + + const invalid = fixture(); + await answer(invalid.host, { requestId: "question_1", optionIndexes: [9], actor: slackActor }); + assert.equal(invalid.activity.count, 0); +}); diff --git a/packages/worker/test/sandbox-handlers.test.mjs b/packages/worker/test/sandbox-handlers.test.mjs index 839cef5..5130a43 100644 --- a/packages/worker/test/sandbox-handlers.test.mjs +++ b/packages/worker/test/sandbox-handlers.test.mjs @@ -616,3 +616,131 @@ test("handleSandboxCloneComplete does not enqueue cache work for a provider with assert.equal(sql.row, null); assert.equal(fixture.host.meta.state, "ready"); }); + +test("provisioning starts the agent with the sandbox provider name in its environment", async () => { + for (const [name, capabilities] of [ + ["e2b", { pauseResume: true, workspaceCache: false, leaseRenewal: true }], + ["cloudflare", { pauseResume: false, workspaceCache: false, leaseRenewal: false }], + ]) { + let startEnv; + const handle = createFakeSandboxHandle({ + ref: { provider: name, id: "sbx_1" }, + startProcess: async (_command, options) => { startEnv = options.env; }, + }); + const provider = createFakeSandboxProvider({ name, capabilities, create: async () => handle }); + const { host } = createFakeHost( + { state: "initializing" }, + { tracer: createFakeTracer(), sandboxProvider: provider, workerEnv: provisioningEnv() }, + ); + await provisionSessionSandbox(host); + assert.equal(startEnv.CODEVIL_SANDBOX_PROVIDER, name); + } +}); + +test("provisioning failure broadcasts the redacted reason and destroys a sandbox that was already created", async () => { + const destroyed = []; + const handle = createFakeSandboxHandle({ + ref: { provider: "e2b", id: "sbx_1" }, + startProcess: async () => { throw new Error("start failed with sekret-token and more"); }, + destroy: async (reason) => { destroyed.push(reason); }, + }); + const provider = createFakeSandboxProvider({ + name: "e2b", + capabilities: { pauseResume: true, workspaceCache: false, leaseRenewal: true }, + create: async () => handle, + }); + const { host, broadcasts } = createFakeHost( + { state: "initializing" }, + { tracer: createFakeTracer(), sandboxProvider: provider, workerEnv: provisioningEnv(), sandboxHandle: handle }, + ); + host.redactionSecrets.push("sekret-token"); + + await provisionSessionSandbox(host); + + assert.equal(host.meta.state, "failed"); + const error = broadcasts.find((event) => event.type === "error"); + assert.match(error.message, /^Sandbox provisioning failed: .*start failed with \[REDACTED\] and more/); + assert.doesNotMatch(JSON.stringify(broadcasts), /sekret-token/); + assert.deepEqual(destroyed, ["provisioning failed"]); + assert.equal(host.meta.expected_close, true); +}); + +test("provisioning failure before a sandbox exists does not try to destroy anything", async () => { + const { host, terminations, broadcasts } = createFakeHost( + { state: "initializing" }, + { tracer: createFakeTracer(), sandboxProvider: createFakeSandboxProvider({ create: async () => { throw new Error("no capacity"); } }), workerEnv: provisioningEnv() }, + ); + await provisionSessionSandbox(host); + assert.deepEqual(terminations, []); + assert.match(broadcasts.find((event) => event.type === "error").message, /^Sandbox provisioning failed: no capacity/); +}); + +test("a missing provider credential surfaces its reason in the failure message", async () => { + const { host, broadcasts } = createFakeHost( + { state: "initializing" }, + { tracer: createFakeTracer(), sandboxProvider: undefined, workerEnv: provisioningEnv() }, + ); + host.sandboxProvider = () => { throw new Error("E2B_API_KEY is not configured"); }; + await provisionSessionSandbox(host); + assert.match(broadcasts.find((event) => event.type === "error").message, /E2B_API_KEY is not configured/); +}); + +test("an agent error outside a run fails the session and destroys its sandbox", async () => { + const { host, terminations, broadcasts } = createFakeHost({ + state: "cloning_repo", + sandbox_ref: { provider: "cloudflare", id: "ses_test" }, + }); + const { ws } = createWsRecorder(); + await dispatchSandboxSocketMessage(host, ws, JSON.stringify({ type: "error", message: "clone failed" })); + assert.equal(host.meta.state, "failed"); + assert.deepEqual(terminations.map((t) => t.reason), ["agent error"]); + assert.ok(broadcasts.some((e) => e.type === "error" && e.message === "clone failed")); +}); + +test("an agent error with no sandbox ref, or during a run, does not destroy the sandbox", async () => { + const noRef = createFakeHost({ state: "cloning_repo" }); + await dispatchSandboxSocketMessage(noRef.host, createWsRecorder().ws, JSON.stringify({ type: "error", message: "x" })); + assert.deepEqual(noRef.terminations, []); + + const inRun = createFakeHost({ + state: "executing", + sandbox_ref: { provider: "cloudflare", id: "ses_test" }, + active_run: createAgentRun({ actor, text: "t", now: "2026-06-03T00:00:00.000Z" }), + }); + await dispatchSandboxSocketMessage(inRun.host, createWsRecorder().ws, JSON.stringify({ type: "error", message: "x" })); + assert.deepEqual(inRun.terminations, []); +}); + +test("an E2B session longer than the provider limit is warned at provisioning", async () => { + const e2bProvider = (create) => createFakeSandboxProvider({ + name: "e2b", + capabilities: { pauseResume: true, workspaceCache: false, leaseRenewal: true }, + create, + }); + const make = (maxTime, workerEnv = provisioningEnv(), provider = e2bProvider(async () => createFakeSandboxHandle({ ref: { provider: "e2b", id: "sbx_1" } }))) => + createFakeHost( + { state: "initializing", max_time: maxTime, created_at: new Date().toISOString() }, + { tracer: createFakeTracer(), sandboxProvider: provider, workerEnv }, + ); + const warning = (fake) => fake.broadcasts.find((e) => e.type === "status" && /continuous runtime limit/.test(e.message)); + + const long = make("2h"); + await provisionSessionSandbox(long.host); + assert.match(warning(long).message, /max time \(2h\).*60 minutes.*cut off/); + + const exact = make("1h"); + await provisionSessionSandbox(exact.host); + assert.equal(warning(exact), undefined); + + const short = make("30m"); + await provisionSessionSandbox(short.host); + assert.equal(warning(short), undefined); + + const raised = make("2h", { ...provisioningEnv(), E2B_MAX_SANDBOX_SECONDS: "86400" }); + await provisionSessionSandbox(raised.host); + assert.equal(warning(raised), undefined); + + const cloudflare = make("8h", provisioningEnv(), createFakeSandboxProvider({ create: async () => createFakeSandboxHandle() })); + await provisionSessionSandbox(cloudflare.host); + assert.equal(warning(cloudflare), undefined); +}); diff --git a/packages/worker/test/session-service.test.mjs b/packages/worker/test/session-service.test.mjs index 6cb017e..e3e1743 100644 --- a/packages/worker/test/session-service.test.mjs +++ b/packages/worker/test/session-service.test.mjs @@ -11,6 +11,7 @@ test("createSession inserts a session, initializes the orchestrator, and returns let initCall = null; const env = { DB: db, + E2B_API_KEY: "e2b-test-key", ORCHESTRATOR: { idFromName(name) { capturedId = name; @@ -91,6 +92,7 @@ test("createSession marks the session failed when orchestrator init throws", asy const db = createFakeDb(); const env = { DB: db, + E2B_API_KEY: "e2b-test-key", ORCHESTRATOR: { idFromName(name) { return name; @@ -173,6 +175,76 @@ test("handleCreateSession fails on a bad SANDBOX_PROVIDER without touching D1 wr assert.deepEqual(writes, []); }); +test("createSession rejects a missing E2B_API_KEY before writing a Session row", async () => { + const db = createFakeDb(); + const env = { + DB: db, + ORCHESTRATOR: { + idFromName: () => assert.fail("must not reach the orchestrator"), + get: () => assert.fail("must not reach the orchestrator"), + }, + }; + + await assert.rejects( + () => createSession( + env, + "https://codevil.example.workers.dev", + { repo: "github.com/acme/app" }, + { id: "usr_123", name: "Alice" }, + ), + /E2B_API_KEY is not configured/, + ); + + assert.equal(db.rows.size, 0); +}); + +test("createSession needs no E2B key on the Cloudflare provider", async () => { + const db = createFakeDb(); + const env = { + DB: db, + SANDBOX_PROVIDER: "cloudflare", + ORCHESTRATOR: { idFromName: (name) => name, get: () => ({ init: async () => {} }) }, + }; + const result = await createSession( + env, + "https://codevil.example.workers.dev", + { repo: "github.com/acme/app" }, + { id: "usr_123", name: "Alice" }, + ); + assert.equal(db.rows.size, 1); + assert.ok(result.session_id); +}); + +test("handleCreateSession names the missing E2B_API_KEY and writes nothing", async () => { + const writes = []; + const env = { + DB: { + prepare: (sql) => ({ bind: () => { writes.push(sql); return { run: async () => ({}) }; } }), + batch: async () => { writes.push("batch"); }, + }, + ORCHESTRATOR: { + idFromName: () => assert.fail("must not reach the orchestrator"), + get: () => assert.fail("must not reach the orchestrator"), + }, + }; + + const response = await handleCreateSession( + new Request("https://codevil.example/sessions", { + method: "POST", + body: JSON.stringify({ repo: "github.com/acme/app" }), + }), + env, + { userId: "usr_123", email: "owner@example.com", name: "Owner", role: "owner" }, + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { + error: "Sandbox provider is not configured", + detail: "E2B_API_KEY is not configured", + }); + assert.deepEqual(writes, []); +}); + test("handleCreateSession forwards a provided max_idle_time to the orchestrator", async () => { const { response, initCall } = await createViaHandler({ repo: "github.com/acme/app", max_idle_time: "45m" }); @@ -191,6 +263,7 @@ test("handleCreateSession forwards the default max_idle_time when none is provid async function createViaHandler(body) { let initCall = null; const env = { + E2B_API_KEY: "e2b-test-key", DB: { prepare: () => ({ bind: () => ({ run: async () => ({}) }) }), batch: async () => [], From 36fc79941f338586eadbc16e424f0e736842f4c2 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 12:43:21 +0530 Subject: [PATCH 30/32] fix(sandbox): cache provider handles, kill paused sandboxes by reference, and recover from mid-pause eviction - per-Orchestrator handle cache invalidated on pause, resume, terminate, loss, ref change and SandboxNotFoundError - terminate/destroy use destroyByRef for paused or unconnectable sandboxes; failed resume kills by reference - assume paused after a DO eviction between provider pause and the paused marker - remove the never-shipped pending_preview_start legacy field and migration --- packages/shared/src/session-meta-schema.ts | 2 - packages/worker/src/orchestrator.ts | 14 +- packages/worker/src/orchestrator/host.ts | 4 +- .../worker/src/orchestrator/sandbox-access.ts | 123 +++++++++-- .../orchestrator/sandbox-session-lifecycle.ts | 67 +++++- .../worker/src/orchestrator/session-meta.ts | 11 +- packages/worker/test/helpers/fake-host.mjs | 20 +- packages/worker/test/sandbox-access.test.mjs | 171 ++++++++++++++++ .../test/sandbox-session-lifecycle.test.mjs | 193 ++++++++++++++++-- 9 files changed, 552 insertions(+), 53 deletions(-) diff --git a/packages/shared/src/session-meta-schema.ts b/packages/shared/src/session-meta-schema.ts index ad83538..4155c26 100644 --- a/packages/shared/src/session-meta-schema.ts +++ b/packages/shared/src/session-meta-schema.ts @@ -48,8 +48,6 @@ export const SessionMetaSchema = z.object({ type: z.enum(["start", "stop"]), app_key: z.string().optional(), }).optional(), - /** @deprecated Legacy start-only flag; migrated to `pending_preview_action` when meta is loaded. */ - pending_preview_start: z.union([z.literal(true), z.object({ app_key: z.string().optional() })]).optional(), last_decision: LastDecisionSchema.optional(), }); diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index 86d1d4d..56a29c0 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -24,6 +24,7 @@ import { buildSandboxDisconnectLogPayload } from "./sandbox.js"; import { collectAgentDiagnostics, connectSandboxHandle, + createSandboxHandleCache, loadStoredSandboxSecret, readRedactedAgentLogs, SANDBOX_SECRET_KEY, @@ -123,6 +124,7 @@ import { provisionSessionSandbox, } from "./orchestrator/sandbox-handlers.js"; import { + assumeSandboxPausedAfterEviction, clearStalePausedMarker, expireSessionAtMaxTime, pauseIdleSandbox, @@ -345,6 +347,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost } clearStalePausedMarker(this); + assumeSandboxPausedAfterEviction(this, now); await renewSandboxLeaseIfDue(this, now); await pauseIdleSandbox(this, now); @@ -764,9 +767,16 @@ export class Orchestrator extends DurableObject implements OrchestratorHost readSecret: () => this.ctx.storage.get(SANDBOX_SECRET_KEY), secrets: this.redactionSecrets, storeSecret: (secret) => this.ctx.storage.put(SANDBOX_SECRET_KEY, secret), + cache: this.sandboxHandleCache, }); } + private readonly sandboxHandleCache = createSandboxHandleCache(); + + invalidateSandboxHandle(): void { + this.sandboxHandleCache.invalidate(); + } + private readonly sandboxResolver = (): Promise => this.sandboxHandle(); /** RPC for `GET /sessions/:id/logs`. */ @@ -781,8 +791,8 @@ export class Orchestrator extends DurableObject implements OrchestratorHost return sandboxDiagnosticsResponse(this.sandboxResolver, this.redactionSecrets, { paused: Boolean(this.meta?.sandbox_paused_at) }); } - async terminateSandbox(reason: string): Promise { - await terminateSandboxFn(this, reason); + async terminateSandbox(reason: string, options?: { assumePaused?: boolean }): Promise { + await terminateSandboxFn(this, reason, options); } recordActivity(source?: "event" | "preview"): void { diff --git a/packages/worker/src/orchestrator/host.ts b/packages/worker/src/orchestrator/host.ts index 03b1500..42fe6c8 100644 --- a/packages/worker/src/orchestrator/host.ts +++ b/packages/worker/src/orchestrator/host.ts @@ -60,7 +60,9 @@ export interface OrchestratorHost { /** Fresh sandbox WebSocket capability, for the token file written on resume. */ issueSandboxWebSocketToken(): Promise; /** Destroys the provider sandbox (running or paused) and closes its sockets. Failures are logged, never thrown. */ - terminateSandbox(reason: string): Promise; + terminateSandbox(reason: string, options?: { assumePaused?: boolean }): Promise; /** Null when the Session has no sandbox yet (`meta.sandbox_ref` unset). */ sandboxHandle(): Promise; + /** Drops the cached provider handle (pause, resume, terminate, loss) so the next lookup reconnects. */ + invalidateSandboxHandle(): void; } diff --git a/packages/worker/src/orchestrator/sandbox-access.ts b/packages/worker/src/orchestrator/sandbox-access.ts index b4b32c0..1c970e6 100644 --- a/packages/worker/src/orchestrator/sandbox-access.ts +++ b/packages/worker/src/orchestrator/sandbox-access.ts @@ -3,10 +3,11 @@ import { collectSandboxDiagnostics, type SandboxDiagnostics, } from "../sandbox.js"; -import type { - SandboxHandle, - SandboxProvider, - SandboxRef, +import { + SandboxNotFoundError, + type SandboxHandle, + type SandboxProvider, + type SandboxRef, } from "../sandbox-provider/types.js"; export const AGENT_PROCESS_ID = "codevil-agent"; @@ -46,6 +47,76 @@ export function sandboxRefForMeta( : null; } +/** + * Per-Orchestrator cache of the connected handle, keyed by provider and + * sandbox id. Every authenticated preview request needs a handle, and for E2B + * connecting is an API call. The cache holds the in-flight promise so + * concurrent requests share one connect; failures and null results are never + * cached, and a `SandboxNotFoundError` from any call on a cached handle drops it. + */ +export interface SandboxHandleCache { + get(key: string, load: () => Promise): Promise; + /** Drop the cached handle (pause, resume, terminate, loss). In-flight loads finish but are not kept. */ + invalidate(): void; +} + +export function createSandboxHandleCache(): SandboxHandleCache { + let entry: { key: string; promise: Promise } | null = null; + + const drop = (promise: Promise): void => { + if (entry?.promise === promise) entry = null; + }; + + return { + get(key, load) { + if (entry?.key === key) return entry.promise; + const promise: Promise = load().then( + (handle) => { + if (!handle) { + drop(promise); + return null; + } + return invalidateOnNotFound(handle, () => drop(promise)); + }, + (error: unknown) => { + drop(promise); + throw error; + }, + ); + entry = { key, promise }; + return promise; + }, + invalidate() { + entry = null; + }, + }; +} + +/** Wraps every handle call so a `SandboxNotFoundError` evicts the (now useless) handle. */ +function invalidateOnNotFound(handle: SandboxHandle, onNotFound: () => void): SandboxHandle { + const guard = (call: (...args: A) => Promise) => + async (...args: A): Promise => { + try { + return await call(...args); + } catch (error) { + if (error instanceof SandboxNotFoundError) onNotFound(); + throw error; + } + }; + return { + ...handle, + exec: guard(handle.exec.bind(handle)), + writeFile: guard(handle.writeFile.bind(handle)), + startProcess: guard(handle.startProcess.bind(handle)), + readProcessLogs: guard(handle.readProcessLogs.bind(handle)), + fetchPort: guard(handle.fetchPort.bind(handle)), + renewLease: guard(handle.renewLease.bind(handle)), + destroy: guard(handle.destroy.bind(handle)), + ...(handle.readLifecycle ? { readLifecycle: guard(handle.readLifecycle.bind(handle)) } : {}), + ...(handle.pause ? { pause: guard(handle.pause.bind(handle)) } : {}), + }; +} + export async function connectSandboxHandle(options: { meta: { session_id: string; sandbox_ref?: SandboxRef; sandbox_paused_at?: string } | null; provider: SandboxProvider; @@ -54,29 +125,57 @@ export async function connectSandboxHandle(options: { secrets?: string[]; /** Persist a secret the provider supplied because none was stored. */ storeSecret?: (secret: string) => Promise; + /** Reuse a connected handle across calls; see {@link createSandboxHandleCache}. */ + cache?: SandboxHandleCache; }): Promise { // Connecting would silently resume a paused VM (no token write, lease or marker // update). Only the resume path may wake it, and it calls `provider.connect` itself. - if (options.meta?.sandbox_paused_at) return null; + if (options.meta?.sandbox_paused_at) { + options.cache?.invalidate(); + return null; + } const ref = sandboxRefForMeta(options.meta, options.provider); if (!ref) return null; - const secret = await options.readSecret(); - const handle = await options.provider.connect(ref, secret ? { secret } : undefined); - if (options.secrets) registerSandboxSecret(options.secrets, handle.secret); - if (!secret && handle.secret) await options.storeSecret?.(handle.secret); - return handle; + const connect = async (): Promise => { + const secret = await options.readSecret(); + const handle = await options.provider.connect(ref, secret ? { secret } : undefined); + if (options.secrets) registerSandboxSecret(options.secrets, handle.secret); + if (!secret && handle.secret) await options.storeSecret?.(handle.secret); + return handle; + }; + return options.cache ? options.cache.get(`${ref.provider}:${ref.id}`, connect) : connect(); } -/** Destroy the Session's sandbox; failures are reported, never thrown. */ +/** + * Destroy the Session's sandbox; failures are reported, never thrown. + * + * A paused sandbox is killed by reference when the provider can (connecting + * would resume it first), and a failed connect falls back to the same. A + * sandbox that is already gone counts as destroyed. + */ export async function destroySandbox( resolveHandle: SandboxHandleResolver, reason: string, onError: (error: unknown) => void, + fallback: { paused?: boolean; destroyByRef?: () => Promise } = {}, ): Promise { try { - const handle = await resolveHandle(); + if (fallback.paused && fallback.destroyByRef) { + await fallback.destroyByRef(); + return; + } + let handle: SandboxHandle | null; + try { + handle = await resolveHandle(); + } catch (error) { + if (error instanceof SandboxNotFoundError) return; + if (!fallback.destroyByRef) throw error; + await fallback.destroyByRef(); + return; + } await handle?.destroy(reason); } catch (error) { + if (error instanceof SandboxNotFoundError) return; onError(error); } } diff --git a/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts b/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts index cc3aa68..923dbd1 100644 --- a/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts +++ b/packages/worker/src/orchestrator/sandbox-session-lifecycle.ts @@ -179,7 +179,12 @@ async function doPause(host: OrchestratorHost, now: number): Promise { try { const handle = await host.sandboxHandle(); if (!handle?.pause) throw new Error("Sandbox provider cannot pause"); - await handle.pause(); + try { + await handle.pause(); + } finally { + // Paused or in an unknown state either way: never reuse this connection. + host.invalidateSandboxHandle(); + } } catch (error) { if (error instanceof SandboxNotFoundError) { await failLostSandbox(host); @@ -302,6 +307,8 @@ async function completeResume(host: OrchestratorHost, now: number): Promise { + host.invalidateSandboxHandle(); if (!(await failSession(host, "Sandbox expired.", "sandbox expired"))) return; await armAlarmSafely(host, Date.now() - 1); } async function failSandboxResume(host: OrchestratorHost): Promise { if (!(await failSession(host, "Sandbox failed to resume.", "sandbox failed to resume"))) return; - await host.terminateSandbox("resume failed"); + // The sandbox is paused or half-resumed: kill it by reference rather than waking it first. + await host.terminateSandbox("resume failed", { assumePaused: true }); await armAlarmSafely(host, Date.now() - 1); } /** * Destroys the Session's sandbox whether it is running or paused (a paused - * E2B sandbox never expires, so this is its only cleanup). + * E2B sandbox never expires, so this is its only cleanup). A paused sandbox, + * or one that cannot be connected to, is killed by reference when the provider + * supports it so teardown never depends on waking the VM first. */ -export async function terminateSandbox(host: OrchestratorHost, reason: string): Promise { +export async function terminateSandbox( + host: OrchestratorHost, + reason: string, + options: { assumePaused?: boolean } = {}, +): Promise { if (!host.meta) return; // A pause still in flight would otherwise land after the destroy and leave a // paused sandbox that never expires. await pausing.get(host)?.catch(() => false); + const paused = options.assumePaused === true || Boolean(host.meta.sandbox_paused_at); host.meta.expected_close = true; host.meta.sandbox_paused_at = undefined; host.meta.pending_preview_action = undefined; host.saveMeta(); await destroySandbox(() => host.sandboxHandle(), reason, (error) => { logFailure(host, "sandbox.stop.failed", error); - }); + }, { paused, destroyByRef: destroyByRefFor(host) }); + host.invalidateSandboxHandle(); host.closeSandboxSockets(reason); } +function destroyByRefFor(host: OrchestratorHost): (() => Promise) | undefined { + try { + const provider = host.sandboxProvider(); + const ref = sandboxRefForMeta(host.meta, provider); + if (!provider.destroyByRef || !ref) return undefined; + return () => provider.destroyByRef!(ref); + } catch { + return undefined; + } +} + +/** + * The Durable Object may be evicted between asking the provider to pause and + * recording `sandbox_paused_at`. A ready Session that set `expected_close`, + * has no paused marker, no pause in flight, no live agent socket and no + * reconnect grace running is in exactly that state: record it as paused so the + * next trigger resumes it (connect wakes a paused sandbox and is a no-op on a + * running one). + */ +export function assumeSandboxPausedAfterEviction(host: OrchestratorHost, now: number): boolean { + const meta = host.meta; + if ( + !meta + || !providerSupportsPause(host) + || meta.state !== "ready" + || !meta.expected_close + || meta.sandbox_paused_at + || pausing.has(host) + || resuming.has(host) + || liveSandboxSockets(host.ctx).length > 0 + || meta.sandbox_disconnected_at + ) { + return false; + } + meta.sandbox_paused_at = new Date(now).toISOString(); + host.saveMeta(); + host.updateDirectory({ sandbox_state: "paused" }); + return true; +} + /** Alarm branch: end the Session at `max_time`, destroying its sandbox even when paused. */ export async function expireSessionAtMaxTime(host: OrchestratorHost, now: number): Promise { const meta = host.meta; diff --git a/packages/worker/src/orchestrator/session-meta.ts b/packages/worker/src/orchestrator/session-meta.ts index d62083e..0e24c77 100644 --- a/packages/worker/src/orchestrator/session-meta.ts +++ b/packages/worker/src/orchestrator/session-meta.ts @@ -43,18 +43,9 @@ export function loadSessionMeta( break; } - store.meta = migrateLegacyPendingPreview(parsed.data); + store.meta = parsed.data; break; } store.eventLog.hydrateFromSql(); } - -/** A legacy start-only `pending_preview_start` becomes a "start" `pending_preview_action` (an explicit action wins). */ -export function migrateLegacyPendingPreview(meta: SessionMeta): SessionMeta { - const { pending_preview_start: legacy, ...rest } = meta; - if (legacy === undefined) return meta; - if (rest.pending_preview_action) return rest; - const appKey = typeof legacy === "object" ? legacy.app_key : undefined; - return { ...rest, pending_preview_action: { type: "start", ...(appKey !== undefined ? { app_key: appKey } : {}) } }; -} diff --git a/packages/worker/test/helpers/fake-host.mjs b/packages/worker/test/helpers/fake-host.mjs index b11240c..74d66b3 100644 --- a/packages/worker/test/helpers/fake-host.mjs +++ b/packages/worker/test/helpers/fake-host.mjs @@ -163,6 +163,7 @@ export function createFakeE2BProvider(handle, options = {}) { capabilities: { pauseResume: true, workspaceCache: false, leaseRenewal: true }, connectCalls: 0, connectOptions: [], + destroyedByRef: [], create: async () => handle, connect: async (_ref, connectOptions) => { provider.connectCalls += 1; @@ -171,6 +172,13 @@ export function createFakeE2BProvider(handle, options = {}) { return handle; }, }; + // `destroyByRef: true` adds the optional static kill; `destroyByRefError` makes it reject. + if (options.destroyByRef) { + provider.destroyByRef = async (ref) => { + provider.destroyedByRef.push(ref); + if (options.destroyByRefError) throw options.destroyByRefError; + }; + } return provider; } @@ -192,6 +200,8 @@ export function createFakeHost(metaOverrides = {}, options = {}) { const closedSandboxSockets = []; const activity = { count: 0 }; const resumeRequests = { count: 0 }; + const handleInvalidations = { count: 0 }; + const terminations = []; const host = { meta, @@ -302,8 +312,12 @@ export function createFakeHost(metaOverrides = {}, options = {}) { async issueSandboxWebSocketToken() { return "fresh_token"; }, - terminateSandbox(reason) { - return terminateSandboxForHost(host, reason); + terminateSandbox(reason, terminateOptions) { + terminations.push({ reason, options: terminateOptions }); + return terminateSandboxForHost(host, reason, terminateOptions); + }, + invalidateSandboxHandle() { + handleInvalidations.count += 1; }, async sandboxHandle() { // Mirrors connectSandboxHandle: a paused sandbox is never woken by a plain handle lookup. @@ -330,6 +344,8 @@ export function createFakeHost(metaOverrides = {}, options = {}) { closedSandboxSockets, activity, resumeRequests, + handleInvalidations, + terminations, get armCalls() { return armCalls; }, diff --git a/packages/worker/test/sandbox-access.test.mjs b/packages/worker/test/sandbox-access.test.mjs index 006a0b3..90303e9 100644 --- a/packages/worker/test/sandbox-access.test.mjs +++ b/packages/worker/test/sandbox-access.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { connectSandboxHandle, + createSandboxHandleCache, destroySandbox, loadStoredSandboxSecret, readRedactedAgentLogs, @@ -12,6 +13,7 @@ import { sandboxLogsResponse, sandboxRefForMeta, } from "../dist/orchestrator/sandbox-access.js"; +import { SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; import { createFakeSandboxHandle } from "./helpers/fake-host.mjs"; const secret = "diagnostics-response-secret"; @@ -202,3 +204,172 @@ test("connectSandboxHandle registers and persists a provider-supplied secret whe assert.deepEqual(secrets, ["fallback_tat"]); assert.deepEqual(stored, ["fallback_tat"]); }); + +const e2bMeta = { session_id: "ses_1", sandbox_ref: { provider: "e2b", id: "sbx_1" } }; + +function lookup(provider, cache, meta = e2bMeta, extra = {}) { + return connectSandboxHandle({ meta, provider, readSecret: async () => "tok", cache, ...extra }); +} + +test("a cached lookup connects once and returns the same handle", async () => { + const { provider, connects } = recordingProvider("e2b"); + const cache = createSandboxHandleCache(); + const first = await lookup(provider, cache); + const second = await lookup(provider, cache); + assert.equal(connects.length, 1); + assert.equal(first.ref.id, second.ref.id); +}); + +test("concurrent lookups share a single connect", async () => { + const { provider, connects } = recordingProvider("e2b"); + const cache = createSandboxHandleCache(); + await Promise.all([lookup(provider, cache), lookup(provider, cache), lookup(provider, cache)]); + assert.equal(connects.length, 1); +}); + +test("without a cache every lookup connects", async () => { + const { provider, connects } = recordingProvider("e2b"); + await lookup(provider, undefined); + await lookup(provider, undefined); + assert.equal(connects.length, 2); +}); + +test("invalidation and a changed sandbox ref both reconnect", async () => { + const { provider, connects } = recordingProvider("e2b"); + const cache = createSandboxHandleCache(); + await lookup(provider, cache); + cache.invalidate(); + await lookup(provider, cache); + assert.equal(connects.length, 2); + await lookup(provider, cache, { ...e2bMeta, sandbox_ref: { provider: "e2b", id: "sbx_2" } }); + assert.equal(connects.length, 3); + await lookup(provider, cache, { ...e2bMeta, sandbox_ref: { provider: "e2b", id: "sbx_2" } }); + assert.equal(connects.length, 3); +}); + +test("a failed connect and a missing sandbox are never cached", async () => { + let attempts = 0; + const provider = { + name: "e2b", + capabilities: { pauseResume: true, workspaceCache: false, leaseRenewal: true }, + create: async () => assert.fail("create must not be called"), + connect: async (ref) => { + attempts += 1; + if (attempts === 1) throw new Error("connect failed"); + return createFakeSandboxHandle({ ref }); + }, + }; + const cache = createSandboxHandleCache(); + await assert.rejects(lookup(provider, cache), /connect failed/); + assert.ok(await lookup(provider, cache)); + assert.equal(attempts, 2); + assert.equal(await lookup(provider, cache, { session_id: "ses_1" }), null); +}); + +test("a SandboxNotFoundError from any handle call evicts the cached handle", async () => { + const members = [ + ["exec", (h) => h.exec("x")], + ["writeFile", (h) => h.writeFile("/x", "y")], + ["startProcess", (h) => h.startProcess("x", { processId: "p", cwd: "/", env: {} })], + ["readProcessLogs", (h) => h.readProcessLogs("p")], + ["fetchPort", (h) => h.fetchPort(1, new Request("http://x/"))], + ["renewLease", (h) => h.renewLease(1)], + ["pause", (h) => h.pause()], + ["destroy", (h) => h.destroy("r")], + ["readLifecycle", (h) => h.readLifecycle()], + ]; + for (const [name, call] of members) { + let connects = 0; + const provider = { + name: "e2b", + capabilities: { pauseResume: true, workspaceCache: false, leaseRenewal: true }, + create: async () => assert.fail("create must not be called"), + connect: async (ref) => { + connects += 1; + return createFakeSandboxHandle({ + ref, + [name]: async () => { throw new SandboxNotFoundError(); }, + ...(name === "pause" ? {} : { pause: async () => {} }), + readLifecycle: name === "readLifecycle" ? async () => { throw new SandboxNotFoundError(); } : async () => null, + }); + }, + }; + const cache = createSandboxHandleCache(); + const handle = await lookup(provider, cache); + await assert.rejects(call(handle), SandboxNotFoundError, name); + await lookup(provider, cache); + assert.equal(connects, 2, `${name} should evict`); + } +}); + +test("other handle errors keep the cached handle", async () => { + let connects = 0; + const provider = { + name: "e2b", + capabilities: { pauseResume: true, workspaceCache: false, leaseRenewal: true }, + create: async () => assert.fail("create must not be called"), + connect: async (ref) => { + connects += 1; + return createFakeSandboxHandle({ ref, exec: async () => { throw new Error("boom"); } }); + }, + }; + const cache = createSandboxHandleCache(); + await assert.rejects((await lookup(provider, cache)).exec("x"), /boom/); + await lookup(provider, cache); + assert.equal(connects, 1); +}); + +test("a cached handle still registers and persists its secret on first connect only", async () => { + const secrets = []; + const stored = []; + const provider = { + name: "e2b", + capabilities: { pauseResume: true, workspaceCache: false, leaseRenewal: true }, + create: async () => assert.fail("create must not be called"), + connect: async (ref) => createFakeSandboxHandle({ ref, secret: "traffic-secret" }), + }; + const cache = createSandboxHandleCache(); + const options = { secrets, storeSecret: async (value) => { stored.push(value); } }; + for (let i = 0; i < 2; i++) { + await connectSandboxHandle({ meta: e2bMeta, provider, readSecret: async () => undefined, cache, ...options }); + } + assert.deepEqual(secrets, ["traffic-secret"]); + assert.deepEqual(stored, ["traffic-secret"]); +}); + +test("destroySandbox kills a paused sandbox by reference without resolving a handle", async () => { + const killed = []; + await destroySandbox( + async () => assert.fail("must not connect to a paused sandbox"), + "stopped", + () => assert.fail("no error expected"), + { paused: true, destroyByRef: async () => { killed.push("ref"); } }, + ); + assert.deepEqual(killed, ["ref"]); +}); + +test("destroySandbox falls back to a kill by reference when connecting fails, and treats not-found as gone", async () => { + const killed = []; + const destroyByRef = async () => { killed.push("ref"); }; + await destroySandbox(async () => { throw new Error("connect failed"); }, "x", () => assert.fail("handled"), { destroyByRef }); + assert.deepEqual(killed, ["ref"]); + + await destroySandbox(async () => { throw new SandboxNotFoundError(); }, "x", () => assert.fail("already gone"), { destroyByRef }); + assert.deepEqual(killed, ["ref"]); + + const errors = []; + await destroySandbox( + async () => { throw new Error("connect failed"); }, + "x", + (error) => errors.push(error.message), + { destroyByRef: async () => { throw new Error("kill failed"); } }, + ); + assert.deepEqual(errors, ["kill failed"]); +}); + +test("a paused sandbox without a by-reference kill is destroyed through its handle", async () => { + const reasons = []; + const handle = createFakeSandboxHandle({ destroy: async (reason) => { reasons.push(reason); } }); + await destroySandbox(async () => handle, "x", () => assert.fail("no error"), { paused: true }); + assert.deepEqual(reasons, ["x"]); +}); diff --git a/packages/worker/test/sandbox-session-lifecycle.test.mjs b/packages/worker/test/sandbox-session-lifecycle.test.mjs index 5a2f99a..0349a2d 100644 --- a/packages/worker/test/sandbox-session-lifecycle.test.mjs +++ b/packages/worker/test/sandbox-session-lifecycle.test.mjs @@ -3,9 +3,10 @@ import test from "node:test"; import { handleAgentRequest } from "../dist/orchestrator/cli-handlers.js"; import { hashPreviewToken, proxyPreviewRequest } from "../dist/orchestrator/preview.js"; import { drainQueuedAgentWorkIfReady } from "../dist/orchestrator/sandbox-handlers.js"; -import { connectSandboxHandle, sandboxDiagnosticsResponse, sandboxLogsResponse } from "../dist/orchestrator/sandbox-access.js"; +import { connectSandboxHandle, createSandboxHandleCache, sandboxDiagnosticsResponse, sandboxLogsResponse } from "../dist/orchestrator/sandbox-access.js"; import { handlePreviewStart, handlePreviewStop } from "../dist/orchestrator/cli-handlers.js"; import { + assumeSandboxPausedAfterEviction, clearStalePausedMarker, flushPendingPreviewAction, idlePauseInput, @@ -29,7 +30,6 @@ import { } from "../dist/sandbox-connection.js"; import { SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; import { sandboxProviderMaxLeaseMs } from "../dist/sandbox-provider/index.js"; -import { loadSessionMeta } from "../dist/orchestrator/session-meta.js"; import { actor, createDefaultMeta, @@ -941,23 +941,6 @@ test("a stale paused marker cleared by the alarm replays a deferred preview stop assert.deepEqual(sandboxMessages, [{ type: "preview_stop" }]); }); -test("legacy pending_preview_start meta loads as a start action", () => { - const load = (legacy, extra = {}) => { - const meta = createDefaultMeta({ pending_preview_start: legacy, ...extra }); - const store = { meta: null, eventLog: { hydrateFromSql() {} } }; - loadSessionMeta({ exec: () => [{ value: JSON.stringify(meta) }] }, store); - return store.meta; - }; - - const flag = load(true); - assert.deepEqual(flag.pending_preview_action, { type: "start" }); - assert.equal(flag.pending_preview_start, undefined); - - assert.deepEqual(load({ app_key: "web" }).pending_preview_action, { type: "start", app_key: "web" }); - // An explicit action is never overwritten by the legacy flag. - assert.deepEqual(load(true, { pending_preview_action: { type: "stop" } }).pending_preview_action, { type: "stop" }); -}); - test("a deferred preview start is dropped when the session fails", async () => { const { host } = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: false, connectError: new SandboxNotFoundError() }); await handlePreviewStart(host, "web"); @@ -1170,3 +1153,175 @@ test("authenticated preview requests in a burst do not re-arm the alarm each tim assert.equal(fake.activity.count, 5); assert.equal(fake.armCalls, 1); }); + +// --- provider handle cache and teardown --- + +test("pause, completed resume, teardown and a vanished sandbox each drop the cached handle", async () => { + const paused = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + assert.equal(await pauseIdleSandbox(paused.host, T0 + 600_000), true); + assert.equal(paused.handleInvalidations.count >= 1, true); + + const resumed = createFakeHost(pausedMeta, { e2b: true }); + await resumeSandbox(resumed.host); + assert.equal(resumed.handleInvalidations.count, 1); + + const stopped = createFakeHost(idleMeta, { e2b: true }); + await terminateSandbox(stopped.host, "stopped by user"); + assert.equal(stopped.handleInvalidations.count, 1); + + const lost = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + lost.handle.pause = async () => { throw new SandboxNotFoundError(); }; + await pauseIdleSandbox(lost.host, T0 + 600_000); + assert.ok(lost.handleInvalidations.count >= 1); +}); + +function cachedPreviewLookup(provider, meta, cache) { + return () => connectSandboxHandle({ meta, provider, readSecret: async () => "tok", cache }); +} + +test("repeated preview requests connect to the provider once and reconnect after each invalidation", async () => { + const handle = createFakeHost(idleMeta, { e2b: true }).handle; + handle.fetchPort = async () => new Response("preview body"); + let connects = 0; + const provider = createFakeSandboxProvider({ + name: "e2b", + capabilities: { pauseResume: true, workspaceCache: false, leaseRenewal: true }, + connect: async () => { connects += 1; return handle; }, + }); + const meta = { + ...createDefaultMeta(idleMeta), + preview_active: true, + preview_port: 5173, + preview_token_hash: await hashPreviewToken(PREVIEW_TOKEN), + }; + const cache = createSandboxHandleCache(); + const lookup = cachedPreviewLookup(provider, meta, cache); + const request = () => proxyPreviewRequest(previewRequest(PREVIEW_TOKEN), meta, PREVIEW_TOKEN, lookup); + + for (let i = 0; i < 5; i++) assert.equal((await request()).status, 200); + assert.equal(connects, 1); + + cache.invalidate(); // pause, resume completion, teardown + assert.equal((await request()).status, 200); + assert.equal(connects, 2); +}); + +test("a paused session never uses a cached handle, and the next lookup after resume reconnects", async () => { + const handle = createFakeHost(idleMeta, { e2b: true }).handle; + let connects = 0; + const provider = createFakeSandboxProvider({ name: "e2b", connect: async () => { connects += 1; return handle; } }); + const cache = createSandboxHandleCache(); + const meta = createDefaultMeta(idleMeta); + const lookup = (current) => connectSandboxHandle({ meta: current, provider, readSecret: async () => undefined, cache }); + + await lookup(meta); + assert.equal(await lookup({ ...meta, sandbox_paused_at: "2026-10-01T00:10:00.000Z" }), null); + assert.ok(await lookup(meta)); + assert.equal(connects, 2); +}); + +test("terminating a paused session kills it by reference without waking it", async () => { + const { host, handle, provider } = createFakeHost(pausedMeta, { e2b: true, sandboxConnected: false, destroyByRef: true }); + await terminateSandbox(host, "stopped by user"); + assert.deepEqual(provider.destroyedByRef, [{ provider: "e2b", id: "sbx_1" }]); + assert.equal(provider.connectCalls, 0); + assert.equal(handle.calls.some(([name]) => name === "destroy"), false); + assert.equal(host.meta.sandbox_paused_at, undefined); +}); + +test("terminating a running session destroys through the handle, not by reference", async () => { + const { host, handle, provider } = createFakeHost(idleMeta, { e2b: true, destroyByRef: true }); + await terminateSandbox(host, "stopped by user"); + assert.deepEqual(handle.calls.find(([name]) => name === "destroy"), ["destroy", "stopped by user"]); + assert.deepEqual(provider.destroyedByRef, []); +}); + +test("teardown falls back to a kill by reference when the sandbox cannot be connected", async () => { + const { host, provider } = createFakeHost(idleMeta, { + e2b: true, + destroyByRef: true, + sandboxHandle: undefined, + }); + host.sandboxHandle = async () => { throw new Error("connect failed"); }; + await terminateSandbox(host, "timed out"); + assert.deepEqual(provider.destroyedByRef, [{ provider: "e2b", id: "sbx_1" }]); +}); + +test("a failed resume kills the paused sandbox by reference even if a connect would now succeed", async () => { + const { host, handle, provider } = createFakeHost( + { ...idleMeta, sandbox_paused_at: "x" }, + { e2b: true, connectError: new Error("503"), destroyByRef: true }, + ); + await resumeSandbox(host, NO_DELAY); + assert.equal(host.meta.state, "failed"); + assert.deepEqual(provider.destroyedByRef, [{ provider: "e2b", id: "sbx_1" }]); + assert.equal(handle.calls.some(([name]) => name === "destroy"), false); +}); + +test("a by-reference kill failure at teardown is logged redacted and not thrown", async () => { + const tracer = createRecordingTracer(); + const { host } = createFakeHost(pausedMeta, { + e2b: true, + tracer, + sandboxConnected: false, + destroyByRef: true, + destroyByRefError: new Error("kill failed sekret-traffic-token"), + }); + host.redactionSecrets.push("sekret-traffic-token"); + await terminateSandbox(host, "stopped by user"); + assert.ok(tracer.logs.some((log) => log.name === "sandbox.stop.failed")); + assert.ok(!JSON.stringify(tracer.logs).includes("sekret-traffic-token")); +}); + +// --- DO eviction mid-pause --- + +test("a ready session left with expected_close but no paused marker is assumed paused after eviction", () => { + const { host, directoryPatches, saveMetaCalls } = createFakeHost( + { ...idleMeta, expected_close: true }, + { e2b: true, sandboxConnected: false }, + ); + assert.equal(assumeSandboxPausedAfterEviction(host, T0 + 700_000), true); + assert.equal(host.meta.sandbox_paused_at, new Date(T0 + 700_000).toISOString()); + assert.deepEqual(directoryPatches.at(-1), { sandbox_state: "paused" }); +}); + +test("the eviction guess is skipped whenever the sandbox may still be in use", () => { + const cases = [ + ["a live agent socket", { ...idleMeta, expected_close: true }, { sandboxConnected: true }], + ["no expected_close", { ...idleMeta }, { sandboxConnected: false }], + ["already marked paused", { ...idleMeta, expected_close: true, sandbox_paused_at: "x" }, { sandboxConnected: false }], + ["a reconnect grace running", { ...idleMeta, expected_close: true, sandbox_disconnected_at: "2026-10-01T00:00:00.000Z" }, { sandboxConnected: false }], + ["a session that is not ready", { ...idleMeta, expected_close: true, state: "failed" }, { sandboxConnected: false }], + ]; + for (const [name, meta, options] of cases) { + const { host } = createFakeHost(meta, { e2b: true, ...options }); + assert.equal(assumeSandboxPausedAfterEviction(host, T0), false, name); + } + const cloudflare = createFakeHost({ ...idleMeta, expected_close: true }, { sandboxConnected: false }); + assert.equal(assumeSandboxPausedAfterEviction(cloudflare.host, T0), false, "provider that cannot pause"); +}); + +test("the eviction guess is skipped while a pause is in flight", async () => { + const { host, handle } = createFakeHost(idleMeta, { e2b: true, sandboxConnected: true }); + let release; + handle.pause = () => new Promise((resolve) => { release = resolve; }); + const pausing = pauseIdleSandbox(host, T0 + 600_000); + await Promise.resolve(); + assert.equal(host.meta.expected_close, true); + host.closeSandboxSockets("x"); + assert.equal(assumeSandboxPausedAfterEviction(host, T0 + 600_001), false); + release(); + assert.equal(await pausing, true); +}); + +test("after an eviction guess, the next agent request resumes through connect", async () => { + const { host, provider, handle } = createFakeHost( + { ...idleMeta, expected_close: true }, + { e2b: true, sandboxConnected: false }, + ); + assumeSandboxPausedAfterEviction(host, T0 + 700_000); + await resumeSandbox(host, NO_DELAY); + assert.equal(provider.connectCalls, 1); + assert.equal(host.meta.sandbox_paused_at, undefined); + assert.ok(handle.calls.some(([name]) => name === "writeFile")); +}); From a5b74ad899d08912278546bfd391d0e93fd7afdf Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 12:50:26 +0530 Subject: [PATCH 31/32] fix(sandbox): never cache Cloudflare sandbox stubs --- .../worker/src/orchestrator/sandbox-access.ts | 6 +++++- .../worker/test/deployment-config.test.mjs | 1 + packages/worker/test/sandbox-access.test.mjs | 20 ++++++++++++++++++- .../worker/wrangler.operator.example.toml | 7 +++++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/worker/src/orchestrator/sandbox-access.ts b/packages/worker/src/orchestrator/sandbox-access.ts index 1c970e6..f89a113 100644 --- a/packages/worker/src/orchestrator/sandbox-access.ts +++ b/packages/worker/src/orchestrator/sandbox-access.ts @@ -143,7 +143,11 @@ export async function connectSandboxHandle(options: { if (!secret && handle.secret) await options.storeSecret?.(handle.secret); return handle; }; - return options.cache ? options.cache.get(`${ref.provider}:${ref.id}`, connect) : connect(); + // Cloudflare handles wrap a Durable Object stub that many exceptions leave + // permanently broken, so every lookup must build a fresh one; only E2B (an + // HTTP API connection) is safe to reuse. + const cache = options.provider.name === "cloudflare" ? undefined : options.cache; + return cache ? cache.get(`${ref.provider}:${ref.id}`, connect) : connect(); } /** diff --git a/packages/worker/test/deployment-config.test.mjs b/packages/worker/test/deployment-config.test.mjs index f10375e..4a56e35 100644 --- a/packages/worker/test/deployment-config.test.mjs +++ b/packages/worker/test/deployment-config.test.mjs @@ -49,6 +49,7 @@ test("operator config and local variables are templates, not deployment credenti assert.match(ignore, /^wrangler\.operator\.toml$/m); assert.match(operatorTemplate, /database_id = "your-d1-database-id"/); + assert.match(operatorTemplate, /^SANDBOX_PROVIDER = "cloudflare"$/m); assert.match(varsTemplate, /^CODEVIL_PROXY_SIGNING_SECRET=$/m); for (const name of ["CODEVIL_API_KEY", "CODEVIL_SETUP_TOKEN", "CODEVIL_PROXY_SIGNING_SECRET", "BETTER_AUTH_SECRET", "GOOGLE_CLIENT_SECRET", "GITHUB_PAT"]) { assert.match(varsTemplate, new RegExp(`^${name}=$`, "m")); diff --git a/packages/worker/test/sandbox-access.test.mjs b/packages/worker/test/sandbox-access.test.mjs index 90303e9..0ee49a1 100644 --- a/packages/worker/test/sandbox-access.test.mjs +++ b/packages/worker/test/sandbox-access.test.mjs @@ -14,6 +14,7 @@ import { sandboxRefForMeta, } from "../dist/orchestrator/sandbox-access.js"; import { SandboxNotFoundError } from "../dist/sandbox-provider/types.js"; +import { createCloudflareSandboxProvider } from "../dist/sandbox-provider/cloudflare.js"; import { createFakeSandboxHandle } from "./helpers/fake-host.mjs"; const secret = "diagnostics-response-secret"; @@ -302,7 +303,7 @@ test("a SandboxNotFoundError from any handle call evicts the cached handle", asy } }); -test("other handle errors keep the cached handle", async () => { +test("other handle errors keep the cached E2B handle", async () => { let connects = 0; const provider = { name: "e2b", @@ -373,3 +374,20 @@ test("a paused sandbox without a by-reference kill is destroyed through its hand await destroySandbox(async () => handle, "x", () => assert.fail("no error"), { paused: true }); assert.deepEqual(reasons, ["x"]); }); + +test("Cloudflare lookups never reuse a handle: every lookup builds a fresh stub", async () => { + let getSandboxCalls = 0; + const provider = createCloudflareSandboxProvider({ + binding: {}, + getSandbox: () => { + getSandboxCalls += 1; + return {}; + }, + }); + const cache = createSandboxHandleCache(); + const meta = { session_id: "ses_1", sandbox_ref: { provider: "cloudflare", id: "ses_1" } }; + const first = await connectSandboxHandle({ meta, provider, readSecret: async () => undefined, cache }); + const second = await connectSandboxHandle({ meta, provider, readSecret: async () => undefined, cache }); + assert.equal(getSandboxCalls, 2); + assert.notEqual(first, second); +}); diff --git a/packages/worker/wrangler.operator.example.toml b/packages/worker/wrangler.operator.example.toml index 5107930..786431d 100644 --- a/packages/worker/wrangler.operator.example.toml +++ b/packages/worker/wrangler.operator.example.toml @@ -13,9 +13,12 @@ upload_source_maps = true [vars] EMAIL_PROVIDER = "none" -# Sandbox provider: "e2b" (default) or "cloudflare". E2B_API_KEY is a secret: +# Sandbox provider: "cloudflare" or "e2b". Keep "cloudflare" until E2B is ready; +# switch to "e2b" only after publishing the template +# (`pnpm --filter @codevil/sandbox-image e2b:template`), setting the E2B_API_KEY +# secret, and passing the manual E2B end-to-end check. E2B_API_KEY is a secret: # set it with `wrangler secret put E2B_API_KEY`, never in this file. -SANDBOX_PROVIDER = "e2b" +SANDBOX_PROVIDER = "cloudflare" E2B_TEMPLATE_ID = "codevil-sandbox" # E2B_MAX_SANDBOX_SECONDS = "3600" CODEVIL_WEB_ORIGIN = "https://your-web-origin.example" From 21c8103568c6d06d385ff7d2ab465c2e1ea9460d Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 2 Oct 2026 17:45:17 +0530 Subject: [PATCH 32/32] ci(sandbox): publish the E2B template on deploy and pin the Worker to it When wrangler.toml selects SANDBOX_PROVIDER = "e2b", a new e2b-template job builds this commit's sandbox image, pushes it to GHCR with GITHUB_TOKEN, and publishes it as the E2B template tagged with the commit SHA (plus "default", so the untagged name follows the latest publish). The deploy job waits for it and pins the Worker to E2B_TEMPLATE_ID = ":". Cloudflare deploys skip the publish. Publishing runs in its own job so the GITHUB_TOKEN E2B receives as pull credentials expires once the publish finishes, and the job that holds Cloudflare credentials never has packages: write. The deploy pipeline reads SANDBOX_PROVIDER and E2B_TEMPLATE_ID from the top-level [vars] table only, and requires SANDBOX_PROVIDER to be explicit because the Worker otherwise defaults to E2B. --- .github/workflows/ci.yml | 57 ++++++ README.md | 13 +- .../sandbox-image/scripts/e2b-template.mjs | 22 ++- .../sandbox-image/test/e2b-template.test.mjs | 24 +++ .../scripts/sandbox-deploy-settings.mjs | 15 ++ .../scripts/write-deployment-config.mjs | 57 +++++- .../worker/test/deployment-config.test.mjs | 177 +++++++++++++++++- packages/worker/wrangler.toml | 7 +- 8 files changed, 356 insertions(+), 16 deletions(-) create mode 100644 packages/worker/scripts/sandbox-deploy-settings.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e172b9..e98edbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,11 +54,64 @@ jobs: env: CODEVIL_SANDBOX_IMAGE: codevil-sandbox-e2b:ci + # Like wrangler's container build for Cloudflare: when wrangler.toml selects + # E2B, build this commit's sandbox image and publish it as the E2B template + # the deployed Worker is pinned to. A failed publish blocks the deploy. This is + # its own job so the GITHUB_TOKEN that E2B receives as registry credentials + # (it can also push packages) expires as soon as the publish finishes, and the + # Cloudflare deploy job never holds packages: write. + e2b-template: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: + - verify + - sandbox-image + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: production + permissions: + contents: read + packages: write + concurrency: + group: production-e2b-template + cancel-in-progress: false + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + with: + version: 10.28.1 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.19.0 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Read sandbox provider + id: sandbox + run: pnpm --filter @codevil/worker exec node scripts/sandbox-deploy-settings.mjs + + - name: Publish E2B sandbox template + if: steps.sandbox.outputs.sandbox_provider == 'e2b' + run: | + export CODEVIL_SANDBOX_IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/codevil-sandbox:${GITHUB_SHA}" + pnpm --filter @codevil/sandbox-image e2b:template + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + E2B_TEMPLATE_ID: ${{ steps.sandbox.outputs.e2b_template_id }} + E2B_TEMPLATE_TAG: ${{ github.sha }} + CODEVIL_REGISTRY_USERNAME: ${{ github.actor }} + CODEVIL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + deploy: if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: - verify - sandbox-image + - e2b-template runs-on: ubuntu-latest timeout-minutes: 60 environment: production @@ -86,8 +139,12 @@ jobs: - run: pnpm install --frozen-lockfile + # With SANDBOX_PROVIDER = "e2b" this pins E2B_TEMPLATE_ID to + # "