From c1ba408e2ee40613456f2a8b0177b5fe950cbd46 Mon Sep 17 00:00:00 2001 From: R4ph-t Date: Wed, 28 Oct 2026 18:14:36 +0100 Subject: [PATCH 01/26] feat(ai-sandbox-render): add Render sandbox provider Adds @tanstack/ai-sandbox-render, a SandboxProvider backed by Render sandboxes through @renderinc/sdk. Every command runs under a wrapper that records its process group, and kill signals that group from a second exec, because dropping a Render exec stream leaves the process running. --- packages/ai-sandbox-render/package.json | 55 ++ packages/ai-sandbox-render/src/handle.ts | 603 +++++++++++++++++++++ packages/ai-sandbox-render/src/index.ts | 9 + packages/ai-sandbox-render/src/provider.ts | 233 ++++++++ packages/ai-sandbox-render/tsconfig.json | 8 + packages/ai-sandbox-render/vite.config.ts | 37 ++ pnpm-lock.yaml | 312 ++++++----- 7 files changed, 1131 insertions(+), 126 deletions(-) create mode 100644 packages/ai-sandbox-render/package.json create mode 100644 packages/ai-sandbox-render/src/handle.ts create mode 100644 packages/ai-sandbox-render/src/index.ts create mode 100644 packages/ai-sandbox-render/src/provider.ts create mode 100644 packages/ai-sandbox-render/tsconfig.json create mode 100644 packages/ai-sandbox-render/vite.config.ts diff --git a/packages/ai-sandbox-render/package.json b/packages/ai-sandbox-render/package.json new file mode 100644 index 0000000000..17ce4b4cac --- /dev/null +++ b/packages/ai-sandbox-render/package.json @@ -0,0 +1,55 @@ +{ + "name": "@tanstack/ai-sandbox-render", + "version": "0.0.0", + "description": "Render sandbox provider for TanStack AI — run harness adapters inside Render sandboxes through the uniform SandboxHandle.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-sandbox-render" + }, + "keywords": [ + "ai", + "tanstack", + "sandbox", + "render", + "harness", + "agent", + "isolation" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:oxlint": "oxlint src --type-aware", + "test:coverage": "vitest run --coverage --coverage.include='src/**' --coverage.reporter=text-summary --coverage.reporter=json-summary", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "dependencies": { + "@renderinc/sdk": "^1.5.0" + }, + "peerDependencies": { + "@tanstack/ai-sandbox": "workspace:^" + }, + "devDependencies": { + "@tanstack/ai-sandbox": "workspace:*", + "@vitest/coverage-v8": "4.1.10" + } +} diff --git a/packages/ai-sandbox-render/src/handle.ts b/packages/ai-sandbox-render/src/handle.ts new file mode 100644 index 0000000000..b02634a4e8 --- /dev/null +++ b/packages/ai-sandbox-render/src/handle.ts @@ -0,0 +1,603 @@ +/** + * SandboxHandle backed by a Render sandbox (via `@renderinc/sdk`). Real + * isolation: fs/exec/git operate inside a remote Render microVM; the + * `/workspace` virtual root is a real directory created at boot. + * + * File reads and writes use the SDK's native `download`/`upload`; every other + * fs call is one shell command. Render's `exec` takes no cwd, env, stdin or + * kill, and dropping its stream leaves the remote process running (measured), + * so every command runs through a wrapper that records its process group in a + * file, and `kill()` signals that group from a second `exec`. + */ +import { randomUUID } from 'node:crypto' +import { Buffer } from 'node:buffer' +import { + UnsupportedCapabilityError, + createExecBackedGit, +} from '@tanstack/ai-sandbox' +import { ClientError } from '@renderinc/sdk' +import type { SandboxSnapshotKind, SandboxesClient } from '@renderinc/sdk' +import type { + ExecResult, + ProcessOptions, + SandboxCapabilities, + SandboxFsStat, + SandboxHandle, + SnapshotRef, + SpawnHandle, +} from '@tanstack/ai-sandbox' + +/** Default workspace root inside the sandbox; created at boot. */ +export const DEFAULT_WORKDIR = '/workspace' + +/** Prefix of every snapshot name this provider creates. */ +export const SNAPSHOT_PREFIX = 'tsai-' + +/** Byte cap per stream, for spawned processes and captured exec output. */ +const MAX_STREAM_BYTES = 8 * 1024 * 1024 + +/** + * How long a kill waits for the remote exit after `TERM` and the in-sandbox + * `KILL` escalation (2 s) before giving up on confirming it. + */ +const KILL_SETTLE_MS = 12_000 + +/** Where each command's process-group id is recorded while it runs. */ +const PGID_DIR = '/tmp/tsai' + +/** + * Capability descriptor for a Render sandbox. + * + * - `killableProcesses`: every command records its process group and `kill()` + * signals the group from a second exec (TERM, then KILL after 2 s). MEASURED + * against a real sandbox: a backgrounded child stopped writing after the + * group kill. `tests/journal.conformance.test.ts` re-measures it. + * - `writableStdin` / `ports` / `fork`: Render has no exec stdin, no inbound + * ports and no native fork. + * - `networkPolicy`: `deny-all` is enforced by Render (measured: DNS fails). + * - `durableFilesystem`: an idle sandbox suspends with its disk and resumes on + * the next exec or file call. + */ +export const RENDER_CAPS: SandboxCapabilities = { + fs: true, + exec: true, + env: true, + ports: false, + backgroundProcesses: true, + writableStdin: false, + killableProcesses: true, + snapshots: true, + networkPolicy: true, + durableFilesystem: true, + fork: false, +} + +/** POSIX single-quote escape for embedding values in `bash -c`. */ +export function q(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Wrap `command` so its process group is recorded in `pgidFile` while it + * runs. Each Render exec shell is already its own process-group leader + * (measured), so `$$` is the group id and the inner `bash -c` joins it. + */ +export function wrapCommand( + command: string, + pgidFile: string, + cwd: string, + env: Record, +): string { + const exports = Object.entries(env).map(([name, value]) => { + if (!ENV_NAME.test(name)) { + throw new Error(`render: invalid env var name ${JSON.stringify(name)}`) + } + return `${name}=${q(value)}` + }) + const exportStep = exports.length > 0 ? `export ${exports.join(' ')} && ` : '' + return `mkdir -p ${PGID_DIR} && echo $$ > ${pgidFile} && cd ${q(cwd)} && ${exportStep}bash -c ${q(command)}; tsai_status=$?; rm -f ${pgidFile}; exit $tsai_status` +} + +/** + * Signal the process group recorded in `pgidFile`: `TERM`, then `KILL` if it + * is still alive after 2 s. Waits up to 2 s for the file, so a kill that races + * the wrapper's start still lands. A missing file means the command is gone. + */ +export function killCommand(pgidFile: string): string { + return `for i in $(seq 20); do [ -f ${pgidFile} ] && break; sleep 0.1; done; g=$(cat ${pgidFile} 2>/dev/null) || exit 0; kill -TERM -- -$g 2>/dev/null || { rm -f ${pgidFile}; exit 0; }; for i in $(seq 20); do sleep 0.1; kill -0 -- -$g 2>/dev/null || { rm -f ${pgidFile}; exit 0; }; done; kill -KILL -- -$g 2>/dev/null; rm -f ${pgidFile}; true` +} + +const LSTAT_MISSING = '__TANSTACK_LSTAT_MISSING__' + +/** Verify a missing path by listing parent entries. `test -e` also fails for inaccessible parents. */ +export function lstatCommand(path: string): string { + return `tanstack_lstat_path=${q(path)}; tanstack_lstat_output=$(stat -c '%f:%s' -- "$tanstack_lstat_path" 2>&1); tanstack_lstat_status=$?; if [ "$tanstack_lstat_status" -eq 0 ]; then printf '%s\n' "$tanstack_lstat_output"; else tanstack_lstat_missing() { tanstack_missing_path=$1; case "$tanstack_missing_path" in /|.) return 1 ;; */*) tanstack_parent=${'$'}{tanstack_missing_path%/*}; tanstack_name=${'$'}{tanstack_missing_path##*/}; [ -n "$tanstack_parent" ] || tanstack_parent=/ ;; *) tanstack_parent=.; tanstack_name=$tanstack_missing_path ;; esac; tanstack_parent_mode=$(stat -L -c '%f' -- "$tanstack_parent" 2>/dev/null); tanstack_parent_status=$?; if [ "$tanstack_parent_status" -ne 0 ]; then tanstack_lstat_missing "$tanstack_parent"; else case "$tanstack_parent_mode" in 4[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) case "$tanstack_parent" in /*) tanstack_find_parent=$tanstack_parent ;; *) tanstack_find_parent=./$tanstack_parent ;; esac; tanstack_match=$(find -H "$tanstack_find_parent" -mindepth 1 -maxdepth 1 -exec sh -c 'tanstack_target=$1; shift; for tanstack_candidate do [ "${'$'}{tanstack_candidate##*/}" = "$tanstack_target" ] && { printf 1; exit 0; }; done; exit 0' sh "$tanstack_name" '{}' + 2>/dev/null); tanstack_find_status=$?; [ "$tanstack_find_status" -eq 0 ] && [ -z "$tanstack_match" ] ;; *) return 1 ;; esac; fi; }; if tanstack_lstat_missing "$tanstack_lstat_path"; then printf '%s' '${LSTAT_MISSING}'; else printf '%s\n' "$tanstack_lstat_output" >&2; exit "$tanstack_lstat_status"; fi; fi` +} + +function parseLstatOutput(output: string): SandboxFsStat { + const fields = /^(?[0-9a-fA-F]{4}):(?\d+)\n?$/.exec(output) + const mode = fields?.groups?.mode + const size = fields?.groups?.size + if (!mode || !size) throw new Error(`invalid lstat output: ${output}`) + const parsedMode = Number.parseInt(mode, 16) + const parsedSize = Number(size) + if ( + !Number.isSafeInteger(parsedMode) || + !Number.isSafeInteger(parsedSize) || + parsedSize < 0 + ) + throw new Error(`invalid lstat output: ${output}`) + const type = parsedMode & 0xf000 + if (type === 0x8000) + return { type: 'file', mode: parsedMode, size: parsedSize } + if (type === 0x4000) return { type: 'dir', mode: parsedMode } + if (type === 0xa000) return { type: 'symlink', mode: parsedMode } + return { type: 'other', mode: parsedMode } +} + +/** + * Reject paths `rm -rf` must never see. The path is single-quoted into the + * command, so this guards intent (wiping the root), not injection. + */ +export function assertRemovablePath(path: string): void { + if (path.includes('\0')) throw new Error('render: path contains a NUL byte') + const normalized = path.replace(/\/+/g, '/').replace(/(.)\/$/, '$1') + if (normalized === '' || normalized === '/' || !normalized.startsWith('/')) { + throw new Error(`render: refusing to remove ${JSON.stringify(path)}`) + } + if (normalized.split('/').some((part) => part === '..' || part === '.')) { + throw new Error( + `render: refusing to remove a path with relative segments: ${JSON.stringify(path)}`, + ) + } +} + +/** + * A push-driven async iterable with a byte cap. The streamer pushes decoded + * chunks and calls `end()` once; consumers `for await` over it. + */ +class AsyncChunkQueue implements AsyncIterable { + private readonly chunks: Array = [] + private readonly waiters: Array<(r: IteratorResult) => void> = [] + private ended = false + private bytes = 0 + + constructor( + private readonly label: string, + private readonly onOverflow: () => void, + ) {} + + push(chunk: string): void { + if (chunk === '' || this.ended) return + this.bytes += Buffer.byteLength(chunk) + if (this.bytes > MAX_STREAM_BYTES) { + this.emit( + `\n[render] ${this.label} exceeded ${MAX_STREAM_BYTES} bytes; output truncated\n`, + ) + this.end() + this.onOverflow() + return + } + this.emit(chunk) + } + + private emit(chunk: string): void { + const waiter = this.waiters.shift() + if (waiter) waiter({ value: chunk, done: false }) + else this.chunks.push(chunk) + } + + end(): void { + this.ended = true + let waiter = this.waiters.shift() + while (waiter) { + waiter({ value: undefined, done: true }) + waiter = this.waiters.shift() + } + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + const chunk = this.chunks.shift() + if (chunk !== undefined) { + return Promise.resolve({ value: chunk, done: false }) + } + if (this.ended) { + return Promise.resolve({ value: undefined, done: true }) + } + return new Promise((resolve) => this.waiters.push(resolve)) + }, + } + } +} + +/** A started command: its exit code, and a kill that settles on the exit. */ +interface RunningCommand { + exit: Promise + kill: () => Promise +} + +export interface RenderHandleDeps { + /** The SDK's sandboxes client. */ + client: SandboxesClient + /** Sandbox id (`sbx-…`). */ + id: string + /** Directory the `/workspace` virtual root maps to. */ + workdir: string + /** What `snapshot()` captures. */ + snapshotKind: SandboxSnapshotKind + /** Handle-local env overlay applied to every exec/spawn. */ + env?: Record +} + +export class RenderHandle implements SandboxHandle { + readonly id: string + readonly provider = 'render' + readonly workspaceRoot: string + readonly capabilities = RENDER_CAPS + readonly fs: SandboxHandle['fs'] + readonly git: SandboxHandle['git'] + readonly process: SandboxHandle['process'] + readonly ports: SandboxHandle['ports'] + readonly env: SandboxHandle['env'] + + private readonly client: SandboxesClient + private readonly workdir: string + private readonly snapshotKind: SandboxSnapshotKind + private readonly envVars: Record = {} + + constructor(deps: RenderHandleDeps) { + this.client = deps.client + this.id = deps.id + this.workdir = deps.workdir + this.workspaceRoot = deps.workdir + this.snapshotKind = deps.snapshotKind + if (deps.env) Object.assign(this.envVars, deps.env) + + this.process = { + exec: (command, opts) => this.exec(command, opts), + spawn: (command, opts) => this.spawnProcess(command, opts), + } + + this.fs = { + read: async (p, o) => + Buffer.from(await this.download(p, o?.signal)).toString('utf8'), + readBytes: (p, o) => this.download(p, o?.signal), + // Render creates missing parents (measured). + write: (p, data, o) => + this.client.upload( + this.id, + this.abs(p), + // Copy into a fresh buffer: a caller's view may sit on a SharedArrayBuffer. + typeof data === 'string' ? data : Buffer.from(data), + undefined, + o?.signal ? { signal: o.signal } : undefined, + ), + list: async (p, o) => { + // `-p` marks directories with a trailing `/`; symlinks are not followed. + const r = await this.check( + `ls -1Ap -- ${q(this.abs(p))}`, + `list ${p}`, + o?.signal, + ) + const base = p.replace(/\/$/, '') + return r.stdout + .split('\n') + .filter((line) => line !== '') + .map((line) => { + const name = line.replace(/\/$/, '') + return { + name, + path: `${base}/${name}`, + type: line.endsWith('/') ? ('dir' as const) : ('file' as const), + } + }) + }, + lstat: (p, o) => this.lstat(this.abs(p), o?.signal), + mkdir: async (p, o) => { + await this.check( + `mkdir -p -- ${q(this.abs(p))}`, + `mkdir ${p}`, + o?.signal, + ) + }, + remove: async (p, o) => { + const path = this.abs(p) + assertRemovablePath(path) + await this.check(`rm -rf -- ${q(path)}`, `remove ${p}`, o?.signal) + }, + rename: async (from, to, o) => { + await this.check( + `mv -f -- ${q(this.abs(from))} ${q(this.abs(to))}`, + `rename ${from}`, + o?.signal, + ) + }, + exists: async (p, o) => { + const path = q(this.abs(p)) + const r = await this.sh(`test -e ${path} || test -L ${path}`, o?.signal) + return r.exitCode === 0 + }, + } + + this.git = createExecBackedGit(this.process, this.workspaceRoot) + + this.ports = { + connect: () => + Promise.reject( + new UnsupportedCapabilityError( + 'render', + 'ports', + 'Render sandboxes do not expose inbound ports.', + ), + ), + } + + this.env = { + // Render has no VM-wide mutable env, so the overlay is handle-local and + // exported into every later exec/spawn. TanStack re-applies workspace + // secrets through this on resume and restore. + set: (vars) => { + Object.assign(this.envVars, vars) + return Promise.resolve() + }, + } + } + + /** + * Map the `/workspace` virtual root onto the workdir and resolve relative + * paths against it (Render resolves them against `/root`). + */ + private abs(p: string): string { + if (!p.startsWith('/')) return `${this.workdir}/${p}` + if (this.workdir === '/workspace') return p + if (p === '/workspace') return this.workdir + if (p.startsWith('/workspace/')) + return `${this.workdir}/${p.slice('/workspace/'.length)}` + return p + } + + private async download( + p: string, + signal: AbortSignal | undefined, + ): Promise { + const file = await this.client.download( + this.id, + this.abs(p), + undefined, + signal, + ) + // A directory downloads as a tar archive; reading one is an error. + if (file.contentType === 'application/x-tar') { + throw new Error(`render: ${p} is a directory`) + } + return new Uint8Array(file.data) + } + + /** + * Run an fs helper from `/`. Helpers take absolute paths, and the workdir + * may not exist yet (creating it is the first helper `create` runs). + */ + private sh( + command: string, + signal: AbortSignal | undefined, + ): Promise { + return this.exec(command, { cwd: '/', ...(signal ? { signal } : {}) }) + } + + /** Run an fs helper and throw on a non-zero exit. */ + private async check( + command: string, + what: string, + signal: AbortSignal | undefined, + ): Promise { + const r = await this.sh(command, signal) + if (r.exitCode !== 0) { + throw new Error( + `render: ${what} failed (exit ${r.exitCode}): ${r.stderr.trim()}`, + ) + } + return r + } + + private async lstat( + path: string, + signal: AbortSignal | undefined, + ): Promise { + const r = await this.sh(lstatCommand(path), signal) + if (r.exitCode === 0 && r.stdout.trim() === LSTAT_MISSING) return undefined + if (r.exitCode !== 0) { + const output = `${r.stdout}\n${r.stderr}` + throw new Error(`lstat failed: ${output.trim()}`) + } + return parseLstatOutput(r.stdout) + } + + /** Run a short command with no wrapper and no abort; used to deliver kills. */ + private async raw(command: string): Promise { + const events = await this.client.exec(this.id, command) + for await (const event of events) void event + } + + /** + * Start `command` under the process-group wrapper. Abort is handled by the + * callers through `kill`, not the SDK signal: dropping the stream would only + * detach this host and leave the command running. + */ + private run( + command: string, + opts: ProcessOptions | undefined, + onOutput: (stream: 'stdout' | 'stderr', data: string) => void, + ): RunningCommand { + const pgidFile = `${PGID_DIR}/${randomUUID()}` + const stream = new AbortController() + let settled = false + + const kill = async (): Promise => { + if (settled) return + await this.raw(killCommand(pgidFile)).catch(() => undefined) + await settleWithin(exit, KILL_SETTLE_MS) + if (!settled) { + stream.abort() + throw new Error( + `render: no exit confirmed ${KILL_SETTLE_MS}ms after killing \`${command}\``, + ) + } + } + + const exit = (async (): Promise => { + const wrapped = wrapCommand( + command, + pgidFile, + opts?.cwd ? this.abs(opts.cwd) : this.workdir, + { ...this.envVars, ...opts?.env }, + ) + const events = await this.client.exec( + this.id, + wrapped, + undefined, + stream.signal, + ) + for await (const event of events) { + if (event.type === 'output') onOutput(event.stream, event.data) + else return event.exit_code + } + throw new Error('render: exec stream ended without an exit event') + })() + .catch(async (error: unknown) => { + // The stream failed, so the process state is unknown: kill the group + // rather than leave it running until the sandbox times out. + if (!stream.signal.aborted) { + await this.raw(killCommand(pgidFile)).catch(() => undefined) + } + throw error + }) + .finally(() => { + settled = true + }) + exit.catch(() => undefined) + + return { exit, kill } + } + + private async exec( + command: string, + opts?: ProcessOptions, + ): Promise { + const signal = opts?.signal + signal?.throwIfAborted() + const out = { stdout: '', stderr: '' } + let truncated = false + const running = this.run(command, opts, (stream, data) => { + if (out[stream].length + data.length > MAX_STREAM_BYTES) { + truncated = true + return + } + out[stream] += data + }) + // An aborted exec kills the group and resolves with its real (signalled) + // exit code, as Railway does. + const onAbort = (): void => { + void running.kill().catch(() => undefined) + } + signal?.addEventListener('abort', onAbort, { once: true }) + try { + const exitCode = await running.exit + return { + stdout: out.stdout, + stderr: truncated + ? `${out.stderr}\n[render] output truncated\n` + : out.stderr, + exitCode, + } + } catch (error) { + if (signal?.aborted) throw signal.reason + throw error + } finally { + signal?.removeEventListener('abort', onAbort) + } + } + + private spawnProcess( + command: string, + opts?: ProcessOptions, + ): Promise { + opts?.signal?.throwIfAborted() + const kill = (): void => { + void running.kill().catch(() => undefined) + } + const stdoutQ = new AsyncChunkQueue('stdout', kill) + const stderrQ = new AsyncChunkQueue('stderr', kill) + const running = this.run(command, opts, (stream, data) => + (stream === 'stdout' ? stdoutQ : stderrQ).push(data), + ) + opts?.signal?.addEventListener('abort', kill, { once: true }) + const exit = running.exit.finally(() => { + opts?.signal?.removeEventListener('abort', kill) + stdoutQ.end() + stderrQ.end() + }) + exit.catch(() => undefined) + + const noStdin = (): Promise => + Promise.reject( + new UnsupportedCapabilityError( + 'render', + 'writableStdin', + 'Render exec has no stdin; deliver input through a file.', + ), + ) + + return Promise.resolve({ + // The process group id lives in the sandbox; a fabricated pid would be wrong. + pid: -1, + stdout: stdoutQ, + stderr: stderrQ, + stdin: { write: noStdin, end: noStdin }, + wait: () => exit, + // Always TERM then KILL to the whole group; the requested signal is not honoured. + kill: () => running.kill(), + }) + } + + snapshot = async (label?: string): Promise => { + const snap = await this.client.snapshots.create({ + sandboxId: this.id, + kind: this.snapshotKind, + name: `${SNAPSHOT_PREFIX}${label ?? 'snap'}`, + wait: true, + }) + return { id: snap.id, ...(label !== undefined ? { label } : {}) } + } + + async destroy(): Promise { + try { + await this.client.terminate(this.id) + } catch (error) { + if (error instanceof ClientError && error.statusCode === 404) return + throw error + } + } +} + +/** Wait for `settled` up to `ms`, ignoring its outcome. */ +async function settleWithin( + settled: Promise, + ms: number, +): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, ms) + timer.unref?.() + }) + try { + await Promise.race([settled.then(noop, noop), timeout]) + } finally { + clearTimeout(timer) + } +} + +function noop(): void {} diff --git a/packages/ai-sandbox-render/src/index.ts b/packages/ai-sandbox-render/src/index.ts new file mode 100644 index 0000000000..878f1bdb19 --- /dev/null +++ b/packages/ai-sandbox-render/src/index.ts @@ -0,0 +1,9 @@ +export { renderSandbox } from './provider' +export type { RenderSandboxConfig } from './provider' +export { + DEFAULT_WORKDIR, + RENDER_CAPS, + RenderHandle, + SNAPSHOT_PREFIX, +} from './handle' +export type { RenderHandleDeps } from './handle' diff --git a/packages/ai-sandbox-render/src/provider.ts b/packages/ai-sandbox-render/src/provider.ts new file mode 100644 index 0000000000..18abce6462 --- /dev/null +++ b/packages/ai-sandbox-render/src/provider.ts @@ -0,0 +1,233 @@ +import { ClientError, Render, SandboxCreateWaitError } from '@renderinc/sdk' +import { DEFAULT_WORKDIR, RENDER_CAPS, RenderHandle } from './handle' +import type { + SandboxCreateInput as RenderCreateInput, + SandboxNetworkPolicy, + SandboxPlan, + SandboxSnapshotKind, + SandboxesClient, +} from '@renderinc/sdk' +import type { + SandboxCapabilities, + SandboxCreateInput, + SandboxDestroyInput, + SandboxHandle, + SandboxPolicy, + SandboxProvider, + SandboxRestoreInput, + SandboxResumeInput, +} from '@tanstack/ai-sandbox' + +export interface RenderSandboxConfig { + /** Render API key. Omit to let the SDK read `RENDER_API_KEY`. */ + token?: string + /** Workspace id (`tea-…`). Omit to let the SDK read `RENDER_WORKSPACE_ID`. */ + ownerId?: `tea-${string}` + /** Pre-built SDK sandboxes client (tests, custom base URL). Overrides `token`. */ + client?: SandboxesClient + /** + * Sandbox group to create in. Defaults to the workspace's default group. + * Snapshots restore only inside the group they were captured in. + */ + sandboxGroupId?: string + /** Compute plan, e.g. `'2c-4g'`. Render defaults to `'1c-2g'`. */ + plan?: SandboxPlan | (string & {}) + /** Maximum continuous runtime in seconds. Render defaults to 86400 (24 h). */ + timeoutSeconds?: number + /** Idle seconds before the sandbox suspends; `0` disables. Render defaults to 900. */ + idleTimeoutSeconds?: number + /** + * Egress when the TanStack policy allows network, e.g. an `allow-list`. + * Defaults to `allow-all`. `policy.capabilities.network: 'deny'` always + * sends `deny-all`. + */ + networkPolicy?: SandboxNetworkPolicy + /** Boot every new sandbox from this Render sandbox build (a custom image). */ + buildName?: string + /** Boot every new sandbox from this named snapshot. */ + snapshotName?: string + /** + * What `handle.snapshot()` captures. `filesystem` (default) restores onto + * any plan; `runtime` also keeps memory and processes but needs the same plan. + */ + snapshotKind?: SandboxSnapshotKind + /** Directory the `/workspace` virtual root maps to. Defaults to `/workspace`. */ + workdir?: string +} + +/** How long `create` waits for the sandbox to reach `running` (measured: under 2 s). */ +const CREATE_WAIT_MS = 120_000 + +/** Statuses a sandbox never comes back from. */ +const ENDED = new Set(['errored', 'terminated']) + +function networkPolicy( + config: RenderSandboxConfig, + policy: SandboxPolicy | undefined, +): SandboxNetworkPolicy | undefined { + if (policy?.capabilities?.network === 'deny') return { type: 'deny-all' } + return config.networkPolicy +} + +/** 404 for an unknown sandbox, 400 for a malformed id (measured): both are gone. */ +function isMissing(error: unknown): boolean { + return ( + error instanceof ClientError && + (error.statusCode === 404 || error.statusCode === 400) + ) +} + +class RenderProvider implements SandboxProvider { + readonly name = 'render' + private readonly client: SandboxesClient + + constructor(private readonly config: RenderSandboxConfig) { + if (config.buildName !== undefined && config.snapshotName !== undefined) { + throw new Error('render: pass either buildName or snapshotName, not both') + } + this.client = + config.client ?? + new Render({ + ...(config.token !== undefined ? { token: config.token } : {}), + ...(config.ownerId !== undefined ? { ownerId: config.ownerId } : {}), + }).sandboxes + } + + capabilities(): SandboxCapabilities { + return RENDER_CAPS + } + + private get workdir(): string { + return this.config.workdir ?? DEFAULT_WORKDIR + } + + private handle(id: string): RenderHandle { + return new RenderHandle({ + client: this.client, + id, + workdir: this.workdir, + snapshotKind: this.config.snapshotKind ?? 'filesystem', + }) + } + + /** + * Create, wait for `running`, then create the workdir. Any failure after + * Render minted the sandbox, or an abort that landed meanwhile, terminates + * it so it does not bill until its timeout. + */ + private async boot( + input: RenderCreateInput, + signal: AbortSignal | undefined, + ): Promise { + signal?.throwIfAborted() + const c = this.config + let id: string | undefined + try { + const sandbox = await this.client.create({ + ...input, + ...(c.sandboxGroupId !== undefined + ? { sandboxGroupId: c.sandboxGroupId } + : {}), + ...(c.plan !== undefined ? { plan: c.plan } : {}), + ...(c.timeoutSeconds !== undefined + ? { timeoutSeconds: c.timeoutSeconds } + : {}), + ...(c.idleTimeoutSeconds !== undefined + ? { idleTimeoutSeconds: c.idleTimeoutSeconds } + : {}), + wait: { timeoutMs: CREATE_WAIT_MS, ...(signal ? { signal } : {}) }, + }) + id = sandbox.id + const handle = this.handle(id) + await handle.fs.mkdir(this.workdir) + signal?.throwIfAborted() + return handle + } catch (error) { + id ??= + error instanceof SandboxCreateWaitError ? error.sandboxId : undefined + if (id !== undefined) { + await this.client.terminate(id).catch(() => undefined) + } + if (signal?.aborted) throw signal.reason + throw error + } + } + + create(input: SandboxCreateInput): Promise { + // Render mints opaque `sbx-…` ids and has no labels to carry `input.id`, + // so it is ignored (the contract allows it); persist the returned id. + const c = this.config + const policy = networkPolicy(c, input.policy) + const source = + c.snapshotName !== undefined + ? { snapshotName: c.snapshotName } + : c.buildName !== undefined + ? { buildName: c.buildName } + : {} + return this.boot( + { + ...source, + ...(policy ? { networkPolicy: policy } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + }, + input.signal, + ) + } + + restoreSnapshot(input: SandboxRestoreInput): Promise { + // Env and network policy are not part of a snapshot (measured: create-time + // env is gone after restore), so both are reapplied here. + const policy = networkPolicy(this.config, input.policy) + return this.boot( + { + snapshotId: input.snapshotId, + ...(policy ? { networkPolicy: policy } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + }, + input.signal, + ) + } + + async resume(input: SandboxResumeInput): Promise { + input.signal?.throwIfAborted() + let status: string + try { + status = (await this.client.get(input.id, undefined, input.signal)).status + } catch (error) { + // Missing is gone. Auth and transport errors must surface so `ensure()` + // does not silently create a duplicate of a live sandbox. + if (isMissing(error)) return null + throw error + } + if (ENDED.has(status)) return null + if (status === 'creating' || status === 'resuming') { + await this.client.waitUntilRunning(input.id, { + ...(input.signal ? { signal: input.signal } : {}), + }) + } + // A suspended sandbox resumes on the next exec or file call. + return this.handle(input.id) + } + + async destroy(input: SandboxDestroyInput): Promise { + input.signal?.throwIfAborted() + try { + // Terminating an already-terminated sandbox succeeds (measured). + await this.client.terminate(input.id) + } catch (error) { + if (isMissing(error)) return + throw error + } + } +} + +/** + * Render sandbox provider — runs harness adapters inside Render sandboxes + * through the uniform `SandboxHandle`. Reads `RENDER_API_KEY` and + * `RENDER_WORKSPACE_ID` through the SDK when `token` / `ownerId` are omitted. + */ +export function renderSandbox( + config: RenderSandboxConfig = {}, +): SandboxProvider { + return new RenderProvider(config) +} diff --git a/packages/ai-sandbox-render/tsconfig.json b/packages/ai-sandbox-render/tsconfig.json new file mode 100644 index 0000000000..c38689f4ea --- /dev/null +++ b/packages/ai-sandbox-render/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-sandbox-render/vite.config.ts b/packages/ai-sandbox-render/vite.config.ts new file mode 100644 index 0000000000..11f5b20b70 --- /dev/null +++ b/packages/ai-sandbox-render/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ef6b0003b..aa5e9f080b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -846,7 +846,7 @@ importers: version: 1.170.41(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': specifier: ^1.168.60 - version: 1.168.60(crossws@0.4.6(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.9)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 1.168.60(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.9)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@19.2.3) @@ -1146,7 +1146,7 @@ importers: version: 1.159.5(@tanstack/react-router@1.170.41(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.171.34)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': specifier: ^1.168.60 - version: 1.168.60(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.9)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 1.168.60(crossws@0.4.6(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.9)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: ^1.168.42 version: 1.168.42(@tanstack/react-router@1.170.41(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.9)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -1222,7 +1222,7 @@ importers: version: 7.1.1(@cfworker/json-schema@4.1.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.2.1) + version: 1.29.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.2.1) '@ngrok/ngrok': specifier: ^1.7.0 version: 1.7.0 @@ -1873,7 +1873,7 @@ importers: version: link:../../packages/ai-remix remix: specifier: ^3.0.0-rc.1 - version: 3.0.0-rc.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(mysql2@3.15.3)(pg@8.23.1) + version: 3.0.0-rc.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(mysql2@3.15.3)(pg@8.23.1)(redis@5.12.1(@opentelemetry/api@1.9.1)) zod: specifier: ^4.2.0 version: 4.3.6 @@ -1916,7 +1916,7 @@ importers: version: link:../../packages/ai-solid '@tanstack/nitro-v2-vite-plugin': specifier: ^1.155.0 - version: 1.155.0(@electric-sql/pglite@0.5.11)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 1.155.0(@electric-sql/pglite@0.5.11)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: ^1.168.42 version: 1.168.42(@tanstack/react-router@1.170.41(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.9)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -2226,7 +2226,7 @@ importers: devDependencies: '@analogjs/vite-plugin-angular': specifier: ^2.6.2 - version: 2.6.3(@angular/build@21.2.21(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(@angular/compiler@21.2.20)(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.20(@angular/common@21.2.20(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1)))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.10.3)(chokidar@5.0.0)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.33.0)(ng-packagr@21.2.5(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@5.9.3))(postcss@8.5.26)(tailwindcss@4.1.18)(terser@5.44.1)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(vitest@4.1.10)(yaml@2.9.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 2.6.3(@angular/build@21.2.21(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(@angular/compiler@21.2.20)(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.20(@angular/common@21.2.20(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1)))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.10.3)(chokidar@5.0.0)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.33.0)(ng-packagr@21.2.5(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@5.9.3))(postcss@8.5.26)(tailwindcss@4.1.18)(terser@5.44.1)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(vitest@4.1.10)(yaml@2.9.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@angular/build': specifier: ^21.2.21 version: 21.2.21(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(@angular/compiler@21.2.20)(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.20(@angular/common@21.2.20(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1)))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.10.3)(chokidar@5.0.0)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.33.0)(ng-packagr@21.2.5(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@5.9.3))(postcss@8.5.26)(tailwindcss@4.1.18)(terser@5.44.1)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(vitest@4.1.10)(yaml@2.9.0) @@ -2262,7 +2262,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) zod: specifier: ^4.2.0 version: 4.3.6 @@ -3046,7 +3046,7 @@ importers: version: link:../ai-event-client ioredis: specifier: '>=5.0.0' - version: 5.9.2(supports-color@7.2.0) + version: 5.9.2 devDependencies: '@honcho-ai/sdk': specifier: ^2.1.1 @@ -3062,7 +3062,7 @@ importers: version: 4.1.10(vitest@4.1.10) ioredis-mock: specifier: ^8.9.0 - version: 8.13.1(@types/ioredis-mock@8.2.7(ioredis@5.9.2(supports-color@7.2.0)))(ioredis@5.9.2(supports-color@7.2.0)) + version: 8.13.1(@types/ioredis-mock@8.2.7(ioredis@5.9.2))(ioredis@5.9.2) redis: specifier: ^4.7.0 version: 4.7.1 @@ -3395,7 +3395,7 @@ importers: version: 20.11.2 remix: specifier: ^3.0.0-rc.1 - version: 3.0.0-rc.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(mysql2@3.15.3)(pg@8.23.1) + version: 3.0.0-rc.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(mysql2@3.15.3)(pg@8.23.1)(redis@5.12.1(@opentelemetry/api@1.9.1)) vite: specifier: ^8.2.1 version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) @@ -3559,6 +3559,19 @@ importers: specifier: 4.1.10 version: 4.1.10(vitest@4.1.11) + packages/ai-sandbox-render: + dependencies: + '@renderinc/sdk': + specifier: ^1.5.0 + version: 1.5.0(@opentelemetry/api@1.9.1) + devDependencies: + '@tanstack/ai-sandbox': + specifier: workspace:* + version: link:../ai-sandbox + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.11) + packages/ai-sandbox-sprites: devDependencies: '@tanstack/ai-sandbox': @@ -9754,10 +9767,28 @@ packages: peerDependencies: '@redis/client': ^1.0.0 + '@redis/bloom@5.12.1': + resolution: {integrity: sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==} + engines: {node: '>= 18.19.0'} + peerDependencies: + '@redis/client': ^5.12.1 + '@redis/client@1.6.1': resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} engines: {node: '>=14'} + '@redis/client@5.12.1': + resolution: {integrity: sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==} + engines: {node: '>= 18.19.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + '@redis/graph@1.1.1': resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} peerDependencies: @@ -9768,16 +9799,34 @@ packages: peerDependencies: '@redis/client': ^1.0.0 + '@redis/json@5.12.1': + resolution: {integrity: sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==} + engines: {node: '>= 18.19.0'} + peerDependencies: + '@redis/client': ^5.12.1 + '@redis/search@1.2.0': resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} peerDependencies: '@redis/client': ^1.0.0 + '@redis/search@5.12.1': + resolution: {integrity: sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==} + engines: {node: '>= 18.19.0'} + peerDependencies: + '@redis/client': ^5.12.1 + '@redis/time-series@1.1.0': resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} peerDependencies: '@redis/client': ^1.0.0 + '@redis/time-series@5.12.1': + resolution: {integrity: sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==} + engines: {node: '>= 18.19.0'} + peerDependencies: + '@redis/client': ^5.12.1 + '@remix-run/assert@0.3.0': resolution: {integrity: sha512-GBA9klvJdG5YSTwRNur54LQcfqDOtir+x1TDbv3dzB9FS2Li9CEiRLp1EpBcaYrj4ByO03WJCWFBiY8bTMy4vQ==} @@ -9942,6 +9991,10 @@ packages: '@remix-run/ui@0.8.0': resolution: {integrity: sha512-4OdZ3oj0zbyVVaCFT/x2vjK5YxpD63KpLONkLZ2QVKIEBcLjJUGSenp2f08BOzC8oythLHCJgzHLKKulYJpIQA==} + '@renderinc/sdk@1.5.0': + resolution: {integrity: sha512-xSJkPp8cx52SEXyO2uFwEaZHi0LAUlPI8hTQj1wEli+hEx4Dnb4ELg+dJeFnNiqSke0pbCgi6S3Ydmi7G0h+DQ==} + engines: {node: '>=18.0.0'} + '@rolldown/binding-android-arm-eabi@1.2.9': resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -13992,6 +14045,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + eventsource@4.1.1: + resolution: {integrity: sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==} + engines: {node: '>=20.0.0'} + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -16462,6 +16519,9 @@ packages: openapi-fetch@0.14.1: resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + openapi-fetch@0.15.2: + resolution: {integrity: sha512-rdYTzUmSsJevmNqg7fwUVGuKc2Gfb9h6ph74EVPkPfIGJaZTfqdIbJahtbJ3qg1LKinln30hqZniLnKpH0RJBg==} + openapi-typescript-helpers@0.0.15: resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} @@ -17299,6 +17359,10 @@ packages: redis@4.7.1: resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + redis@5.12.1: + resolution: {integrity: sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==} + engines: {node: '>= 18.19.0'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -19598,7 +19662,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - ? '@analogjs/vite-plugin-angular@2.6.3(@angular/build@21.2.21(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(@angular/compiler@21.2.20)(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.20(@angular/common@21.2.20(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1)))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.10.3)(chokidar@5.0.0)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.33.0)(ng-packagr@21.2.5(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@5.9.3))(postcss@8.5.26)(tailwindcss@4.1.18)(terser@5.44.1)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(vitest@4.1.10)(yaml@2.9.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))' + ? '@analogjs/vite-plugin-angular@2.6.3(@angular/build@21.2.21(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(@angular/compiler@21.2.20)(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.20(@angular/common@21.2.20(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1)))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.10.3)(chokidar@5.0.0)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.33.0)(ng-packagr@21.2.5(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@5.9.3))(postcss@8.5.26)(tailwindcss@4.1.18)(terser@5.44.1)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(vitest@4.1.10)(yaml@2.9.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))' : dependencies: magic-string: 0.30.21 obug: 2.1.1 @@ -19608,7 +19672,7 @@ snapshots: typescript: 5.9.3 optionalDependencies: '@angular/build': 21.2.21(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(@angular/compiler@21.2.20)(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.20(@angular/common@21.2.20(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1)))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.10.3)(chokidar@5.0.0)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.33.0)(ng-packagr@21.2.5(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@5.9.3))(postcss@8.5.26)(tailwindcss@4.1.18)(terser@5.44.1)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(vitest@4.1.10)(yaml@2.9.0) - vite: 8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -19729,7 +19793,7 @@ snapshots: ng-packagr: 21.2.5(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3))(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@5.9.3) postcss: 8.5.26 tailwindcss: 4.1.18 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -23209,7 +23273,7 @@ snapshots: '@mcp-ui/client@7.1.1(@cfworker/json-schema@4.1.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@modelcontextprotocol/ext-apps': 1.7.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.25.76) + '@modelcontextprotocol/ext-apps': 1.7.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.2.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.25.76) '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -23310,16 +23374,16 @@ snapshots: dependencies: zod: 4.3.6 - '@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.25.76)': + '@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.2.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.25.76)': dependencies: - '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.2.1) '@standard-schema/spec': 1.1.0 zod: 3.25.76 optionalDependencies: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.2.1)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.8) ajv: 8.20.0 @@ -23330,20 +23394,20 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.1 express: 5.2.1 - express-rate-limit: 8.7.0(express@5.2.1) + express-rate-limit: 8.7.0(express@5.2.1)(supports-color@7.2.0) hono: 4.13.8 jose: 6.2.12 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) + zod: 4.2.1 + zod-to-json-schema: 3.25.2(zod@4.2.1) optionalDependencies: '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.2.1)': + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.8) ajv: 8.20.0 @@ -23354,14 +23418,14 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.1 express: 5.2.1 - express-rate-limit: 8.7.0(express@5.2.1) + express-rate-limit: 8.7.0(express@5.2.1)(supports-color@7.2.0) hono: 4.13.8 jose: 6.2.12 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.2.1 - zod-to-json-schema: 3.25.2(zod@4.2.1) + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) optionalDependencies: '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: @@ -23378,7 +23442,7 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.1 express: 5.2.1 - express-rate-limit: 8.7.0(express@5.2.1) + express-rate-limit: 8.7.0(express@5.2.1)(supports-color@7.2.0) hono: 4.13.8 jose: 6.2.12 json-schema-typed: 8.0.2 @@ -23402,7 +23466,7 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.1 express: 5.2.1 - express-rate-limit: 8.7.0(express@5.2.1) + express-rate-limit: 8.7.0(express@5.2.1)(supports-color@7.2.0) hono: 4.13.8 jose: 6.2.12 json-schema-typed: 8.0.2 @@ -24710,20 +24774,20 @@ snapshots: typescript: 5.9.3 optional: true - '@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2))(typescript@7.0.2)': dependencies: '@prisma/client-runtime-utils': 7.9.0 optionalDependencies: - prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) - typescript: 5.9.3 + prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2) + typescript: 7.0.2 optional: true - '@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2))(typescript@7.0.2)': + '@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@prisma/client-runtime-utils': 7.9.0 optionalDependencies: - prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2) - typescript: 7.0.2 + prisma: 7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + typescript: 5.9.3 optional: true '@prisma/config@7.9.0(magicast@0.5.2)': @@ -26589,12 +26653,22 @@ snapshots: dependencies: '@redis/client': 1.6.1 + '@redis/bloom@5.12.1(@redis/client@5.12.1(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 5.12.1(@opentelemetry/api@1.9.1) + '@redis/client@1.6.1': dependencies: cluster-key-slot: 1.1.2 generic-pool: 3.9.0 yallist: 4.0.0 + '@redis/client@5.12.1(@opentelemetry/api@1.9.1)': + dependencies: + cluster-key-slot: 1.1.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@redis/graph@1.1.1(@redis/client@1.6.1)': dependencies: '@redis/client': 1.6.1 @@ -26603,14 +26677,26 @@ snapshots: dependencies: '@redis/client': 1.6.1 + '@redis/json@5.12.1(@redis/client@5.12.1(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 5.12.1(@opentelemetry/api@1.9.1) + '@redis/search@1.2.0(@redis/client@1.6.1)': dependencies: '@redis/client': 1.6.1 + '@redis/search@5.12.1(@redis/client@5.12.1(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 5.12.1(@opentelemetry/api@1.9.1) + '@redis/time-series@1.1.0(@redis/client@1.6.1)': dependencies: '@redis/client': 1.6.1 + '@redis/time-series@5.12.1(@redis/client@5.12.1(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 5.12.1(@opentelemetry/api@1.9.1) + '@remix-run/assert@0.3.0': {} '@remix-run/assets@0.6.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': @@ -26815,9 +26901,11 @@ snapshots: dependencies: '@remix-run/session': 0.4.2 - '@remix-run/session-storage-redis@0.1.1': + '@remix-run/session-storage-redis@0.1.1(redis@5.12.1(@opentelemetry/api@1.9.1))': dependencies: '@remix-run/session': 0.4.2 + optionalDependencies: + redis: 5.12.1(@opentelemetry/api@1.9.1) '@remix-run/session@0.4.2': {} @@ -26873,6 +26961,15 @@ snapshots: dependencies: '@types/dom-navigation': 1.0.7 + '@renderinc/sdk@1.5.0(@opentelemetry/api@1.9.1)': + dependencies: + eventsource: 4.1.1 + openapi-fetch: 0.15.2 + redis: 5.12.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' + '@rolldown/binding-android-arm-eabi@1.2.9': optional: true @@ -27845,9 +27942,9 @@ snapshots: - uploadthing - xml2js - '@tanstack/nitro-v2-vite-plugin@1.155.0(@electric-sql/pglite@0.5.11)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + '@tanstack/nitro-v2-vite-plugin@1.155.0(@electric-sql/pglite@0.5.11)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - nitropack: 2.13.1(@electric-sql/pglite@0.5.11)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.2.4) + nitropack: 2.13.1(@electric-sql/pglite@0.5.11)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.2.4) pathe: 2.0.3 vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: @@ -28823,9 +28920,9 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/ioredis-mock@8.2.7(ioredis@5.9.2(supports-color@7.2.0))': + '@types/ioredis-mock@8.2.7(ioredis@5.9.2)': dependencies: - ioredis: 5.9.2(supports-color@7.2.0) + ioredis: 5.9.2 '@types/istanbul-lib-coverage@2.0.6': {} @@ -29339,7 +29436,7 @@ snapshots: obug: 2.2.1 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/coverage-v8@4.1.10(vitest@4.1.11)': dependencies: @@ -29389,14 +29486,6 @@ snapshots: optionalDependencies: vite: 8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) - '@vitest/mocker@4.1.10(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.1.10 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) - '@vitest/mocker@4.1.10(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 @@ -30895,11 +30984,11 @@ snapshots: dayjs@1.11.19: {} - db0@0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3): + db0@0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3): optionalDependencies: '@electric-sql/pglite': 0.5.11 better-sqlite3: 12.11.1 - drizzle-orm: 0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)) + drizzle-orm: 0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)) mysql2: 3.15.3 db0@0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3): @@ -31151,34 +31240,34 @@ snapshots: prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) optional: true - drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)): + drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2))(typescript@7.0.2))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2)): optionalDependencies: '@cloudflare/workers-types': 4.20260317.1 '@electric-sql/pglite': 0.5.11 '@opentelemetry/api': 1.9.1 - '@prisma/client': 7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) + '@prisma/client': 7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2))(typescript@7.0.2) '@types/pg': 8.23.1 better-sqlite3: 12.11.1 bun-types: 1.3.14 mysql2: 3.15.3 pg: 8.23.1 postgres: 3.4.7 - prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) - optional: true + prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2) - drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2))(typescript@7.0.2))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2)): + drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)): optionalDependencies: '@cloudflare/workers-types': 4.20260317.1 '@electric-sql/pglite': 0.5.11 '@opentelemetry/api': 1.9.1 - '@prisma/client': 7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2))(typescript@7.0.2) + '@prisma/client': 7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) '@types/pg': 8.23.1 better-sqlite3: 12.11.1 bun-types: 1.3.14 mysql2: 3.15.3 pg: 8.23.1 postgres: 3.4.7 - prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2) + prisma: 7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + optional: true drizzle-orm@0.45.4(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)): optionalDependencies: @@ -31609,6 +31698,10 @@ snapshots: dependencies: eventsource-parser: 3.1.1 + eventsource@4.1.1: + dependencies: + eventsource-parser: 3.1.1 + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -31817,7 +31910,7 @@ snapshots: exponential-backoff@3.1.3: {} - express-rate-limit@8.7.0(express@5.2.1): + express-rate-limit@8.7.0(express@5.2.1)(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) express: 5.2.1 @@ -32704,17 +32797,17 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis-mock@8.13.1(@types/ioredis-mock@8.2.7(ioredis@5.9.2(supports-color@7.2.0)))(ioredis@5.9.2(supports-color@7.2.0)): + ioredis-mock@8.13.1(@types/ioredis-mock@8.2.7(ioredis@5.9.2))(ioredis@5.9.2): dependencies: '@ioredis/as-callback': 3.0.0 '@ioredis/commands': 1.5.0 - '@types/ioredis-mock': 8.2.7(ioredis@5.9.2(supports-color@7.2.0)) + '@types/ioredis-mock': 8.2.7(ioredis@5.9.2) fengari: 0.1.5 fengari-interop: 0.1.4(fengari@0.1.5) - ioredis: 5.9.2(supports-color@7.2.0) + ioredis: 5.9.2 semver: 7.8.4 - ioredis@5.9.2(supports-color@7.2.0): + ioredis@5.9.2: dependencies: '@ioredis/commands': 1.5.0 cluster-key-slot: 1.1.2 @@ -34557,7 +34650,7 @@ snapshots: h3: 1.15.5 hookable: 5.5.3 httpxy: 0.1.7 - ioredis: 5.9.2(supports-color@7.2.0) + ioredis: 5.9.2 jiti: 2.7.0 klona: 2.0.6 knitwork: 1.3.0 @@ -34625,7 +34718,7 @@ snapshots: - supports-color - uploadthing - nitropack@2.13.1(@electric-sql/pglite@0.5.11)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.2.4): + nitropack@2.13.1(@electric-sql/pglite@0.5.11)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.2.4): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.60.1) @@ -34646,7 +34739,7 @@ snapshots: cookie-es: 2.0.1 croner: 9.1.0 crossws: 0.3.5 - db0: 0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) + db0: 0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) defu: 6.1.4 destr: 2.0.5 dot-prop: 10.1.0 @@ -34659,7 +34752,7 @@ snapshots: h3: 1.15.5 hookable: 5.5.3 httpxy: 0.1.7 - ioredis: 5.9.2(supports-color@7.2.0) + ioredis: 5.9.2 jiti: 2.7.0 klona: 2.0.6 knitwork: 1.3.0 @@ -34692,7 +34785,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 5.6.0 unplugin-utils: 0.3.1 - unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ioredis@5.9.2) + unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ioredis@5.9.2) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.0-beta.13 @@ -35101,6 +35194,10 @@ snapshots: dependencies: openapi-typescript-helpers: 0.0.15 + openapi-fetch@0.15.2: + dependencies: + openapi-typescript-helpers: 0.0.15 + openapi-typescript-helpers@0.0.15: {} optionator@0.9.4: @@ -35798,17 +35895,17 @@ snapshots: - react-dom optional: true - prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2): dependencies: '@prisma/config': 7.9.0(magicast@0.5.2) - '@prisma/dev': 0.24.14(typescript@5.9.3) + '@prisma/dev': 0.24.14(typescript@7.0.2) '@prisma/engines': 7.9.0 '@prisma/studio-core': 0.33.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) mysql2: 3.15.3 postgres: 3.4.7 optionalDependencies: better-sqlite3: 12.11.1 - typescript: 5.9.3 + typescript: 7.0.2 transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -35817,17 +35914,17 @@ snapshots: - react-dom optional: true - prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2): + prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): dependencies: '@prisma/config': 7.9.0(magicast@0.5.2) - '@prisma/dev': 0.24.14(typescript@7.0.2) + '@prisma/dev': 0.24.14(typescript@5.9.3) '@prisma/engines': 7.9.0 '@prisma/studio-core': 0.33.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) mysql2: 3.15.3 postgres: 3.4.7 optionalDependencies: better-sqlite3: 12.11.1 - typescript: 7.0.2 + typescript: 5.9.3 transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -36488,6 +36585,17 @@ snapshots: '@redis/search': 1.2.0(@redis/client@1.6.1) '@redis/time-series': 1.1.0(@redis/client@1.6.1) + redis@5.12.1(@opentelemetry/api@1.9.1): + dependencies: + '@redis/bloom': 5.12.1(@redis/client@5.12.1(@opentelemetry/api@1.9.1)) + '@redis/client': 5.12.1(@opentelemetry/api@1.9.1) + '@redis/json': 5.12.1(@redis/client@5.12.1(@opentelemetry/api@1.9.1)) + '@redis/search': 5.12.1(@redis/client@5.12.1(@opentelemetry/api@1.9.1)) + '@redis/time-series': 5.12.1(@redis/client@5.12.1(@opentelemetry/api@1.9.1)) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' + reflect-metadata@0.2.2: {} regenerate-unicode-properties@10.2.2: @@ -36578,7 +36686,7 @@ snapshots: remeda@2.33.4: optional: true - remix@3.0.0-rc.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(mysql2@3.15.3)(pg@8.23.1): + remix@3.0.0-rc.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(mysql2@3.15.3)(pg@8.23.1)(redis@5.12.1(@opentelemetry/api@1.9.1)): dependencies: '@remix-run/assert': 0.3.0 '@remix-run/assets': 0.6.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) @@ -36619,7 +36727,7 @@ snapshots: '@remix-run/session': 0.4.2 '@remix-run/session-middleware': 0.4.0 '@remix-run/session-storage-memcache': 0.1.2 - '@remix-run/session-storage-redis': 0.1.1 + '@remix-run/session-storage-redis': 0.1.1(redis@5.12.1(@opentelemetry/api@1.9.1)) '@remix-run/spa': 0.1.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) '@remix-run/static-middleware': 0.4.14 '@remix-run/tar-parser': 0.7.1 @@ -36630,6 +36738,7 @@ snapshots: optionalDependencies: mysql2: 3.15.3 pg: 8.23.1 + redis: 5.12.1(@opentelemetry/api@1.9.1) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -38252,9 +38361,9 @@ snapshots: optionalDependencies: aws4fetch: 1.0.20 db0: 0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) - ioredis: 5.9.2(supports-color@7.2.0) + ioredis: 5.9.2 - unstorage@1.17.4(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ioredis@5.9.2): + unstorage@1.17.4(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ioredis@5.9.2): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -38266,8 +38375,8 @@ snapshots: ufo: 1.6.3 optionalDependencies: aws4fetch: 1.0.20 - db0: 0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) - ioredis: 5.9.2(supports-color@7.2.0) + db0: 0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.3.0)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) + ioredis: 5.9.2 unstorage@2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.11)(better-sqlite3@12.11.1)(drizzle-orm@0.45.4(@electric-sql/pglite@0.5.11)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.23.1)(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(pg@8.23.1)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(lru-cache@11.5.3)(ofetch@2.0.0-alpha.3): optionalDependencies: @@ -38574,24 +38683,6 @@ snapshots: tsx: 4.23.12 yaml: 2.9.0 - vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0): - dependencies: - lightningcss: 1.33.0 - picomatch: 4.0.7 - postcss: 8.5.28 - rolldown: 1.2.9 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 24.10.3 - esbuild: 0.28.1 - fsevents: 2.3.3 - jiti: 2.7.0 - less: 4.6.6 - sass: 1.97.3 - terser: 5.44.1 - tsx: 4.23.12 - yaml: 2.9.0 - vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 @@ -38680,37 +38771,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.1.1 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.3.0(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@types/node': 24.10.3 - '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - happy-dom: 20.11.2 - jsdom: 27.4.0(@noble/hashes@2.3.0) - transitivePeerDependencies: - - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 From 247b1bef1c614d88b898070d3814d22bcd6a3372 Mon Sep 17 00:00:00 2001 From: R4ph-t Date: Wed, 28 Oct 2026 18:14:36 +0100 Subject: [PATCH 02/26] test(ai-sandbox-render): add unit tests The handle tests run commands in a local bash that leads its own process group, as Render's exec shell does, so the wrapper and group kill run for real. The provider tests cover create cleanup, resume status mapping and network policy. --- packages/ai-sandbox-render/tests/fakes.ts | 88 ++++++++++ .../ai-sandbox-render/tests/handle.test.ts | 161 ++++++++++++++++++ .../ai-sandbox-render/tests/provider.test.ts | 153 +++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 packages/ai-sandbox-render/tests/fakes.ts create mode 100644 packages/ai-sandbox-render/tests/handle.test.ts create mode 100644 packages/ai-sandbox-render/tests/provider.test.ts diff --git a/packages/ai-sandbox-render/tests/fakes.ts b/packages/ai-sandbox-render/tests/fakes.ts new file mode 100644 index 0000000000..e19874bf0c --- /dev/null +++ b/packages/ai-sandbox-render/tests/fakes.ts @@ -0,0 +1,88 @@ +/** + * A fake slice of the Render SDK's `SandboxesClient`. `exec` runs the command + * in a local `bash` as its own process-group leader, as Render's exec shell is + * (measured), so the handle's real wrapper and group kill are exercised. An + * aborted stream only detaches, as on Render: the process keeps running. + */ +import { spawn } from 'node:child_process' +import { constants } from 'node:os' +import { vi } from 'vitest' +import type { SandboxExecEvent, SandboxesClient } from '@renderinc/sdk' + +async function* localExec( + command: string, + signal?: AbortSignal, +): AsyncGenerator { + const child = spawn('bash', ['-c', command], { + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const queue: Array = [] + let wake = (): void => {} + const push = (event: SandboxExecEvent): void => { + queue.push(event) + wake() + } + child.stdout.on('data', (d) => + push({ type: 'output', stream: 'stdout', data: String(d) }), + ) + child.stderr.on('data', (d) => + push({ type: 'output', stream: 'stderr', data: String(d) }), + ) + child.on('close', (code, sig) => + push({ + type: 'exit', + exit_code: code ?? 128 + (sig ? constants.signals[sig] : 0), + }), + ) + for (;;) { + if (signal?.aborted) throw new Error('AbortError') + const event = queue.shift() + if (event === undefined) { + await new Promise((resolve) => { + wake = resolve + signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + continue + } + yield event + if (event.type === 'exit') return + } +} + +export function fakeClient(files = new Map()): { + /** The fake's `vi.fn` mocks, for driving and asserting. */ + client: Record + sdk: SandboxesClient + files: Map +} { + const client = { + exec: vi.fn( + (_id: string, command: string, _owner?: string, signal?: AbortSignal) => + Promise.resolve(localExec(command, signal)), + ), + upload: vi.fn((_id: string, path: string, data: string | Uint8Array) => { + files.set(path, typeof data === 'string' ? Buffer.from(data) : data) + return Promise.resolve() + }), + download: vi.fn((_id: string, path: string) => { + const data = files.get(path) + if (data === undefined) return Promise.reject(new Error('not found')) + return Promise.resolve({ + data: Buffer.from(data), + size: data.byteLength, + contentType: path.endsWith('/') + ? 'application/x-tar' + : 'application/octet-stream', + }) + }), + terminate: vi.fn(() => Promise.resolve()), + get: vi.fn(), + create: vi.fn(), + waitUntilRunning: vi.fn(() => Promise.resolve()), + snapshots: { + create: vi.fn(() => Promise.resolve({ id: 'snp-1' })), + }, + } + return { client, sdk: client as unknown as SandboxesClient, files } +} diff --git a/packages/ai-sandbox-render/tests/handle.test.ts b/packages/ai-sandbox-render/tests/handle.test.ts new file mode 100644 index 0000000000..b93a01f1b9 --- /dev/null +++ b/packages/ai-sandbox-render/tests/handle.test.ts @@ -0,0 +1,161 @@ +/** + * Handle tests against a local `bash` standing in for Render's exec (see + * `fakes.ts`). The case that matters most: `kill()` stops a backgrounded + * child, not just this host's view of the stream. + */ +import { mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { UnsupportedCapabilityError } from '@tanstack/ai-sandbox' +import { RenderHandle, wrapCommand } from '../src/handle' +import { fakeClient } from './fakes' + +let dir: string +let fake: ReturnType +let handle: RenderHandle + +beforeEach(async () => { + // Resolved, because macOS tmpdir is a symlink and `pwd` prints the real path. + dir = await realpath(await mkdtemp(join(tmpdir(), 'tsai-render-'))) + fake = fakeClient() + handle = new RenderHandle({ + client: fake.sdk, + id: 'sbx-test', + workdir: dir, + snapshotKind: 'filesystem', + }) +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const lines = async (path: string) => + (await readFile(path, 'utf8').catch(() => '')).split('\n').length + +describe('process', () => { + it('runs in the workdir with the env overlay and returns the exit code', async () => { + await handle.env.set({ A: "it's" }) + const r = await handle.process.exec( + 'pwd; echo "$A $B"; echo err >&2; exit 3', + { + env: { B: 'b' }, + }, + ) + expect(r).toEqual({ + stdout: `${dir}\nit's b\n`, + stderr: 'err\n', + exitCode: 3, + }) + }) + + it('maps /workspace and relative cwd onto the workdir', async () => { + await handle.fs.mkdir('sub') + const r = await handle.process.exec('pwd', { cwd: '/workspace/sub' }) + expect(r.stdout.trim()).toBe(join(dir, 'sub')) + }) + + it('rejects env names that are not shell identifiers', () => { + expect(() => wrapCommand('true', '/tmp/x', '/', { 'A;rm': 'x' })).toThrow( + /invalid env var name/, + ) + }) + + it('kill() stops a backgrounded child', async () => { + const ticks = join(dir, 'ticks') + const proc = await handle.process.spawn( + `(while true; do echo t >> ${ticks}; sleep 0.05; done) & wait`, + ) + await sleep(300) + await proc.kill() + expect(await proc.wait()).not.toBe(0) + const after = await lines(ticks) + await sleep(300) + expect(await lines(ticks)).toBe(after) + }) + + it('an aborted exec kills the group and resolves with its exit code', async () => { + const ticks = join(dir, 'ticks') + const controller = new AbortController() + const run = handle.process.exec( + `(while true; do echo t >> ${ticks}; sleep 0.05; done) & wait`, + { signal: controller.signal }, + ) + await sleep(300) + controller.abort() + expect((await run).exitCode).not.toBe(0) + const after = await lines(ticks) + await sleep(300) + expect(await lines(ticks)).toBe(after) + }) + + it('streams spawn output and has no stdin', async () => { + const proc = await handle.process.spawn('echo out; echo err >&2') + const out: Array = [] + for await (const chunk of proc.stdout) out.push(chunk) + expect(out.join('')).toBe('out\n') + expect(await proc.wait()).toBe(0) + await expect(proc.stdin.write('x')).rejects.toBeInstanceOf( + UnsupportedCapabilityError, + ) + }) +}) + +describe('fs', () => { + it('lists, renames, checks and removes through the shell', async () => { + await handle.fs.mkdir('/workspace/a/b') + await handle.process.exec('touch a/f') + expect(await handle.fs.list('a')).toEqual( + expect.arrayContaining([ + { name: 'b', path: 'a/b', type: 'dir' }, + { name: 'f', path: 'a/f', type: 'file' }, + ]), + ) + await handle.fs.rename('a/f', 'a/g') + expect(await handle.fs.exists('a/g')).toBe(true) + expect(await handle.fs.exists('a/f')).toBe(false) + // ponytail: lstat's GNU `stat` probe is covered by the live suite, not here (BSD stat differs). + await handle.fs.remove('a') + await expect(stat(join(dir, 'a'))).rejects.toThrow() + }) + + it('reads and writes through upload and download at absolute paths', async () => { + await handle.fs.write('x.txt', 'hello') + expect(fake.client.upload.mock.calls[0]?.[1]).toBe(join(dir, 'x.txt')) + expect(await handle.fs.read('x.txt')).toBe('hello') + await handle.fs.write('/tmp/b.bin', new Uint8Array([0, 255])) + expect([...(await handle.fs.readBytes('/tmp/b.bin'))]).toEqual([0, 255]) + }) + + it('refuses to read a directory (Render returns a tar archive)', async () => { + fake.files.set('/tmp/dir/', new Uint8Array([1])) + await expect(handle.fs.read('/tmp/dir/')).rejects.toThrow(/is a directory/) + }) + + it('refuses to remove the root', async () => { + await expect(handle.fs.remove('/')).rejects.toThrow(/refusing/) + }) +}) + +describe('capabilities', () => { + it('rejects ports', async () => { + await expect(handle.ports.connect(3000)).rejects.toBeInstanceOf( + UnsupportedCapabilityError, + ) + }) + + it('snapshots with the configured kind and a tsai- name', async () => { + expect(await handle.snapshot('after-setup')).toEqual({ + id: 'snp-1', + label: 'after-setup', + }) + expect(fake.client.snapshots.create).toHaveBeenCalledWith({ + sandboxId: 'sbx-test', + kind: 'filesystem', + name: 'tsai-after-setup', + wait: true, + }) + }) +}) diff --git a/packages/ai-sandbox-render/tests/provider.test.ts b/packages/ai-sandbox-render/tests/provider.test.ts new file mode 100644 index 0000000000..303eae5a9e --- /dev/null +++ b/packages/ai-sandbox-render/tests/provider.test.ts @@ -0,0 +1,153 @@ +/** + * Provider tests against a fake SDK client. The cases that matter most: a + * missing or ended sandbox resumes as `null` (so `ensure()` re-creates), + * anything else surfaces (so `ensure()` does not duplicate a live sandbox), a + * failed or aborted create terminates what Render minted, and a `deny` + * network policy always reaches Render, on restore too. + */ +import { describe, expect, it } from 'vitest' +import { + ClientError, + SandboxCreateWaitError, + ServerError, +} from '@renderinc/sdk' +import { renderSandbox } from '../src/index' +import { fakeClient } from './fakes' + +function setup(config = {}) { + const fake = fakeClient() + fake.client.create.mockResolvedValue({ id: 'sbx-1', status: 'running' }) + const provider = renderSandbox({ + client: fake.sdk, + workdir: '/tmp', + ...config, + }) + return { ...fake, provider } +} + +describe('create', () => { + it('passes the config, waits for running, and returns the handle', async () => { + const { client, provider } = setup({ + plan: '2c-4g', + idleTimeoutSeconds: 600, + buildName: 'node-22', + }) + const handle = await provider.create({ env: { K: 'v' } }) + expect(handle.id).toBe('sbx-1') + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + plan: '2c-4g', + idleTimeoutSeconds: 600, + buildName: 'node-22', + env: { K: 'v' }, + wait: expect.objectContaining({ timeoutMs: 120_000 }), + }), + ) + }) + + it("sends deny-all for policy network 'deny', over the configured policy", async () => { + const { client, provider } = setup({ + networkPolicy: { type: 'allow-list', rules: [{ domain: 'x.com' }] }, + }) + await provider.create({ policy: { capabilities: { network: 'deny' } } }) + expect(client.create.mock.calls[0]?.[0].networkPolicy).toEqual({ + type: 'deny-all', + }) + }) + + it('terminates the sandbox when the wait fails', async () => { + const { client, provider } = setup() + client.create.mockRejectedValue( + new SandboxCreateWaitError('wait failed', 'sbx-2', new Error('timeout')), + ) + await expect(provider.create({})).rejects.toThrow('wait failed') + expect(client.terminate).toHaveBeenCalledWith('sbx-2') + }) + + it('terminates the sandbox and rejects with the reason on abort', async () => { + const { client, provider } = setup() + const controller = new AbortController() + const reason = new Error('cancelled') + client.create.mockImplementation(async () => { + controller.abort(reason) + return { id: 'sbx-3' } + }) + await expect(provider.create({ signal: controller.signal })).rejects.toBe( + reason, + ) + expect(client.terminate).toHaveBeenCalledWith('sbx-3') + }) + + it('rejects buildName with snapshotName', () => { + expect(() => + renderSandbox({ + client: fakeClient().sdk, + buildName: 'a', + snapshotName: 'b', + }), + ).toThrow(/either/) + }) +}) + +describe('restoreSnapshot', () => { + it('restores by id and reapplies env and the deny policy', async () => { + const { client, provider } = setup() + await provider.restoreSnapshot!({ + snapshotId: 'snp-1', + env: { K: 'v' }, + policy: { capabilities: { network: 'deny' } }, + }) + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + snapshotId: 'snp-1', + env: { K: 'v' }, + networkPolicy: { type: 'deny-all' }, + }), + ) + }) +}) + +describe('resume', () => { + it.each(['running', 'suspended'])( + 'returns a handle for %s', + async (status) => { + const { client, provider } = setup() + client.get.mockResolvedValue({ status }) + expect((await provider.resume({ id: 'sbx-1' }))?.id).toBe('sbx-1') + expect(client.waitUntilRunning).not.toHaveBeenCalled() + }, + ) + + it('waits for a sandbox that is still resuming', async () => { + const { client, provider } = setup() + client.get.mockResolvedValue({ status: 'resuming' }) + await provider.resume({ id: 'sbx-1' }) + expect(client.waitUntilRunning).toHaveBeenCalled() + }) + + it.each(['terminated', 'errored'])('returns null for %s', async (status) => { + const { client, provider } = setup() + client.get.mockResolvedValue({ status }) + expect(await provider.resume({ id: 'sbx-1' })).toBeNull() + }) + + it.each([404, 400])('returns null on a %s', async (code) => { + const { client, provider } = setup() + client.get.mockRejectedValue(new ClientError('gone', code)) + expect(await provider.resume({ id: 'sbx-1' })).toBeNull() + }) + + it('surfaces other errors', async () => { + const { client, provider } = setup() + client.get.mockRejectedValue(new ServerError('boom', 500)) + await expect(provider.resume({ id: 'sbx-1' })).rejects.toThrow('boom') + }) +}) + +describe('destroy', () => { + it('treats a missing sandbox as destroyed', async () => { + const { client, provider } = setup() + client.terminate.mockRejectedValue(new ClientError('gone', 404)) + await expect(provider.destroy({ id: 'sbx-1' })).resolves.toBeUndefined() + }) +}) From adca89b83855a172f490222b24042e92d9f7294c Mon Sep 17 00:00:00 2001 From: R4ph-t Date: Wed, 28 Oct 2026 18:14:36 +0100 Subject: [PATCH 03/26] test(ai-sandbox-render): add live tests and journal conformance Gated on RENDER_API_KEY and RENDER_WORKSPACE_ID; without them the suites render as named skips. --- .../tests/journal.conformance.test.ts | 32 ++++++ .../ai-sandbox-render/tests/render.test.ts | 103 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 packages/ai-sandbox-render/tests/journal.conformance.test.ts create mode 100644 packages/ai-sandbox-render/tests/render.test.ts diff --git a/packages/ai-sandbox-render/tests/journal.conformance.test.ts b/packages/ai-sandbox-render/tests/journal.conformance.test.ts new file mode 100644 index 0000000000..bca5ae985f --- /dev/null +++ b/packages/ai-sandbox-render/tests/journal.conformance.test.ts @@ -0,0 +1,32 @@ +/** + * Journal conformance for the Render provider. + * + * Registering the shared suite makes the process-lifecycle claims falsifiable: + * with Render credentials it runs against a real sandbox, and without them it + * renders as a NAMED skip carrying the reason instead of a silent pass. + * + * `killableProcesses` is `true`, so the follow and kill cases run in full: the + * kill case starts a backgrounded grandchild and proves it stops writing after + * `kill()`, which is the measurement behind the capability. + */ +import { runJournalConformance } from '@tanstack/ai-sandbox/testkit' +import { renderSandbox } from '../src/index' + +// Auto-gate: these cases create real, billed Render sandboxes. +const hasCreds = + !!process.env.RENDER_API_KEY && !!process.env.RENDER_WORKSPACE_ID + +runJournalConformance({ + name: 'render', + createHandle: async () => { + const handle = await renderSandbox({ timeoutSeconds: 1800 }).create({}) + return { handle, dispose: () => handle.destroy() } + }, + ...(hasCreds + ? {} + : { + unsupported: { + reason: 'no RENDER_API_KEY + RENDER_WORKSPACE_ID in the environment', + }, + }), +}) diff --git a/packages/ai-sandbox-render/tests/render.test.ts b/packages/ai-sandbox-render/tests/render.test.ts new file mode 100644 index 0000000000..15eb69d6c7 --- /dev/null +++ b/packages/ai-sandbox-render/tests/render.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { renderSandbox } from '../src/index' +import type { SandboxHandle } from '@tanstack/ai-sandbox' + +// Auto-gate: only run with Render credentials (these tests create real, +// billed sandboxes). The SDK reads both from the environment itself. +const hasCreds = + !!process.env.RENDER_API_KEY && !!process.env.RENDER_WORKSPACE_ID + +const provider = renderSandbox({ timeoutSeconds: 1800 }) + +/** Run `body` against a fresh sandbox and always terminate it. */ +async function withSandbox( + input: Parameters[0], + body: (sbx: SandboxHandle) => Promise, +): Promise { + const sbx = await provider.create(input) + try { + await body(sbx) + } finally { + await sbx.destroy() + } +} + +describe.skipIf(!hasCreds)( + 'render provider (gated on RENDER_API_KEY + RENDER_WORKSPACE_ID)', + () => { + it('runs exec, env and a full fs round trip', async () => { + await withSandbox({ env: { CREATE_VAR: 'created' } }, async (sbx) => { + const echo = await sbx.process.exec('pwd') + expect(echo).toMatchObject({ stdout: '/workspace\n', exitCode: 0 }) + + const failed = await sbx.process.exec('echo nope >&2; exit 7') + expect(failed).toMatchObject({ stderr: 'nope\n', exitCode: 7 }) + + await sbx.env.set({ BASE_VAR: 'base' }) + const env = await sbx.process.exec( + 'echo "$CREATE_VAR:$BASE_VAR:$EXTRA_VAR"', + { env: { EXTRA_VAR: "it's" } }, + ) + expect(env.stdout).toBe("created:base:it's\n") + + await sbx.fs.write('/workspace/nested/note.txt', 'inside render') + expect(await sbx.fs.read('nested/note.txt')).toBe('inside render') + expect(await sbx.fs.lstat!('nested/note.txt')).toMatchObject({ + type: 'file', + size: 13, + }) + await sbx.process.exec('ln -s nested/note.txt link') + expect(await sbx.fs.lstat!('link')).toMatchObject({ type: 'symlink' }) + expect(await sbx.fs.lstat!('absent')).toBeUndefined() + + await sbx.fs.write('bin', new Uint8Array([0, 1, 2, 250])) + expect([...(await sbx.fs.readBytes('bin'))]).toEqual([0, 1, 2, 250]) + expect(await sbx.fs.list('/workspace')).toEqual( + expect.arrayContaining([ + { name: 'nested', path: '/workspace/nested', type: 'dir' }, + { name: 'bin', path: '/workspace/bin', type: 'file' }, + ]), + ) + await expect(sbx.fs.read('nested')).rejects.toThrow(/directory/) + await sbx.fs.rename('bin', 'bin2') + expect(await sbx.fs.exists('bin')).toBe(false) + await sbx.fs.remove('/workspace/nested') + expect(await sbx.fs.exists('/workspace/nested')).toBe(false) + + const git = await sbx.process.exec('git --version') + expect(git.exitCode).toBe(0) + }) + }, 120_000) + + it('snapshots, then restores with env reapplied and egress denied', async () => { + await withSandbox({}, async (sbx) => { + await sbx.fs.write('/workspace/kept.txt', 'kept') + const ref = await sbx.snapshot!('live-test') + const restored = await provider.restoreSnapshot!({ + snapshotId: ref.id, + env: { AFTER: 'restore' }, + policy: { capabilities: { network: 'deny' } }, + }) + try { + expect(await restored.fs.read('/workspace/kept.txt')).toBe('kept') + const r = await restored.process.exec( + 'echo $AFTER; curl -sS -m 5 https://example.com >/dev/null; echo curl=$?', + ) + expect(r.stdout).toContain('restore') + expect(r.stdout).not.toContain('curl=0') + } finally { + await restored.destroy() + } + }) + }, 180_000) + + it('resumes a live sandbox and returns null once it is gone', async () => { + const sbx = await provider.create({}) + const resumed = await provider.resume({ id: sbx.id }) + expect((await resumed!.process.exec('echo back')).stdout).toBe('back\n') + await provider.destroy({ id: sbx.id }) + expect(await provider.resume({ id: sbx.id })).toBeNull() + await provider.destroy({ id: sbx.id }) + }, 120_000) + }, +) From efe172862a457ed7fb68f702473e8d83cb2f090a Mon Sep 17 00:00:00 2001 From: R4ph-t Date: Wed, 28 Oct 2026 18:24:03 +0100 Subject: [PATCH 04/26] docs(sandbox): document the Render provider Adds Render to the providers table, the constructor list and the killableProcesses table, a Render section in providers-advanced, and a row in the ai-sandbox README. --- docs/sandbox/providers-advanced.md | 41 +++++++++++++++++++++++++++++- docs/sandbox/providers.md | 8 ++++-- packages/ai-sandbox/README.md | 1 + 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/docs/sandbox/providers-advanced.md b/docs/sandbox/providers-advanced.md index 68479eeb81..998353eb35 100644 --- a/docs/sandbox/providers-advanced.md +++ b/docs/sandbox/providers-advanced.md @@ -380,10 +380,48 @@ export function provider(env: SandboxAgentEnv) { The agent coordinator, durable runs, live previews through a quick tunnel, callback hosts, and the Durable Object run log are on [Cloudflare](./cloudflare) and [Cloudflare Edge Details](./cloudflare-advanced). +## Render + +```ts +import { renderSandbox } from '@tanstack/ai-sandbox-render' + +const render = renderSandbox({ + plan: '2c-4g', + idleTimeoutSeconds: 900, + // Optional: when the policy allows network, allow only these hosts. + networkPolicy: { + type: 'allow-list', + rules: [{ domain: 'registry.npmjs.org' }, { domain: 'api.anthropic.com' }], + }, +}) +``` + +- **Isolation:** a managed [Render](https://render.com/docs/sandboxes) sandbox. It boots from the base image (Debian 12, `root` user), a custom image (`buildName`), or a named snapshot (`snapshotName`). +- **Auth:** the SDK reads `RENDER_API_KEY` and `RENDER_WORKSPACE_ID`. Pass `token` and `ownerId` to set them in code. Harness credentials are [workspace secrets](./provisioning), and each command gets them as env. +- **Lifetime:** Render suspends an idle sandbox after `idleTimeoutSeconds` (default 900) and keeps its disk, so `durableFilesystem` is `true`. The next command or file call resumes it. After `timeoutSeconds` (default 86400), Render terminates it. +- **Network:** `policy.capabilities.network: 'deny'` sends `deny-all`, and Render enforces it. Otherwise the provider sends `networkPolicy`, or allows all egress. +- **Not available:** inbound ports, stdin for a spawned process, and fork. `ports.connect` throws. Harnesses that need a port or stdin, such as OpenCode and ACP harnesses, do not run on Render. + +Snapshots and resume on Render: + +- `snapshot(label)` captures a `filesystem` snapshot named `tsai-