feat: enhance desktop computer-use streaming with neko integration

Improve desktop streaming architecture, add inspector dev tooling,
React DesktopViewer updates, and computer-use documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-16 23:59:43 -07:00
parent 4252c705df
commit 2d8508d6e2
17 changed files with 2712 additions and 688 deletions

View file

@ -0,0 +1,7 @@
FROM node:22-bookworm-slim
RUN npm install -g pnpm@10.28.2
WORKDIR /app
CMD ["bash", "-lc", "pnpm install --filter @sandbox-agent/inspector... && cd frontend/packages/inspector && exec pnpm vite --host 0.0.0.0 --port 5173"]

View file

@ -6,6 +6,7 @@ COPY server/ ./server/
COPY gigacode/ ./gigacode/
COPY resources/agent-schemas/artifacts/ ./resources/agent-schemas/artifacts/
COPY scripts/agent-configs/ ./scripts/agent-configs/
COPY scripts/audit-acp-deps/ ./scripts/audit-acp-deps/
ENV SANDBOX_AGENT_SKIP_INSPECTOR=1
@ -15,6 +16,12 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
cargo build -p sandbox-agent --release && \
cp target/release/sandbox-agent /sandbox-agent
# Extract neko binary from the official image for WebRTC desktop streaming.
# Using neko v3 base image from GHCR which provides multi-arch support (amd64, arm64).
# Pinned by digest to prevent breaking changes from upstream.
# Reference client: https://github.com/demodesk/neko-client/blob/37f93eae6bd55b333c94bd009d7f2b079075a026/src/component/internal/webrtc.ts
FROM ghcr.io/m1k1o/neko/base@sha256:0c384afa56268aaa2d5570211d284763d0840dcdd1a7d9a24be3081d94d3dfce AS neko-base
FROM node:22-bookworm-slim
RUN apt-get update -qq && \
apt-get install -y -qq --no-install-recommends \
@ -26,6 +33,15 @@ RUN apt-get update -qq && \
xdotool \
imagemagick \
ffmpeg \
gstreamer1.0-tools \
gstreamer1.0-plugins-base \
gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad \
gstreamer1.0-plugins-ugly \
gstreamer1.0-nice \
gstreamer1.0-x \
gstreamer1.0-pulseaudio \
libxcvt0 \
x11-xserver-utils \
dbus-x11 \
xauth \
@ -35,8 +51,11 @@ RUN apt-get update -qq && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent
COPY --from=neko-base /usr/bin/neko /usr/local/bin/neko
EXPOSE 3000
# Expose UDP port range for WebRTC media transport
EXPOSE 59050-59070/udp
ENTRYPOINT ["/usr/local/bin/sandbox-agent"]
CMD ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"]

560
docs/common-software.mdx Normal file
View file

@ -0,0 +1,560 @@
---
title: "Common Software"
description: "Install browsers, languages, databases, and other tools inside the sandbox."
sidebarTitle: "Common Software"
icon: "box-open"
---
The sandbox runs a Debian/Ubuntu base image. You can install software with `apt-get` via the [Process API](/processes) or by customizing your Docker image. This page covers commonly needed packages and how to install them.
## Browsers
### Chromium
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "chromium", "chromium-sandbox"],
});
// Launch headless
await sdk.runProcess({
command: "chromium",
args: ["--headless", "--no-sandbox", "--disable-gpu", "https://example.com"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","chromium","chromium-sandbox"]}'
```
</CodeGroup>
<Note>
Use `--no-sandbox` when running Chromium inside a container. The container itself provides isolation.
</Note>
### Firefox
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "firefox-esr"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","firefox-esr"]}'
```
</CodeGroup>
### Playwright browsers
Playwright bundles its own browser binaries. Install the Playwright CLI and let it download browsers for you.
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "npx",
args: ["playwright", "install", "--with-deps", "chromium"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"npx","args":["playwright","install","--with-deps","chromium"]}'
```
</CodeGroup>
---
## Languages and runtimes
### Node.js
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "nodejs", "npm"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","nodejs","npm"]}'
```
</CodeGroup>
For a specific version, use [nvm](https://github.com/nvm-sh/nvm):
```ts TypeScript
await sdk.runProcess({
command: "bash",
args: ["-c", "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash && . ~/.nvm/nvm.sh && nvm install 22"],
});
```
### Python
Python 3 is typically pre-installed. To add pip and common packages:
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "python3", "python3-pip", "python3-venv"],
});
await sdk.runProcess({
command: "pip3",
args: ["install", "numpy", "pandas", "matplotlib"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","python3","python3-pip","python3-venv"]}'
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"pip3","args":["install","numpy","pandas","matplotlib"]}'
```
</CodeGroup>
### Go
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "bash",
args: ["-c", "curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz"],
});
// Add to PATH for subsequent commands
await sdk.runProcess({
command: "bash",
args: ["-c", "export PATH=$PATH:/usr/local/go/bin && go version"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"bash","args":["-c","curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz"]}'
```
</CodeGroup>
### Rust
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "bash",
args: ["-c", "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"bash","args":["-c","curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"]}'
```
</CodeGroup>
### Java (OpenJDK)
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "default-jdk"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","default-jdk"]}'
```
</CodeGroup>
### Ruby
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "ruby-full"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","ruby-full"]}'
```
</CodeGroup>
---
## Databases
### PostgreSQL
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "postgresql", "postgresql-client"],
});
// Start the service
const proc = await sdk.createProcess({
command: "bash",
args: ["-c", "su - postgres -c 'pg_ctlcluster 15 main start'"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","postgresql","postgresql-client"]}'
```
</CodeGroup>
### SQLite
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "sqlite3"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","sqlite3"]}'
```
</CodeGroup>
### Redis
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "redis-server"],
});
const proc = await sdk.createProcess({
command: "redis-server",
args: ["--daemonize", "no"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","redis-server"]}'
curl -X POST "http://127.0.0.1:2468/v1/processes" \
-H "Content-Type: application/json" \
-d '{"command":"redis-server","args":["--daemonize","no"]}'
```
</CodeGroup>
### MySQL / MariaDB
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "mariadb-server", "mariadb-client"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","mariadb-server","mariadb-client"]}'
```
</CodeGroup>
---
## Build tools
### Essential build toolchain
Most compiled software needs the standard build toolchain:
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "build-essential", "cmake", "pkg-config"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","build-essential","cmake","pkg-config"]}'
```
</CodeGroup>
This installs `gcc`, `g++`, `make`, `cmake`, and related tools.
---
## Desktop applications
These require the [Computer Use](/computer-use) desktop to be started first.
### LibreOffice
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "libreoffice"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","libreoffice"]}'
```
</CodeGroup>
### GIMP
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "gimp"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","gimp"]}'
```
</CodeGroup>
### VLC
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "vlc"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","vlc"]}'
```
</CodeGroup>
### VS Code (code-server)
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "bash",
args: ["-c", "curl -fsSL https://code-server.dev/install.sh | sh"],
});
const proc = await sdk.createProcess({
command: "code-server",
args: ["--bind-addr", "0.0.0.0:8080", "--auth", "none"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"bash","args":["-c","curl -fsSL https://code-server.dev/install.sh | sh"]}'
curl -X POST "http://127.0.0.1:2468/v1/processes" \
-H "Content-Type: application/json" \
-d '{"command":"code-server","args":["--bind-addr","0.0.0.0:8080","--auth","none"]}'
```
</CodeGroup>
---
## CLI tools
### Git
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "git"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","git"]}'
```
</CodeGroup>
### Docker
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "bash",
args: ["-c", "curl -fsSL https://get.docker.com | sh"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"bash","args":["-c","curl -fsSL https://get.docker.com | sh"]}'
```
</CodeGroup>
### jq
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "jq"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","jq"]}'
```
</CodeGroup>
### tmux
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "tmux"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","tmux"]}'
```
</CodeGroup>
---
## Media and graphics
### FFmpeg
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "ffmpeg"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","ffmpeg"]}'
```
</CodeGroup>
### ImageMagick
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "imagemagick"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","imagemagick"]}'
```
</CodeGroup>
### Poppler (PDF utilities)
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "poppler-utils"],
});
// Convert PDF to images
await sdk.runProcess({
command: "pdftoppm",
args: ["-png", "document.pdf", "output"],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","poppler-utils"]}'
```
</CodeGroup>
---
## Pre-installing in a Docker image
For production use, install software in your Dockerfile instead of at runtime. This avoids repeated downloads and makes startup faster.
```dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
chromium \
firefox-esr \
nodejs npm \
python3 python3-pip \
git curl wget \
build-essential \
sqlite3 \
ffmpeg \
imagemagick \
jq \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install numpy pandas matplotlib
```
See [Docker deployment](/deploy/docker) for how to use custom images with Sandbox Agent.

553
docs/computer-use.mdx Normal file
View file

@ -0,0 +1,553 @@
---
title: "Computer Use"
description: "Control a virtual desktop inside the sandbox with mouse, keyboard, screenshots, recordings, and live streaming."
sidebarTitle: "Computer Use"
icon: "desktop"
---
Sandbox Agent provides a managed virtual desktop (Xvfb + openbox) that you can control programmatically. This is useful for browser automation, GUI testing, and AI computer-use workflows.
## Start and stop
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const sdk = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
});
const status = await sdk.startDesktop({
width: 1920,
height: 1080,
dpi: 96,
});
console.log(status.state); // "active"
console.log(status.display); // ":99"
// When done
await sdk.stopDesktop();
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/desktop/start" \
-H "Content-Type: application/json" \
-d '{"width":1920,"height":1080,"dpi":96}'
curl -X POST "http://127.0.0.1:2468/v1/desktop/stop"
```
</CodeGroup>
All fields in the start request are optional. Defaults are 1440x900 at 96 DPI.
## Status
<CodeGroup>
```ts TypeScript
const status = await sdk.getDesktopStatus();
console.log(status.state); // "inactive" | "active" | "failed" | ...
```
```bash cURL
curl "http://127.0.0.1:2468/v1/desktop/status"
```
</CodeGroup>
## Screenshots
Capture the full desktop or a specific region.
<CodeGroup>
```ts TypeScript
// Full screenshot (PNG by default)
const png = await sdk.takeDesktopScreenshot();
// JPEG at 70% quality, half scale
const jpeg = await sdk.takeDesktopScreenshot({
format: "jpeg",
quality: 70,
scale: 0.5,
});
// Region screenshot
const region = await sdk.takeDesktopRegionScreenshot({
x: 100,
y: 100,
width: 400,
height: 300,
});
```
```bash cURL
curl "http://127.0.0.1:2468/v1/desktop/screenshot" --output screenshot.png
curl "http://127.0.0.1:2468/v1/desktop/screenshot?format=jpeg&quality=70&scale=0.5" \
--output screenshot.jpg
curl "http://127.0.0.1:2468/v1/desktop/screenshot/region?x=100&y=100&width=400&height=300" \
--output region.png
```
</CodeGroup>
## Mouse
<CodeGroup>
```ts TypeScript
// Get current position
const pos = await sdk.getDesktopMousePosition();
console.log(pos.x, pos.y);
// Move
await sdk.moveDesktopMouse({ x: 500, y: 300 });
// Click (left by default)
await sdk.clickDesktop({ x: 500, y: 300 });
// Right click
await sdk.clickDesktop({ x: 500, y: 300, button: "right" });
// Double click
await sdk.clickDesktop({ x: 500, y: 300, clickCount: 2 });
// Drag
await sdk.dragDesktopMouse({
startX: 100, startY: 100,
endX: 400, endY: 400,
});
// Scroll
await sdk.scrollDesktop({ x: 500, y: 300, deltaY: -3 });
```
```bash cURL
curl "http://127.0.0.1:2468/v1/desktop/mouse/position"
curl -X POST "http://127.0.0.1:2468/v1/desktop/mouse/click" \
-H "Content-Type: application/json" \
-d '{"x":500,"y":300}'
curl -X POST "http://127.0.0.1:2468/v1/desktop/mouse/drag" \
-H "Content-Type: application/json" \
-d '{"startX":100,"startY":100,"endX":400,"endY":400}'
curl -X POST "http://127.0.0.1:2468/v1/desktop/mouse/scroll" \
-H "Content-Type: application/json" \
-d '{"x":500,"y":300,"deltaY":-3}'
```
</CodeGroup>
## Keyboard
<CodeGroup>
```ts TypeScript
// Type text
await sdk.typeDesktopText({ text: "Hello, world!" });
// Press a key with modifiers
await sdk.pressDesktopKey({
key: "c",
modifiers: { ctrl: true },
});
// Low-level key down/up
await sdk.keyDownDesktop({ key: "Shift_L" });
await sdk.keyUpDesktop({ key: "Shift_L" });
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/desktop/keyboard/type" \
-H "Content-Type: application/json" \
-d '{"text":"Hello, world!"}'
curl -X POST "http://127.0.0.1:2468/v1/desktop/keyboard/press" \
-H "Content-Type: application/json" \
-d '{"key":"c","modifiers":{"ctrl":true}}'
```
</CodeGroup>
## Display and windows
<CodeGroup>
```ts TypeScript
const display = await sdk.getDesktopDisplayInfo();
console.log(display.resolution); // { width: 1920, height: 1080, dpi: 96 }
const { windows } = await sdk.listDesktopWindows();
for (const win of windows) {
console.log(win.title, win.x, win.y, win.width, win.height);
}
```
```bash cURL
curl "http://127.0.0.1:2468/v1/desktop/display/info"
curl "http://127.0.0.1:2468/v1/desktop/windows"
```
</CodeGroup>
## Recording
Record the desktop to MP4.
<CodeGroup>
```ts TypeScript
const recording = await sdk.startDesktopRecording({ fps: 30 });
console.log(recording.id);
// ... do things ...
const stopped = await sdk.stopDesktopRecording();
// List all recordings
const { recordings } = await sdk.listDesktopRecordings();
// Download
const mp4 = await sdk.downloadDesktopRecording(recording.id);
// Clean up
await sdk.deleteDesktopRecording(recording.id);
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/desktop/recording/start" \
-H "Content-Type: application/json" \
-d '{"fps":30}'
curl -X POST "http://127.0.0.1:2468/v1/desktop/recording/stop"
curl "http://127.0.0.1:2468/v1/desktop/recordings"
curl "http://127.0.0.1:2468/v1/desktop/recordings/rec_1/download" --output recording.mp4
curl -X DELETE "http://127.0.0.1:2468/v1/desktop/recordings/rec_1"
```
</CodeGroup>
## Desktop processes
The desktop runtime manages several background processes (Xvfb, openbox, neko, ffmpeg). These are all registered with the general [Process API](/processes) under the `desktop` owner, so you can inspect logs, check status, and troubleshoot using the same tools you use for any other managed process.
<CodeGroup>
```ts TypeScript
// List all processes, including desktop-owned ones
const { processes } = await sdk.listProcesses();
const desktopProcs = processes.filter((p) => p.owner === "desktop");
for (const p of desktopProcs) {
console.log(p.id, p.command, p.status);
}
// Read logs from a specific desktop process
const logs = await sdk.getProcessLogs(desktopProcs[0].id, { tail: 50 });
for (const entry of logs.entries) {
console.log(entry.stream, atob(entry.data));
}
```
```bash cURL
# List all processes (desktop processes have owner: "desktop")
curl "http://127.0.0.1:2468/v1/processes"
# Get logs from a specific desktop process
curl "http://127.0.0.1:2468/v1/processes/proc_1/logs?tail=50"
```
</CodeGroup>
The desktop status endpoint also includes a summary of running processes:
<CodeGroup>
```ts TypeScript
const status = await sdk.getDesktopStatus();
for (const proc of status.processes) {
console.log(proc.name, proc.pid, proc.running);
}
```
```bash cURL
curl "http://127.0.0.1:2468/v1/desktop/status"
# Response includes: processes: [{ name: "Xvfb", pid: 123, running: true }, ...]
```
</CodeGroup>
| Process | Role | Restart policy |
|---------|------|---------------|
| Xvfb | Virtual X11 framebuffer | Auto-restart while desktop is active |
| openbox | Window manager | Auto-restart while desktop is active |
| neko | WebRTC streaming server (started by `startDesktopStream`) | No auto-restart |
| ffmpeg | Screen recorder (started by `startDesktopRecording`) | No auto-restart |
## Live streaming
Start a WebRTC stream for real-time desktop viewing in a browser.
<CodeGroup>
```ts TypeScript
await sdk.startDesktopStream();
// Connect via the React DesktopViewer component or
// use the WebSocket signaling endpoint directly
// at ws://127.0.0.1:2468/v1/desktop/stream/signaling
await sdk.stopDesktopStream();
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/desktop/stream/start"
# Connect to ws://127.0.0.1:2468/v1/desktop/stream/signaling for WebRTC signaling
curl -X POST "http://127.0.0.1:2468/v1/desktop/stream/stop"
```
</CodeGroup>
For a drop-in React component, see [React Components](/react-components).
## Customizing the desktop environment
The desktop runs inside the sandbox filesystem, so you can customize it using the [File System](/file-system) API before or after starting the desktop. The desktop HOME directory is located at `~/.local/state/sandbox-agent/desktop/home` (or `$XDG_STATE_HOME/sandbox-agent/desktop/home` if `XDG_STATE_HOME` is set).
All configuration files below are written to paths relative to this HOME directory.
### Window manager (openbox)
The desktop uses [openbox](http://openbox.org/) as its window manager. You can customize its behavior, theme, and keyboard shortcuts by writing an `rc.xml` config file.
<CodeGroup>
```ts TypeScript
const openboxConfig = `<?xml version="1.0" encoding="UTF-8"?>
<openbox_config xmlns="http://openbox.org/3.4/rc">
<theme>
<name>Clearlooks</name>
<titleLayout>NLIMC</titleLayout>
<font place="ActiveWindow"><name>DejaVu Sans</name><size>10</size></font>
</theme>
<desktops><number>1</number></desktops>
<keyboard>
<keybind key="A-F4"><action name="Close"/></keybind>
<keybind key="A-Tab"><action name="NextWindow"/></keybind>
</keyboard>
</openbox_config>`;
await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox" });
await sdk.writeFsFile(
{ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox/rc.xml" },
openboxConfig,
);
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox"
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox/rc.xml" \
-H "Content-Type: application/octet-stream" \
--data-binary @rc.xml
```
</CodeGroup>
### Autostart programs
Openbox runs scripts in `~/.config/openbox/autostart` on startup. Use this to launch applications, set the background, or configure the environment.
<CodeGroup>
```ts TypeScript
const autostart = `#!/bin/sh
# Set a solid background color
xsetroot -solid "#1e1e2e" &
# Launch a terminal
xterm -geometry 120x40+50+50 &
# Launch a browser
firefox --no-remote &
`;
await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox" });
await sdk.writeFsFile(
{ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" },
autostart,
);
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox"
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" \
-H "Content-Type: application/octet-stream" \
--data-binary @autostart.sh
```
</CodeGroup>
<Note>
The autostart script runs when openbox starts, which happens during `startDesktop()`. Write the autostart file before calling `startDesktop()` for it to take effect.
</Note>
### Background
There is no wallpaper set by default (the background is the X root window default). You can set it using `xsetroot` in the autostart script (as shown above), or use `feh` if you need an image:
<CodeGroup>
```ts TypeScript
// Upload a wallpaper image
import fs from "node:fs";
const wallpaper = await fs.promises.readFile("./wallpaper.png");
await sdk.writeFsFile(
{ path: "~/.local/state/sandbox-agent/desktop/home/wallpaper.png" },
wallpaper,
);
// Set the autostart to apply it
const autostart = `#!/bin/sh
feh --bg-fill ~/wallpaper.png &
`;
await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox" });
await sdk.writeFsFile(
{ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" },
autostart,
);
```
```bash cURL
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/wallpaper.png" \
-H "Content-Type: application/octet-stream" \
--data-binary @wallpaper.png
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" \
-H "Content-Type: application/octet-stream" \
--data-binary @autostart.sh
```
</CodeGroup>
<Note>
`feh` is not installed by default. Install it via the [Process API](/processes) before starting the desktop: `await sdk.runProcess({ command: "apt-get", args: ["install", "-y", "feh"] })`.
</Note>
### Fonts
Only `fonts-dejavu-core` is installed by default. To add more fonts, install them with your system package manager or copy font files into the sandbox:
<CodeGroup>
```ts TypeScript
// Install a font package
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "fonts-noto", "fonts-liberation"],
});
// Or copy a custom font file
import fs from "node:fs";
const font = await fs.promises.readFile("./CustomFont.ttf");
await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.local/share/fonts" });
await sdk.writeFsFile(
{ path: "~/.local/state/sandbox-agent/desktop/home/.local/share/fonts/CustomFont.ttf" },
font,
);
// Rebuild the font cache
await sdk.runProcess({ command: "fc-cache", args: ["-fv"] });
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","fonts-noto","fonts-liberation"]}'
curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.local/share/fonts"
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.local/share/fonts/CustomFont.ttf" \
-H "Content-Type: application/octet-stream" \
--data-binary @CustomFont.ttf
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"fc-cache","args":["-fv"]}'
```
</CodeGroup>
### Cursor theme
<CodeGroup>
```ts TypeScript
await sdk.runProcess({
command: "apt-get",
args: ["install", "-y", "dmz-cursor-theme"],
});
const xresources = `Xcursor.theme: DMZ-White\nXcursor.size: 24\n`;
await sdk.writeFsFile(
{ path: "~/.local/state/sandbox-agent/desktop/home/.Xresources" },
xresources,
);
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"apt-get","args":["install","-y","dmz-cursor-theme"]}'
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.Xresources" \
-H "Content-Type: application/octet-stream" \
--data-binary 'Xcursor.theme: DMZ-White\nXcursor.size: 24'
```
</CodeGroup>
<Note>
Run `xrdb -merge ~/.Xresources` (via the autostart or process API) after writing the file for changes to take effect.
</Note>
### Shell and terminal
No terminal emulator or shell is launched by default. Add one to the openbox autostart:
```sh
# In ~/.config/openbox/autostart
xterm -geometry 120x40+50+50 &
```
To use a different shell, set the `SHELL` environment variable in your Dockerfile or install your preferred shell and configure the terminal to use it.
### GTK theme
Applications using GTK will pick up settings from `~/.config/gtk-3.0/settings.ini`:
<CodeGroup>
```ts TypeScript
const gtkSettings = `[Settings]
gtk-theme-name=Adwaita
gtk-icon-theme-name=Adwaita
gtk-font-name=DejaVu Sans 10
gtk-cursor-theme-name=DMZ-White
gtk-cursor-theme-size=24
`;
await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0" });
await sdk.writeFsFile(
{ path: "~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0/settings.ini" },
gtkSettings,
);
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0"
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0/settings.ini" \
-H "Content-Type: application/octet-stream" \
--data-binary @settings.ini
```
</CodeGroup>
### Summary of configuration paths
All paths are relative to the desktop HOME directory (`~/.local/state/sandbox-agent/desktop/home`).
| What | Path | Notes |
|------|------|-------|
| Openbox config | `.config/openbox/rc.xml` | Window manager theme, keybindings, behavior |
| Autostart | `.config/openbox/autostart` | Shell script run on desktop start |
| Custom fonts | `.local/share/fonts/` | TTF/OTF files, run `fc-cache -fv` after |
| Cursor theme | `.Xresources` | Requires `xrdb -merge` to apply |
| GTK 3 settings | `.config/gtk-3.0/settings.ini` | Theme, icons, fonts for GTK apps |
| Wallpaper | Any path, referenced from autostart | Requires `feh` or similar tool |

View file

@ -87,7 +87,7 @@
},
{
"group": "System",
"pages": ["file-system", "processes"]
"pages": ["file-system", "processes", "computer-use", "common-software"]
},
{
"group": "Orchestration",

View file

@ -1,33 +1,43 @@
import { Loader2, Monitor, Play, RefreshCw, Square, Camera } from "lucide-react";
import { Camera, Circle, Download, Loader2, Monitor, Play, RefreshCw, Square, Trash2, Video } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { SandboxAgentError } from "sandbox-agent";
import type { DesktopStatusResponse, SandboxAgent } from "sandbox-agent";
import type { DesktopRecordingInfo, DesktopStatusResponse, SandboxAgent } from "sandbox-agent";
import { DesktopViewer } from "@sandbox-agent/react";
import type { DesktopViewerClient } from "@sandbox-agent/react";
const MIN_SPIN_MS = 350;
const extractErrorMessage = (error: unknown, fallback: string): string => {
if (error instanceof SandboxAgentError && error.problem?.detail) return error.problem.detail;
if (error instanceof Error) return error.message;
return fallback;
};
const formatStartedAt = (value: string | null | undefined): string => {
if (!value) {
return "Not started";
}
if (!value) return "Not started";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / 1024 ** i).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
};
const formatDuration = (start: string, end?: string | null): string => {
const startMs = new Date(start).getTime();
const endMs = end ? new Date(end).getTime() : Date.now();
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return "Unknown";
const seconds = Math.round((endMs - startMs) / 1000);
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}m ${secs}s`;
};
const createScreenshotUrl = async (bytes: Uint8Array): Promise<string> => {
const payload = new Uint8Array(bytes.byteLength);
payload.set(bytes);
const blob = new Blob([payload.buffer], { type: "image/png" });
if (typeof URL.createObjectURL === "function") {
return URL.createObjectURL(blob);
}
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("Unable to read screenshot blob."));
@ -41,22 +51,42 @@ const createScreenshotUrl = async (bytes: Uint8Array): Promise<string> => {
reader.readAsDataURL(blob);
});
};
const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
const [status, setStatus] = useState<DesktopStatusResponse | null>(null);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [acting, setActing] = useState<"start" | "stop" | null>(null);
const [error, setError] = useState<string | null>(null);
const [width, setWidth] = useState("1440");
const [height, setHeight] = useState("900");
const [dpi, setDpi] = useState("96");
// Screenshot fallback
const [screenshotUrl, setScreenshotUrl] = useState<string | null>(null);
const [screenshotLoading, setScreenshotLoading] = useState(false);
const [screenshotError, setScreenshotError] = useState<string | null>(null);
// Live view
const [liveViewActive, setLiveViewActive] = useState(false);
const [liveViewError, setLiveViewError] = useState<string | null>(null);
// Memoize the client as a DesktopViewerClient so the reference is stable
// across renders and doesn't cause the DesktopViewer effect to re-fire.
const viewerClient = useMemo<DesktopViewerClient>(() => {
const c = getClient();
return {
startDesktopStream: () => c.startDesktopStream(),
stopDesktopStream: () => c.stopDesktopStream(),
connectDesktopStream: (opts?: Parameters<SandboxAgent["connectDesktopStream"]>[0]) => c.connectDesktopStream(opts),
};
}, [getClient]);
// Recording
const [recordings, setRecordings] = useState<DesktopRecordingInfo[]>([]);
const [recordingLoading, setRecordingLoading] = useState(false);
const [recordingActing, setRecordingActing] = useState<"start" | "stop" | null>(null);
const [recordingError, setRecordingError] = useState<string | null>(null);
const [recordingFps, setRecordingFps] = useState("30");
const [deletingRecordingId, setDeletingRecordingId] = useState<string | null>(null);
const [downloadingRecordingId, setDownloadingRecordingId] = useState<string | null>(null);
// Active recording tracking
const activeRecording = useMemo(() => recordings.find((r) => r.status === "recording"), [recordings]);
const revokeScreenshotUrl = useCallback(() => {
setScreenshotUrl((current) => {
if (current?.startsWith("blob:") && typeof URL.revokeObjectURL === "function") {
@ -65,14 +95,10 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
return null;
});
}, []);
const loadStatus = useCallback(
async (mode: "initial" | "refresh" = "initial") => {
if (mode === "initial") {
setLoading(true);
} else {
setRefreshing(true);
}
if (mode === "initial") setLoading(true);
else setRefreshing(true);
setError(null);
try {
const next = await getClient().getDesktopStatus();
@ -88,7 +114,6 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
},
[getClient],
);
const refreshScreenshot = useCallback(async () => {
setScreenshotLoading(true);
setScreenshotError(null);
@ -103,25 +128,38 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
setScreenshotLoading(false);
}
}, [getClient, revokeScreenshotUrl]);
const loadRecordings = useCallback(async () => {
setRecordingLoading(true);
setRecordingError(null);
try {
const result = await getClient().listDesktopRecordings();
setRecordings(result.recordings);
} catch (loadError) {
setRecordingError(extractErrorMessage(loadError, "Unable to load recordings."));
} finally {
setRecordingLoading(false);
}
}, [getClient]);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useEffect(() => {
if (status?.state === "active") {
void refreshScreenshot();
void loadRecordings();
} else {
revokeScreenshotUrl();
setLiveViewActive(false);
}
}, [refreshScreenshot, revokeScreenshotUrl, status?.state]);
}, [status?.state, loadRecordings, revokeScreenshotUrl]);
useEffect(() => {
return () => {
revokeScreenshotUrl();
};
return () => revokeScreenshotUrl();
}, [revokeScreenshotUrl]);
// Poll recording list while a recording is active
useEffect(() => {
if (!activeRecording) return;
const interval = setInterval(() => void loadRecordings(), 3000);
return () => clearInterval(interval);
}, [activeRecording, loadRecordings]);
const handleStart = async () => {
const parsedWidth = Number.parseInt(width, 10);
const parsedHeight = Number.parseInt(height, 10);
@ -136,9 +174,6 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
dpi: Number.isFinite(parsedDpi) ? parsedDpi : undefined,
});
setStatus(next);
if (next.state === "active") {
await refreshScreenshot();
}
} catch (startError) {
setError(extractErrorMessage(startError, "Unable to start desktop runtime."));
await loadStatus("refresh");
@ -150,7 +185,6 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
setActing(null);
}
};
const handleStop = async () => {
setActing("stop");
setError(null);
@ -159,6 +193,7 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
const next = await getClient().stopDesktop();
setStatus(next);
revokeScreenshotUrl();
setLiveViewActive(false);
} catch (stopError) {
setError(extractErrorMessage(stopError, "Unable to stop desktop runtime."));
await loadStatus("refresh");
@ -170,15 +205,71 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
setActing(null);
}
};
const handleStartRecording = async () => {
const fps = Number.parseInt(recordingFps, 10);
setRecordingActing("start");
setRecordingError(null);
try {
await getClient().startDesktopRecording({
fps: Number.isFinite(fps) && fps > 0 ? fps : undefined,
});
await loadRecordings();
} catch (err) {
setRecordingError(extractErrorMessage(err, "Unable to start recording."));
} finally {
setRecordingActing(null);
}
};
const handleStopRecording = async () => {
setRecordingActing("stop");
setRecordingError(null);
try {
await getClient().stopDesktopRecording();
await loadRecordings();
} catch (err) {
setRecordingError(extractErrorMessage(err, "Unable to stop recording."));
} finally {
setRecordingActing(null);
}
};
const handleDeleteRecording = async (id: string) => {
setDeletingRecordingId(id);
try {
await getClient().deleteDesktopRecording(id);
setRecordings((prev) => prev.filter((r) => r.id !== id));
} catch (err) {
setRecordingError(extractErrorMessage(err, "Unable to delete recording."));
} finally {
setDeletingRecordingId(null);
}
};
const handleDownloadRecording = async (id: string, fileName: string) => {
setDownloadingRecordingId(id);
try {
const bytes = await getClient().downloadDesktopRecording(id);
const blob = new Blob([bytes], { type: "video/mp4" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
setRecordingError(extractErrorMessage(err, "Unable to download recording."));
} finally {
setDownloadingRecordingId(null);
}
};
const canRefreshScreenshot = status?.state === "active";
const isActive = status?.state === "active";
const resolutionLabel = useMemo(() => {
const resolution = status?.resolution;
if (!resolution) return "Unknown";
const dpiLabel = resolution.dpi ? ` @ ${resolution.dpi} DPI` : "";
return `${resolution.width} x ${resolution.height}${dpiLabel}`;
}, [status?.resolution]);
return (
<div className="desktop-panel">
<div className="inline-row" style={{ marginBottom: 16 }}>
@ -186,15 +277,16 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
<RefreshCw className={`button-icon ${loading || refreshing ? "spinner-icon" : ""}`} />
Refresh Status
</button>
{isActive && !liveViewActive && (
<button className="button secondary small" onClick={() => void refreshScreenshot()} disabled={!canRefreshScreenshot || screenshotLoading}>
{screenshotLoading ? <Loader2 className="button-icon spinner-icon" /> : <Camera className="button-icon" />}
Refresh Screenshot
Screenshot
</button>
)}
</div>
{error && <div className="banner error">{error}</div>}
{screenshotError && <div className="banner error">{screenshotError}</div>}
{/* ========== Runtime Section ========== */}
<div className="card">
<div className="card-header">
<span className="card-title">
@ -209,7 +301,6 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
{status?.state ?? "unknown"}
</span>
</div>
<div className="desktop-state-grid">
<div>
<div className="card-meta">Display</div>
@ -224,7 +315,6 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
<div>{formatStartedAt(status?.startedAt)}</div>
</div>
</div>
<div className="desktop-start-controls">
<div className="desktop-input-group">
<label className="label">Width</label>
@ -239,19 +329,21 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
<input className="setup-input mono" value={dpi} onChange={(event) => setDpi(event.target.value)} inputMode="numeric" />
</div>
</div>
<div className="card-actions">
<button className="button success small" onClick={() => void handleStart()} disabled={acting === "start"}>
{acting === "start" ? <Loader2 className="button-icon spinner-icon" /> : <Play className="button-icon" />}
Start Desktop
</button>
{isActive ? (
<button className="button danger small" onClick={() => void handleStop()} disabled={acting === "stop"}>
{acting === "stop" ? <Loader2 className="button-icon spinner-icon" /> : <Square className="button-icon" />}
Stop Desktop
</button>
) : (
<button className="button success small" onClick={() => void handleStart()} disabled={acting === "start"}>
{acting === "start" ? <Loader2 className="button-icon spinner-icon" /> : <Play className="button-icon" />}
Start Desktop
</button>
)}
</div>
</div>
{/* ========== Missing Dependencies ========== */}
{status?.missingDependencies && status.missingDependencies.length > 0 && (
<div className="card">
<div className="card-header">
@ -274,7 +366,179 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
)}
</div>
)}
{/* ========== Live View Section ========== */}
<div className="card">
<div className="card-header">
<span className="card-title">
<Video size={14} style={{ marginRight: 6 }} />
Live View
</span>
{isActive && (
<button
className={`button small ${liveViewActive ? "danger" : "success"}`}
onClick={(e) => {
e.stopPropagation();
if (liveViewActive) {
// Stop: close viewer then stop the stream process
setLiveViewActive(false);
void getClient()
.stopDesktopStream()
.catch(() => undefined);
} else {
// Start stream first, then show viewer
void getClient()
.startDesktopStream()
.then(() => {
setLiveViewActive(true);
})
.catch(() => undefined);
}
}}
style={{ padding: "4px 10px", fontSize: 11 }}
>
{liveViewActive ? (
<>
<Square size={12} style={{ marginRight: 4 }} />
Stop Stream
</>
) : (
<>
<Play size={12} style={{ marginRight: 4 }} />
Start Stream
</>
)}
</button>
)}
</div>
{liveViewError && (
<div className="banner error" style={{ marginBottom: 8 }}>
{liveViewError}
</div>
)}
{!isActive && <div className="desktop-screenshot-empty">Start the desktop runtime to enable live view.</div>}
{isActive && liveViewActive && <DesktopViewer client={viewerClient} autoStart={true} showStatusBar={true} />}
{isActive && !liveViewActive && (
<>
{screenshotUrl ? (
<div className="desktop-screenshot-frame">
<img src={screenshotUrl} alt="Desktop screenshot" className="desktop-screenshot-image" />
</div>
) : (
<div className="desktop-screenshot-empty">Click "Start Stream" for live desktop view, or use the Screenshot button above.</div>
)}
</>
)}
</div>
{/* ========== Recording Section ========== */}
<div className="card">
<div className="card-header">
<span className="card-title">
<Circle size={14} style={{ marginRight: 6, fill: activeRecording ? "#ff3b30" : "none" }} />
Recording
</span>
{activeRecording && <span className="pill danger">Recording</span>}
</div>
{recordingError && (
<div className="banner error" style={{ marginBottom: 8 }}>
{recordingError}
</div>
)}
{!isActive && <div className="desktop-screenshot-empty">Start the desktop runtime to enable recording.</div>}
{isActive && (
<>
<div className="desktop-start-controls" style={{ gridTemplateColumns: "1fr" }}>
<div className="desktop-input-group">
<label className="label">FPS</label>
<input
className="setup-input mono"
value={recordingFps}
onChange={(e) => setRecordingFps(e.target.value)}
inputMode="numeric"
style={{ maxWidth: 80 }}
disabled={!!activeRecording}
/>
</div>
</div>
<div className="card-actions">
{!activeRecording ? (
<button className="button danger small" onClick={() => void handleStartRecording()} disabled={recordingActing === "start"}>
{recordingActing === "start" ? (
<Loader2 className="button-icon spinner-icon" />
) : (
<Circle size={14} className="button-icon" style={{ fill: "#ff3b30" }} />
)}
Start Recording
</button>
) : (
<button className="button secondary small" onClick={() => void handleStopRecording()} disabled={recordingActing === "stop"}>
{recordingActing === "stop" ? <Loader2 className="button-icon spinner-icon" /> : <Square className="button-icon" />}
Stop Recording
</button>
)}
<button className="button secondary small" onClick={() => void loadRecordings()} disabled={recordingLoading}>
<RefreshCw className={`button-icon ${recordingLoading ? "spinner-icon" : ""}`} />
Refresh
</button>
</div>
{recordings.length > 0 && (
<div className="desktop-process-list" style={{ marginTop: 12 }}>
{recordings.map((rec) => (
<div key={rec.id} className="desktop-process-item">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<strong className="mono" style={{ fontSize: 12 }}>
{rec.fileName}
</strong>
<span
className={`pill ${rec.status === "recording" ? "danger" : rec.status === "completed" ? "success" : "warning"}`}
style={{ marginLeft: 8 }}
>
{rec.status}
</span>
</div>
{rec.status === "completed" && (
<div style={{ display: "flex", gap: 4 }}>
<button
className="button ghost small"
title="Download"
onClick={() => void handleDownloadRecording(rec.id, rec.fileName)}
disabled={downloadingRecordingId === rec.id}
style={{ padding: "4px 6px" }}
>
{downloadingRecordingId === rec.id ? <Loader2 size={14} className="spinner-icon" /> : <Download size={14} />}
</button>
<button
className="button ghost small"
title="Delete"
onClick={() => void handleDeleteRecording(rec.id)}
disabled={deletingRecordingId === rec.id}
style={{ padding: "4px 6px", color: "var(--danger)" }}
>
{deletingRecordingId === rec.id ? <Loader2 size={14} className="spinner-icon" /> : <Trash2 size={14} />}
</button>
</div>
)}
</div>
<div className="mono" style={{ fontSize: 11, color: "var(--muted)", marginTop: 4 }}>
{formatBytes(rec.bytes)}
{" \u00b7 "}
{formatDuration(rec.startedAt, rec.endedAt)}
{" \u00b7 "}
{formatStartedAt(rec.startedAt)}
</div>
</div>
))}
</div>
)}
{recordings.length === 0 && !recordingLoading && (
<div className="desktop-screenshot-empty" style={{ marginTop: 8 }}>
No recordings yet. Click "Start Recording" to begin.
</div>
)}
</>
)}
</div>
{/* ========== Diagnostics Section ========== */}
{(status?.lastError || status?.runtimeLogPath || (status?.processes?.length ?? 0) > 0) && (
<div className="card">
<div className="card-header">
@ -314,27 +578,7 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
)}
</div>
)}
<div className="card">
<div className="card-header">
<span className="card-title">Latest Screenshot</span>
{status?.state === "active" ? <span className="card-meta">Manual refresh only</span> : null}
</div>
{loading ? <div className="card-meta">Loading...</div> : null}
{!loading && !screenshotUrl && (
<div className="desktop-screenshot-empty">
{status?.state === "active" ? "No screenshot loaded yet." : "Start the desktop runtime to capture a screenshot."}
</div>
)}
{screenshotUrl && (
<div className="desktop-screenshot-frame">
<img src={screenshotUrl} alt="Desktop screenshot" className="desktop-screenshot-image" />
</div>
)}
</div>
</div>
);
};
export default DesktopTab;

View file

@ -8,7 +8,7 @@ export default defineConfig(({ command }) => ({
port: 5173,
proxy: {
"/v1": {
target: "http://localhost:2468",
target: process.env.SANDBOX_AGENT_URL || "http://localhost:2468",
changeOrigin: true,
ws: true,
},

414
pnpm-lock.yaml generated
View file

@ -5,8 +5,8 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@types/react': ^18.3.3
'@types/react-dom': ^18.3.0
'@types/react': ^19.1.12
'@types/react-dom': ^19.1.6
importers:
@ -57,7 +57,7 @@ importers:
dependencies:
'@cloudflare/sandbox':
specifier: latest
version: 0.7.17(@opencode-ai/sdk@1.2.24)
version: 0.7.18(@opencode-ai/sdk@1.2.24)
hono:
specifier: ^4.12.2
version: 4.12.2
@ -73,16 +73,16 @@ importers:
devDependencies:
'@cloudflare/workers-types':
specifier: latest
version: 4.20260313.1
version: 4.20260317.1
'@types/node':
specifier: latest
version: 25.5.0
'@types/react':
specifier: ^18.3.3
version: 18.3.27
specifier: ^19.1.12
version: 19.2.14
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.27)
specifier: ^19.1.6
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.5.0
version: 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
@ -97,7 +97,7 @@ importers:
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
wrangler:
specifier: latest
version: 4.73.0(@cloudflare/workers-types@4.20260313.1)
version: 4.74.0(@cloudflare/workers-types@4.20260317.1)
examples/computesdk:
dependencies:
@ -106,7 +106,7 @@ importers:
version: link:../shared
computesdk:
specifier: latest
version: 2.5.0
version: 2.5.1
sandbox-agent:
specifier: workspace:*
version: link:../../sdks/typescript
@ -154,13 +154,13 @@ importers:
dockerode:
specifier: latest
version: 4.0.9
get-port:
specifier: latest
version: 7.1.0
sandbox-agent:
specifier: workspace:*
version: link:../../sdks/typescript
devDependencies:
'@types/dockerode':
specifier: latest
version: 4.0.1
'@types/node':
specifier: latest
version: 25.5.0
@ -345,9 +345,6 @@ importers:
'@sandbox-agent/example-shared':
specifier: workspace:*
version: link:../shared
'@sandbox-agent/persist-postgres':
specifier: workspace:*
version: link:../../sdks/persist-postgres
pg:
specifier: latest
version: 8.20.0
@ -373,13 +370,16 @@ importers:
'@sandbox-agent/example-shared':
specifier: workspace:*
version: link:../shared
'@sandbox-agent/persist-sqlite':
specifier: workspace:*
version: link:../../sdks/persist-sqlite
better-sqlite3:
specifier: ^11.0.0
version: 11.10.0
sandbox-agent:
specifier: workspace:*
version: link:../../sdks/typescript
devDependencies:
'@types/better-sqlite3':
specifier: ^7.0.0
version: 7.6.13
'@types/node':
specifier: latest
version: 25.5.0
@ -492,12 +492,9 @@ importers:
'@sandbox-agent/foundry-shared':
specifier: workspace:*
version: link:../shared
'@sandbox-agent/persist-rivet':
specifier: workspace:*
version: link:../../../sdks/persist-rivet
better-auth:
specifier: ^1.5.5
version: 1.5.5(@cloudflare/workers-types@4.20260313.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2))
version: 1.5.5(@cloudflare/workers-types@4.20260317.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2))
dockerode:
specifier: ^4.0.9
version: 4.0.9
@ -506,7 +503,7 @@ importers:
version: 0.31.9
drizzle-orm:
specifier: ^0.44.5
version: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
version: 0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
hono:
specifier: ^4.11.9
version: 4.12.2
@ -515,7 +512,7 @@ importers:
version: 10.3.1
rivetkit:
specifier: https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a
version: https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0)
version: https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0)
sandbox-agent:
specifier: workspace:*
version: link:../../../sdks/typescript
@ -546,14 +543,14 @@ importers:
version: 19.2.4
rivetkit:
specifier: 2.1.6
version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0)
version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0)
sandbox-agent:
specifier: workspace:*
version: link:../../../sdks/typescript
devDependencies:
'@types/react':
specifier: ^18.3.3
version: 18.3.27
specifier: ^19.1.12
version: 19.2.14
tsup:
specifier: ^8.5.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
@ -580,7 +577,7 @@ importers:
version: 3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
baseui:
specifier: ^16.1.1
version: 16.1.1(@types/react@18.3.27)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4))
version: 16.1.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4))
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@19.2.4)
@ -602,19 +599,19 @@ importers:
devDependencies:
'@react-grab/mcp':
specifier: ^0.1.13
version: 0.1.27(@types/react@18.3.27)(react@19.2.4)
version: 0.1.27(@types/react@19.2.14)(react@19.2.4)
'@types/react':
specifier: ^18.3.3
version: 18.3.27
specifier: ^19.1.12
version: 19.2.14
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.27)
specifier: ^19.1.6
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^5.0.3
version: 5.1.4(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
react-grab:
specifier: ^0.1.13
version: 0.1.27(@types/react@18.3.27)(react@19.2.4)
version: 0.1.27(@types/react@19.2.14)(react@19.2.4)
tsup:
specifier: ^8.5.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
@ -640,9 +637,6 @@ importers:
frontend/packages/inspector:
dependencies:
'@sandbox-agent/persist-indexeddb':
specifier: workspace:*
version: link:../../../sdks/persist-indexeddb
lucide-react:
specifier: ^0.469.0
version: 0.469.0(react@18.3.1)
@ -657,11 +651,11 @@ importers:
specifier: workspace:*
version: link:../../../sdks/react
'@types/react':
specifier: ^18.3.3
version: 18.3.27
specifier: ^19.1.12
version: 19.2.14
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.27)
specifier: ^19.1.6
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@25.5.0))
@ -688,7 +682,7 @@ importers:
dependencies:
'@astrojs/react':
specifier: ^4.2.0
version: 4.4.2(@types/node@25.5.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)
version: 4.4.2(@types/node@25.5.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)
'@astrojs/sitemap':
specifier: ^3.2.0
version: 3.7.0
@ -715,11 +709,11 @@ importers:
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
devDependencies:
'@types/react':
specifier: ^18.3.3
version: 18.3.27
specifier: ^19.1.12
version: 19.2.14
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.27)
specifier: ^19.1.6
version: 19.2.3(@types/react@19.2.14)
typescript:
specifier: ^5.7.0
version: 5.9.3
@ -799,8 +793,8 @@ importers:
sdks/acp-http-client:
dependencies:
'@agentclientprotocol/sdk':
specifier: ^0.14.1
version: 0.14.1(zod@4.3.6)
specifier: ^0.16.1
version: 0.16.1(zod@4.3.6)
devDependencies:
'@types/node':
specifier: ^22.0.0
@ -900,57 +894,30 @@ importers:
sdks/gigacode/platforms/win32-x64: {}
sdks/persist-indexeddb:
dependencies:
sandbox-agent:
specifier: workspace:*
version: link:../typescript
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.7
fake-indexeddb:
specifier: ^6.2.4
version: 6.2.5
tsup:
specifier: ^8.0.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
sdks/persist-postgres:
dependencies:
pg:
specifier: ^8.16.3
version: 8.18.0
sandbox-agent:
specifier: workspace:*
version: link:../typescript
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.7
'@types/pg':
specifier: ^8.15.6
version: 8.16.0
tsup:
specifier: ^8.0.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
sdks/persist-rivet:
dependencies:
sandbox-agent:
specifier: workspace:*
version: link:../typescript
devDependencies:
'@types/node':
specifier: ^22.0.0
@ -961,22 +928,9 @@ importers:
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
sdks/persist-sqlite:
dependencies:
better-sqlite3:
specifier: ^11.0.0
version: 11.10.0
sandbox-agent:
specifier: workspace:*
version: link:../typescript
devDependencies:
'@types/better-sqlite3':
specifier: ^7.0.0
version: 7.6.13
'@types/node':
specifier: ^22.0.0
version: 22.19.7
@ -986,25 +940,22 @@ importers:
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
sdks/react:
dependencies:
'@tanstack/react-virtual':
specifier: ^3.13.22
version: 3.13.22(react-dom@19.2.4(react@18.3.1))(react@18.3.1)
version: 3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ghostty-web:
specifier: ^0.4.0
version: 0.4.0
devDependencies:
'@types/react':
specifier: ^18.3.3
version: 18.3.27
specifier: ^19.1.12
version: 19.2.14
react:
specifier: ^18.3.1
version: 18.3.1
specifier: ^19.1.1
version: 19.2.4
sandbox-agent:
specifier: workspace:*
version: link:../typescript
@ -1028,12 +979,39 @@ importers:
specifier: workspace:*
version: link:../cli
devDependencies:
'@cloudflare/sandbox':
specifier: '>=0.1.0'
version: 0.7.18(@opencode-ai/sdk@1.2.24)
'@daytonaio/sdk':
specifier: '>=0.12.0'
version: 0.151.0(ws@8.19.0)
'@e2b/code-interpreter':
specifier: '>=1.0.0'
version: 2.3.3
'@types/dockerode':
specifier: ^4.0.0
version: 4.0.1
'@types/node':
specifier: ^22.0.0
version: 22.19.7
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vercel/sandbox':
specifier: '>=0.1.0'
version: 1.8.1
computesdk:
specifier: '>=0.1.0'
version: 2.5.1
dockerode:
specifier: '>=4.0.0'
version: 4.0.9
get-port:
specifier: '>=7.0.0'
version: 7.1.0
modal:
specifier: '>=0.1.0'
version: 0.7.3
openapi-typescript:
specifier: ^6.7.0
version: 6.7.6
@ -1073,6 +1051,11 @@ packages:
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@agentclientprotocol/sdk@0.16.1':
resolution: {integrity: sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@ -1106,8 +1089,8 @@ packages:
resolution: {integrity: sha512-1tl95bpGfuaDMDn8O3x/5Dxii1HPvzjvpL2YTuqOOrQehs60I2DKiDgh1jrKc7G8lv+LQT5H15V6QONQ+9waeQ==}
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
peerDependencies:
'@types/react': ^18.3.3
'@types/react-dom': ^18.3.0
'@types/react': ^19.1.12
'@types/react-dom': ^19.1.6
react: ^17.0.2 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0
@ -1614,8 +1597,8 @@ packages:
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
engines: {node: '>=18.0.0'}
'@cloudflare/sandbox@0.7.17':
resolution: {integrity: sha512-dS6IdIq8OHg7jg08wz18BtP6TkBSaVrixq+kN4Zm5/6N9Vir+PbfebEFL23lM5UcaPoKx6Gttnhby8e79PSMOA==}
'@cloudflare/sandbox@0.7.18':
resolution: {integrity: sha512-v9hNXik88TfhLu83RRf1I8t98L86bt2RJVJB+EPIyZB7vBVwsx8w/veQ1BoJfbf/MSETtc/zLpQROgtbe5RYRw==}
peerDependencies:
'@openai/agents': ^0.3.3
'@opencode-ai/sdk': ^1.1.40
@ -1667,8 +1650,8 @@ packages:
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260313.1':
resolution: {integrity: sha512-jMEeX3RKfOSVqqXRKr/ulgglcTloeMzSH3FdzIfqJHtvc12/ELKd5Ldsg8ZHahKX/4eRxYdw3kbzb8jLXbq/jQ==}
'@cloudflare/workers-types@4.20260317.1':
resolution: {integrity: sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==}
'@computesdk/cmd@0.4.1':
resolution: {integrity: sha512-hhcYrwMnOpRSwWma3gkUeAVsDFG56nURwSaQx8vCepv0IuUv39bK4mMkgszolnUQrVjBDdW7b3lV+l5B2S8fRA==}
@ -3636,27 +3619,21 @@ packages:
'@types/node@25.5.0':
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
'@types/pg@8.16.0':
resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==}
'@types/pg@8.18.0':
resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
'@types/react': ^18.3.3
'@types/react': ^19.1.12
'@types/react-reconciler@0.28.9':
resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
peerDependencies:
'@types/react': ^18.3.3
'@types/react': ^19.1.12
'@types/react@18.3.27':
resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==}
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
@ -4192,8 +4169,8 @@ packages:
compare-versions@6.1.1:
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
computesdk@2.5.0:
resolution: {integrity: sha512-HDOSw1D6ogR7wwlBtr5ZZtRFhfNxdPBW1omLaApxDwAvdkBW4PGoIfYyMPX0N1LpaDl5NWwWALMvmtW2p4dafg==}
computesdk@2.5.1:
resolution: {integrity: sha512-zDzgdztvloTEm7cfVxSYX+kaQXlg+2UQBzmsisH61LTVcEPaZA7CCI/T8eC/eCxGzOjUjs5n68xo/ymK3w7Ebg==}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@ -5623,8 +5600,8 @@ packages:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
miniflare@4.20260312.0:
resolution: {integrity: sha512-pieP2rfXynPT6VRINYaiHe/tfMJ4c5OIhqRlIdLF6iZ9g5xgpEmvimvIgMpgAdDJuFlrLcwDUi8MfAo2R6dt/w==}
miniflare@4.20260312.1:
resolution: {integrity: sha512-YSWxec9ssisqkQgaCgcIQxZlB41E9hMiq1nxUgxXHRrE9NsfyC6ptSt8yfgBobsKIseAVKLTB/iEDpMumBv8oA==}
engines: {node: '>=18.0.0'}
hasBin: true
@ -5894,9 +5871,6 @@ packages:
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
pg-connection-string@2.11.0:
resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==}
pg-connection-string@2.12.0:
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
@ -5904,11 +5878,6 @@ packages:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.11.0:
resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==}
peerDependencies:
pg: '>=8.0'
pg-pool@3.13.0:
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
peerDependencies:
@ -5924,15 +5893,6 @@ packages:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.18.0:
resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pg@8.20.0:
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
engines: {node: '>= 16.0.0'}
@ -6193,7 +6153,7 @@ packages:
react-focus-lock@2.13.7:
resolution: {integrity: sha512-20lpZHEQrXPb+pp1tzd4ULL6DyO5D2KnR0G69tTDdydrmNhU7pdFmbQUYVyHUgp+xN29IuFR0PVuhOmvaZL9Og==}
peerDependencies:
'@types/react': ^18.3.3
'@types/react': ^19.1.12
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
@ -6262,7 +6222,7 @@ packages:
resolution: {integrity: sha512-tsPZ77GR0pISGYmpCLHAbZTabKXZ7zBniKPVqVMMfnXFyo39zq5g/psIlD5vLTKkjQEhWOO8JhqcHnxkwNu6eA==}
engines: {node: '>=8.5.0'}
peerDependencies:
'@types/react': ^18.3.3
'@types/react': ^19.1.12
react: ^16.8.0
peerDependenciesMeta:
'@types/react':
@ -7122,7 +7082,7 @@ packages:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^18.3.3
'@types/react': ^19.1.12
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
@ -7132,7 +7092,7 @@ packages:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^18.3.3
'@types/react': ^19.1.12
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
@ -7394,8 +7354,8 @@ packages:
engines: {node: '>=16'}
hasBin: true
wrangler@4.73.0:
resolution: {integrity: sha512-VJXsqKDFCp6OtFEHXITSOR5kh95JOknwPY8m7RyQuWJQguSybJy43m4vhoCSt42prutTef7eeuw7L4V4xiynGw==}
wrangler@4.74.0:
resolution: {integrity: sha512-3qprbhgdUyqYGHZ+Y1k0gsyHLMOlLrKL/HU0LDqLlCkbsKPprUA0/ThE4IZsxD84xAAXY6pv5JUuxS2+OnMa3A==}
engines: {node: '>=20.0.0'}
hasBin: true
peerDependencies:
@ -7536,6 +7496,10 @@ snapshots:
dependencies:
zod: 4.3.6
'@agentclientprotocol/sdk@0.16.1(zod@4.3.6)':
dependencies:
zod: 4.3.6
'@alloc/quick-lru@5.2.0': {}
'@antfu/ni@0.23.2': {}
@ -7587,10 +7551,10 @@ snapshots:
dependencies:
prismjs: 1.30.0
'@astrojs/react@4.4.2(@types/node@25.5.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)':
'@astrojs/react@4.4.2(@types/node@25.5.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)':
dependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@ -8372,7 +8336,7 @@ snapshots:
'@balena/dockerignore@1.0.2': {}
'@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)':
'@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)':
dependencies:
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
@ -8383,39 +8347,39 @@ snapshots:
nanostores: 1.1.1
zod: 4.3.6
optionalDependencies:
'@cloudflare/workers-types': 4.20260313.1
'@cloudflare/workers-types': 4.20260317.1
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))':
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
optionalDependencies:
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
'@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)':
'@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
kysely: 0.28.11
'@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
'@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
'@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))':
'@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
@ -8497,7 +8461,7 @@ snapshots:
'@cloudflare/kv-asset-handler@0.4.2': {}
'@cloudflare/sandbox@0.7.17(@opencode-ai/sdk@1.2.24)':
'@cloudflare/sandbox@0.7.18(@opencode-ai/sdk@1.2.24)':
dependencies:
'@cloudflare/containers': 0.1.1
aws4fetch: 1.0.20
@ -8525,7 +8489,7 @@ snapshots:
'@cloudflare/workerd-windows-64@1.20260312.1':
optional: true
'@cloudflare/workers-types@4.20260313.1': {}
'@cloudflare/workers-types@4.20260317.1': {}
'@computesdk/cmd@0.4.1': {}
@ -9610,11 +9574,11 @@ snapshots:
prompts: 2.4.2
smol-toml: 1.6.0
'@react-grab/mcp@0.1.27(@types/react@18.3.27)(react@19.2.4)':
'@react-grab/mcp@0.1.27(@types/react@19.2.14)(react@19.2.4)':
dependencies:
'@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76)
fkill: 9.0.0
react-grab: 0.1.27(@types/react@18.3.27)(react@19.2.4)
react-grab: 0.1.27(@types/react@19.2.14)(react@19.2.4)
zod: 3.25.76
transitivePeerDependencies:
- '@cfworker/json-schema'
@ -10227,12 +10191,6 @@ snapshots:
react-dom: 19.2.4(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4)
'@tanstack/react-virtual@3.13.22(react-dom@19.2.4(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.13.22
react: 18.3.1
react-dom: 19.2.4(react@18.3.1)
'@tanstack/react-virtual@3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/virtual-core': 3.13.22
@ -10342,31 +10300,22 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/pg@8.16.0':
dependencies:
'@types/node': 24.10.9
pg-protocol: 1.11.0
pg-types: 2.2.0
'@types/pg@8.18.0':
dependencies:
'@types/node': 24.10.9
pg-protocol: 1.11.0
pg-types: 2.2.0
'@types/prop-types@15.7.15': {}
'@types/react-dom@18.3.7(@types/react@18.3.27)':
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 18.3.27
'@types/react': 19.2.14
'@types/react-reconciler@0.28.9(@types/react@18.3.27)':
'@types/react-reconciler@0.28.9(@types/react@19.2.14)':
dependencies:
'@types/react': 18.3.27
'@types/react': 19.2.14
'@types/react@18.3.27':
'@types/react@19.2.14':
dependencies:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/retry@0.12.2': {}
@ -10725,7 +10674,7 @@ snapshots:
baseline-browser-mapping@2.9.18: {}
baseui@16.1.1(@types/react@18.3.27)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4)):
baseui@16.1.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4)):
dependencies:
'@date-io/date-fns': 2.17.0(date-fns@2.30.0)
'@date-io/moment': 2.17.0(moment@2.30.1)
@ -10745,7 +10694,7 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-dropzone: 9.0.0(react@19.2.4)
react-focus-lock: 2.13.7(@types/react@18.3.27)(react@19.2.4)
react-focus-lock: 2.13.7(@types/react@19.2.14)(react@19.2.4)
react-hook-form: 7.71.2(react@19.2.4)
react-input-mask: 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-is: 17.0.2
@ -10753,7 +10702,7 @@ snapshots:
react-movable: 3.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-multi-ref: 1.0.2
react-range: 1.10.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-uid: 2.3.0(@types/react@18.3.27)(react@19.2.4)
react-uid: 2.3.0(@types/react@19.2.14)(react@19.2.4)
react-virtualized: 9.22.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-virtualized-auto-sizer: 1.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-window: 1.8.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -10767,15 +10716,15 @@ snapshots:
dependencies:
tweetnacl: 0.14.5
better-auth@1.5.5(@cloudflare/workers-types@4.20260313.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)):
better-auth@1.5.5(@cloudflare/workers-types@4.20260317.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))
'@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)
'@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))
'@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)
'@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.1.1
@ -10788,7 +10737,7 @@ snapshots:
zod: 4.3.6
optionalDependencies:
drizzle-kit: 0.31.9
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
pg: 8.20.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@ -10817,9 +10766,9 @@ snapshots:
dependencies:
file-uri-to-path: 1.0.0
bippy@0.5.32(@types/react@18.3.27)(react@19.2.4):
bippy@0.5.32(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@types/react-reconciler': 0.28.9(@types/react@18.3.27)
'@types/react-reconciler': 0.28.9(@types/react@19.2.14)
react: 19.2.4
transitivePeerDependencies:
- '@types/react'
@ -11041,7 +10990,7 @@ snapshots:
compare-versions@6.1.1: {}
computesdk@2.5.0:
computesdk@2.5.1:
dependencies:
'@computesdk/cmd': 0.4.1
@ -11409,9 +11358,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0):
drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0):
optionalDependencies:
'@cloudflare/workers-types': 4.20260313.1
'@cloudflare/workers-types': 4.20260317.1
'@opentelemetry/api': 1.9.0
'@types/better-sqlite3': 7.6.13
'@types/pg': 8.18.0
@ -11439,7 +11388,7 @@ snapshots:
glob: 11.1.0
openapi-fetch: 0.14.1
platform: 1.3.6
tar: 7.5.6
tar: 7.5.7
earcut@2.2.4: {}
@ -12724,7 +12673,7 @@ snapshots:
mimic-response@3.1.0: {}
miniflare@4.20260312.0:
miniflare@4.20260312.1:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
@ -12998,16 +12947,10 @@ snapshots:
pg-cloudflare@1.3.0:
optional: true
pg-connection-string@2.11.0: {}
pg-connection-string@2.12.0: {}
pg-int8@1.0.1: {}
pg-pool@3.11.0(pg@8.18.0):
dependencies:
pg: 8.18.0
pg-pool@3.13.0(pg@8.20.0):
dependencies:
pg: 8.20.0
@ -13024,16 +12967,6 @@ snapshots:
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.18.0:
dependencies:
pg-connection-string: 2.11.0
pg-pool: 3.11.0(pg@8.18.0)
pg-protocol: 1.11.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.3.0
pg@8.20.0:
dependencies:
pg-connection-string: 2.12.0
@ -13290,11 +13223,6 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
react-dom@19.2.4(react@18.3.1):
dependencies:
react: 18.3.1
scheduler: 0.27.0
react-dom@19.2.4(react@19.2.4):
dependencies:
react: 19.2.4
@ -13308,23 +13236,23 @@ snapshots:
prop-types-extra: 1.1.1(react@19.2.4)
react: 19.2.4
react-focus-lock@2.13.7(@types/react@18.3.27)(react@19.2.4):
react-focus-lock@2.13.7(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
focus-lock: 1.3.6
prop-types: 15.8.1
react: 19.2.4
react-clientside-effect: 1.2.8(react@19.2.4)
use-callback-ref: 1.3.3(@types/react@18.3.27)(react@19.2.4)
use-sidecar: 1.1.3(@types/react@18.3.27)(react@19.2.4)
use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4)
use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4)
optionalDependencies:
'@types/react': 18.3.27
'@types/react': 19.2.14
react-grab@0.1.27(@types/react@18.3.27)(react@19.2.4):
react-grab@0.1.27(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@medv/finder': 4.0.2
'@react-grab/cli': 0.1.27
bippy: 0.5.32(@types/react@18.3.27)(react@19.2.4)
bippy: 0.5.32(@types/react@19.2.14)(react@19.2.4)
solid-js: 1.9.11
optionalDependencies:
react: 19.2.4
@ -13378,12 +13306,12 @@ snapshots:
react-refresh@0.18.0: {}
react-uid@2.3.0(@types/react@18.3.27)(react@19.2.4):
react-uid@2.3.0(@types/react@19.2.14)(react@19.2.4):
dependencies:
react: 19.2.4
tslib: 1.14.1
optionalDependencies:
'@types/react': 18.3.27
'@types/react': 19.2.14
react-virtualized-auto-sizer@1.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
@ -13571,7 +13499,7 @@ snapshots:
reusify@1.1.0: {}
rivetkit@2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0):
rivetkit@2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0):
dependencies:
'@hono/standard-validator': 0.1.5(@standard-schema/spec@1.1.0)(hono@4.12.2)
'@hono/zod-openapi': 1.2.2(hono@4.12.2)(zod@4.3.6)
@ -13599,14 +13527,14 @@ snapshots:
'@hono/node-server': 1.19.9(hono@4.12.2)
'@hono/node-ws': 1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2)
drizzle-kit: 0.31.9
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
ws: 8.19.0
transitivePeerDependencies:
- '@standard-schema/spec'
- bufferutil
- utf-8-validate
rivetkit@https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0):
rivetkit@https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0):
dependencies:
'@hono/standard-validator': 0.1.5(@standard-schema/spec@1.1.0)(hono@4.12.2)
'@hono/zod-openapi': 1.2.2(hono@4.12.2)(zod@4.3.6)
@ -13637,7 +13565,7 @@ snapshots:
'@hono/node-ws': 1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2)
dockerode: 4.0.9
drizzle-kit: 0.31.9
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
ws: 8.19.0
transitivePeerDependencies:
- '@standard-schema/spec'
@ -14371,20 +14299,20 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
use-callback-ref@1.3.3(@types/react@18.3.27)(react@19.2.4):
use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4):
dependencies:
react: 19.2.4
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.3.27
'@types/react': 19.2.14
use-sidecar@1.1.3(@types/react@18.3.27)(react@19.2.4):
use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4):
dependencies:
detect-node-es: 1.1.0
react: 19.2.4
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.3.27
'@types/react': 19.2.14
use-sync-external-store@1.6.0(react@19.2.4):
dependencies:
@ -14744,18 +14672,18 @@ snapshots:
'@cloudflare/workerd-linux-arm64': 1.20260312.1
'@cloudflare/workerd-windows-64': 1.20260312.1
wrangler@4.73.0(@cloudflare/workers-types@4.20260313.1):
wrangler@4.74.0(@cloudflare/workers-types@4.20260317.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.4.2
'@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260312.1)
blake3-wasm: 2.1.5
esbuild: 0.27.3
miniflare: 4.20260312.0
miniflare: 4.20260312.1
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260312.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260313.1
'@cloudflare/workers-types': 4.20260317.1
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil

View file

@ -0,0 +1,103 @@
# Desktop Streaming Architecture
## Decision: neko over GStreamer (direct) and VNC
We evaluated three approaches for streaming the virtual desktop to browser clients:
1. **VNC (noVNC/websockify)** - traditional remote desktop
2. **GStreamer WebRTC (direct)** - custom GStreamer pipeline in the sandbox agent process
3. **neko** - standalone WebRTC streaming server with its own GStreamer pipeline
We chose **neko**.
## Approach comparison
### VNC (noVNC)
- Uses RFB protocol, not WebRTC. Relies on pixel-diff framebuffer updates over WebSocket.
- Higher latency than WebRTC (no hardware-accelerated codec, no adaptive bitrate).
- Requires a VNC server (x11vnc or similar) plus websockify for browser access.
- Input handling is mature but tied to the RFB protocol.
- No audio support without additional plumbing.
**Rejected because:** Latency is noticeably worse than WebRTC-based approaches. The pixel-diff approach doesn't scale well at higher resolutions or frame rates. No native audio path.
### GStreamer WebRTC (direct)
- Custom pipeline: `ximagesrc -> videoconvert -> vp8enc -> rtpvp8pay -> webrtcbin`.
- Runs inside the sandbox agent Rust process using `gstreamer-rs` bindings.
- Requires feature-gating (`desktop-gstreamer` Cargo feature) and linking GStreamer at compile time.
- ICE candidate handling is complex: Docker-internal IPs (172.17.x.x) must be rewritten to 127.0.0.1 for host browser connectivity.
- UDP port range must be constrained via libnice NiceAgent properties to stay within Docker-forwarded ports.
- Input must be implemented separately (xdotool or custom X11 input injection).
- No built-in session management, authentication, or multi-client support.
**Rejected because:** Too much complexity for the sandbox agent to own directly. ICE/NAT traversal bugs are hard to debug. The GStreamer Rust bindings add significant compile-time dependencies. Input handling requires a separate implementation. We built and tested this approach (branch `desktop-computer-use`, PR #226) and found:
- Black screen issues due to GStreamer pipeline negotiation failures
- ICE candidate rewriting fragility across Docker networking modes
- libnice port range configuration requires accessing internal NiceAgent properties that vary across GStreamer versions
- No data channel for low-latency input (had to fall back to WebSocket-based input which adds a round trip)
### neko (chosen)
- Standalone Go binary extracted from `ghcr.io/m1k1o/neko/base`.
- Has its own GStreamer pipeline internally (same `ximagesrc -> vp8enc -> webrtcbin` approach, but battle-tested).
- Provides WebSocket signaling, WebRTC media, and a binary data channel for input, all out of the box.
- Input via data channel is low-latency (sub-frame, no HTTP round trip). Uses X11 XTEST extension.
- Multi-session support with `noauth` provider (each browser tab gets its own session).
- ICE-lite mode with `--webrtc.nat1to1 127.0.0.1` eliminates NAT traversal issues for Docker-to-host.
- EPR (ephemeral port range) flag constrains UDP ports cleanly.
- Sandbox agent acts as a thin WebSocket proxy: browser WS connects to sandbox agent, which creates a per-connection neko login session and relays signaling messages bidirectionally.
- Audio codec support (opus) included for free.
**Chosen because:** Neko encapsulates all the hard WebRTC/GStreamer/input complexity into a single binary. The sandbox agent only needs to:
1. Manage the neko process lifecycle (start/stop via the process runtime)
2. Proxy WebSocket signaling (bidirectional relay, ~60 lines of code)
3. Handle neko session creation (HTTP login to get a session cookie)
This keeps the sandbox agent's desktop streaming code simple (~300 lines for the manager, ~120 lines for the WS proxy) while delivering production-quality WebRTC streaming with data channel input.
## Architecture
```
Browser Sandbox Agent neko (internal)
| | |
|-- WS /stream/signaling --> |-- WS ws://127.0.0.1:18100/api/ws -->|
| | (bidirectional relay) |
|<-- neko signaling ---------|<-- neko signaling -------|
| | |
|<========= WebRTC (UDP 59000-59100) ==================>|
| VP8 video, Opus audio, binary data channel |
| |
|-- data channel input (mouse/keyboard) --------------->|
| (binary protocol: opcode + payload, big-endian) |
```
Key points:
- neko listens on internal port 18100 (not exposed externally).
- UDP ports 59000-59100 are forwarded through Docker for WebRTC media.
- `--webrtc.icelite` + `--webrtc.nat1to1 127.0.0.1` means neko advertises 127.0.0.1 as its ICE candidate, so the browser connects to localhost UDP ports directly.
- `--desktop.input.enabled=false` disables neko's custom xf86-input driver (not available outside neko's official Docker images). Input falls back to XTEST.
- Each WebSocket proxy connection creates a fresh neko login session with a unique username to avoid session conflicts when multiple clients connect.
## Trade-offs
| Concern | neko | GStreamer direct |
|---------|------|-----------------|
| Binary size | ~30MB additional binary | ~0 (uses system GStreamer libs) |
| Compile-time deps | None (external binary) | gstreamer-rs crate + GStreamer dev libs |
| Input latency | Sub-frame (data channel) | WebSocket round trip |
| ICE/NAT complexity | Handled by neko flags | Must implement in Rust |
| Multi-client | Built-in session management | Must implement |
| Maintenance | Upstream neko updates | Own all the code |
| Audio | Built-in (opus) | Must add audio pipeline |
The main trade-off is the additional ~30MB binary size from neko. This is acceptable for the Docker-based deployment model where image size is less critical than reliability and development velocity.
## References
- neko v3: https://github.com/m1k1o/neko
- neko client reference: https://github.com/demodesk/neko-client
- neko data channel protocol: https://github.com/m1k1o/neko/blob/master/server/internal/webrtc/payload/receive.go
- GStreamer branch (closed): PR #226, branch `desktop-computer-use`
- Image digest: `ghcr.io/m1k1o/neko/base@sha256:0c384afa56268aaa2d5570211d284763d0840dcdd1a7d9a24be3081d94d3dfce`

View file

@ -6,7 +6,7 @@ import type { DesktopMouseButton, DesktopStreamErrorStatus, DesktopStreamReadySt
type ConnectionState = "connecting" | "ready" | "closed" | "error";
export type DesktopViewerClient = Pick<SandboxAgent, "startDesktopStream" | "stopDesktopStream" | "connectDesktopStream">;
export type DesktopViewerClient = Pick<SandboxAgent, "connectDesktopStream">;
export interface DesktopViewerProps {
client: DesktopViewerClient;
@ -51,7 +51,7 @@ const viewportStyle: CSSProperties = {
background: "radial-gradient(circle at top, rgba(14, 165, 233, 0.18), transparent 45%), linear-gradient(180deg, #0f172a 0%, #111827 100%)",
};
const imageBaseStyle: CSSProperties = {
const videoBaseStyle: CSSProperties = {
display: "block",
width: "100%",
height: "100%",
@ -78,112 +78,96 @@ const getStatusColor = (state: ConnectionState): string => {
export const DesktopViewer = ({ client, className, style, imageStyle, height = 480, onConnect, onDisconnect, onError }: DesktopViewerProps) => {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const sessionRef = useRef<ReturnType<DesktopViewerClient["connectDesktopStream"]> | null>(null);
const [connectionState, setConnectionState] = useState<ConnectionState>("connecting");
const [statusMessage, setStatusMessage] = useState("Starting desktop stream...");
const [frameUrl, setFrameUrl] = useState<string | null>(null);
const [hasVideo, setHasVideo] = useState(false);
const [resolution, setResolution] = useState<{ width: number; height: number } | null>(null);
useEffect(() => {
let cancelled = false;
let lastObjectUrl: string | null = null;
let session: ReturnType<DesktopViewerClient["connectDesktopStream"]> | null = null;
setConnectionState("connecting");
setStatusMessage("Starting desktop stream...");
setStatusMessage("Connecting to desktop stream...");
setResolution(null);
setHasVideo(false);
const connect = async () => {
try {
await client.startDesktopStream();
if (cancelled) {
return;
}
session = client.connectDesktopStream();
const session = client.connectDesktopStream();
sessionRef.current = session;
session.onReady((status) => {
if (cancelled) {
return;
}
if (cancelled) return;
setConnectionState("ready");
setStatusMessage("Desktop stream connected.");
setResolution({ width: status.width, height: status.height });
onConnect?.(status);
});
session.onFrame((frame) => {
if (cancelled) {
return;
session.onTrack((stream) => {
if (cancelled) return;
const video = videoRef.current;
if (video) {
video.srcObject = stream;
void video.play().catch(() => undefined);
setHasVideo(true);
}
const nextUrl = URL.createObjectURL(new Blob([frame.slice().buffer], { type: "image/jpeg" }));
setFrameUrl((current) => {
if (current) {
URL.revokeObjectURL(current);
}
return nextUrl;
});
if (lastObjectUrl) {
URL.revokeObjectURL(lastObjectUrl);
}
lastObjectUrl = nextUrl;
});
session.onError((error) => {
if (cancelled) {
return;
}
if (cancelled) return;
setConnectionState("error");
setStatusMessage(error instanceof Error ? error.message : error.message);
onError?.(error);
});
session.onClose(() => {
if (cancelled) {
return;
}
session.onDisconnect(() => {
if (cancelled) return;
setConnectionState((current) => (current === "error" ? current : "closed"));
setStatusMessage((current) => (current === "Desktop stream connected." ? "Desktop stream disconnected." : current));
onDisconnect?.();
});
} catch (error) {
if (cancelled) {
return;
}
const nextError = error instanceof Error ? error : new Error("Failed to initialize desktop stream.");
setConnectionState("error");
setStatusMessage(nextError.message);
onError?.(nextError);
}
};
void connect();
return () => {
cancelled = true;
session?.close();
session.close();
sessionRef.current = null;
void client.stopDesktopStream().catch(() => undefined);
setFrameUrl((current) => {
if (current) {
URL.revokeObjectURL(current);
}
return null;
});
if (lastObjectUrl) {
URL.revokeObjectURL(lastObjectUrl);
const video = videoRef.current;
if (video) {
video.srcObject = null;
}
setHasVideo(false);
};
}, [client, onConnect, onDisconnect, onError]);
const scalePoint = (clientX: number, clientY: number) => {
const wrapper = wrapperRef.current;
if (!wrapper || !resolution) {
const video = videoRef.current;
if (!video || !resolution) {
return null;
}
const rect = wrapper.getBoundingClientRect();
const rect = video.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return null;
}
const x = Math.max(0, Math.min(resolution.width, ((clientX - rect.left) / rect.width) * resolution.width));
const y = Math.max(0, Math.min(resolution.height, ((clientY - rect.top) / rect.height) * resolution.height));
// The video uses objectFit: "contain", so we need to compute the actual
// rendered content area within the <video> element to map coordinates
// accurately (ignoring letterbox bars).
const videoAspect = resolution.width / resolution.height;
const elemAspect = rect.width / rect.height;
let renderW: number;
let renderH: number;
if (elemAspect > videoAspect) {
// Pillarboxed (black bars on left/right)
renderH = rect.height;
renderW = rect.height * videoAspect;
} else {
// Letterboxed (black bars on top/bottom)
renderW = rect.width;
renderH = rect.width / videoAspect;
}
const offsetX = (rect.width - renderW) / 2;
const offsetY = (rect.height - renderH) / 2;
const relX = clientX - rect.left - offsetX;
const relY = clientY - rect.top - offsetY;
const x = Math.max(0, Math.min(resolution.width, (relX / renderW) * resolution.width));
const y = Math.max(0, Math.min(resolution.height, (relY / renderH) * resolution.height));
return {
x: Math.round(x),
y: Math.round(y),
@ -212,7 +196,7 @@ export const DesktopViewer = ({ client, className, style, imageStyle, height = 4
<div className={className} style={{ ...shellStyle, ...style }}>
<div style={statusBarStyle}>
<span style={{ color: getStatusColor(connectionState) }}>{statusMessage}</span>
<span style={hintStyle}>{resolution ? `${resolution.width}×${resolution.height}` : "Awaiting frames"}</span>
<span style={hintStyle}>{resolution ? `${resolution.width}×${resolution.height}` : "Awaiting stream"}</span>
</div>
<div
ref={wrapperRef}
@ -226,8 +210,14 @@ export const DesktopViewer = ({ client, className, style, imageStyle, height = 4
}
withSession((session) => session.moveMouse(point.x, point.y));
}}
onContextMenu={(event) => {
event.preventDefault();
}}
onMouseDown={(event) => {
event.preventDefault();
// preventDefault on mousedown suppresses the default focus behavior,
// so we must explicitly focus the wrapper to receive keyboard events.
wrapperRef.current?.focus();
const point = scalePoint(event.clientX, event.clientY);
withSession((session) => session.mouseDown(buttonFromMouseEvent(event), point?.x, point?.y));
}}
@ -244,13 +234,25 @@ export const DesktopViewer = ({ client, className, style, imageStyle, height = 4
withSession((session) => session.scroll(point.x, point.y, Math.round(event.deltaX), Math.round(event.deltaY)));
}}
onKeyDown={(event) => {
event.preventDefault();
event.stopPropagation();
withSession((session) => session.keyDown(event.key));
}}
onKeyUp={(event) => {
event.preventDefault();
event.stopPropagation();
withSession((session) => session.keyUp(event.key));
}}
>
{frameUrl ? <img alt="Desktop stream" draggable={false} src={frameUrl} style={{ ...imageBaseStyle, ...imageStyle }} /> : null}
<video
ref={videoRef}
autoPlay
playsInline
muted
tabIndex={-1}
draggable={false}
style={{ ...videoBaseStyle, ...imageStyle, display: hasVideo ? "block" : "none", pointerEvents: "none" }}
/>
</div>
</div>
);

View file

@ -1926,7 +1926,7 @@ export class SandboxAgent {
buildDesktopStreamWebSocketUrl(options: ProcessTerminalWebSocketUrlOptions = {}): string {
return toWebSocketUrl(
this.buildUrl(`${API_PREFIX}/desktop/stream/ws`, {
this.buildUrl(`${API_PREFIX}/desktop/stream/signaling`, {
access_token: options.accessToken ?? this.token,
}),
);

View file

@ -1,6 +1,5 @@
import type { DesktopMouseButton } from "./types.ts";
const WS_READY_STATE_CONNECTING = 0;
const WS_READY_STATE_OPEN = 1;
const WS_READY_STATE_CLOSED = 3;
@ -21,63 +20,140 @@ export interface DesktopStreamConnectOptions {
accessToken?: string;
WebSocket?: typeof WebSocket;
protocols?: string | string[];
RTCPeerConnection?: typeof RTCPeerConnection;
rtcConfig?: RTCConfiguration;
}
type DesktopStreamClientFrame =
| {
type: "moveMouse";
x: number;
y: number;
/**
* Neko data channel binary input protocol (Big Endian, v3).
*
* Reference implementation:
* https://github.com/demodesk/neko-client/blob/37f93eae6bd55b333c94bd009d7f2b079075a026/src/component/internal/webrtc.ts
*
* Server-side protocol:
* https://github.com/m1k1o/neko/blob/master/server/internal/webrtc/payload/receive.go
*
* Pinned to neko server image: m1k1o/neko:base@sha256:14e4012bc361025f71205ffc2a9342a628f39168c0a1d855db033fb18590fcae
*/
const NEKO_OP_MOVE = 0x01;
const NEKO_OP_SCROLL = 0x02;
const NEKO_OP_KEY_DOWN = 0x03;
const NEKO_OP_KEY_UP = 0x04;
const NEKO_OP_BTN_DOWN = 0x05;
const NEKO_OP_BTN_UP = 0x06;
function mouseButtonToX11(button?: DesktopMouseButton): number {
switch (button) {
case "middle":
return 2;
case "right":
return 3;
default:
return 1;
}
| {
type: "mouseDown" | "mouseUp";
x?: number;
y?: number;
button?: DesktopMouseButton;
}
| {
type: "scroll";
x: number;
y: number;
deltaX?: number;
deltaY?: number;
function keyToX11Keysym(key: string): number {
if (key.length === 1) {
const cp = key.charCodeAt(0);
if (cp >= 0x20 && cp <= 0x7e) return cp;
return 0x01000000 + cp;
}
| {
type: "keyDown" | "keyUp";
key: string;
}
| {
type: "close";
const map: Record<string, number> = {
Backspace: 0xff08,
Tab: 0xff09,
Return: 0xff0d,
Enter: 0xff0d,
Escape: 0xff1b,
Delete: 0xffff,
Home: 0xff50,
Left: 0xff51,
ArrowLeft: 0xff51,
Up: 0xff52,
ArrowUp: 0xff52,
Right: 0xff53,
ArrowRight: 0xff53,
Down: 0xff54,
ArrowDown: 0xff54,
PageUp: 0xff55,
PageDown: 0xff56,
End: 0xff57,
Insert: 0xff63,
F1: 0xffbe,
F2: 0xffbf,
F3: 0xffc0,
F4: 0xffc1,
F5: 0xffc2,
F6: 0xffc3,
F7: 0xffc4,
F8: 0xffc5,
F9: 0xffc6,
F10: 0xffc7,
F11: 0xffc8,
F12: 0xffc9,
Shift: 0xffe1,
ShiftLeft: 0xffe1,
ShiftRight: 0xffe2,
Control: 0xffe3,
ControlLeft: 0xffe3,
ControlRight: 0xffe4,
Alt: 0xffe9,
AltLeft: 0xffe9,
AltRight: 0xffea,
Meta: 0xffeb,
MetaLeft: 0xffeb,
MetaRight: 0xffec,
CapsLock: 0xffe5,
NumLock: 0xff7f,
ScrollLock: 0xff14,
" ": 0x0020,
Space: 0x0020,
};
return map[key] ?? 0;
}
export class DesktopStreamSession {
readonly socket: WebSocket;
readonly closed: Promise<void>;
private pc: RTCPeerConnection | null = null;
private dataChannel: RTCDataChannel | null = null;
private mediaStream: MediaStream | null = null;
private connected = false;
private pendingCandidates: Record<string, unknown>[] = [];
private cachedReadyStatus: DesktopStreamReadyStatus | null = null;
private readonly readyListeners = new Set<(status: DesktopStreamReadyStatus) => void>();
private readonly frameListeners = new Set<(frame: Uint8Array) => void>();
private readonly trackListeners = new Set<(stream: MediaStream) => void>();
private readonly connectListeners = new Set<() => void>();
private readonly disconnectListeners = new Set<() => void>();
private readonly errorListeners = new Set<(error: DesktopStreamErrorStatus | Error) => void>();
private readonly closeListeners = new Set<() => void>();
private closeSignalSent = false;
private closedResolve!: () => void;
private readonly PeerConnection: typeof RTCPeerConnection;
private readonly rtcConfig: RTCConfiguration;
constructor(socket: WebSocket) {
constructor(socket: WebSocket, options: DesktopStreamConnectOptions = {}) {
this.socket = socket;
this.socket.binaryType = "arraybuffer";
this.PeerConnection = options.RTCPeerConnection ?? globalThis.RTCPeerConnection;
this.rtcConfig = options.rtcConfig ?? {};
this.closed = new Promise<void>((resolve) => {
this.closedResolve = resolve;
});
this.socket.addEventListener("message", (event) => {
void this.handleMessage(event.data);
this.handleMessage(event.data as string);
});
this.socket.addEventListener("error", () => {
this.emitError(new Error("Desktop stream websocket connection failed."));
this.emitError(new Error("Desktop stream signaling connection failed."));
});
this.socket.addEventListener("close", () => {
this.teardownPeerConnection();
this.closedResolve();
for (const listener of this.closeListeners) {
for (const listener of this.disconnectListeners) {
listener();
}
});
@ -85,15 +161,37 @@ export class DesktopStreamSession {
onReady(listener: (status: DesktopStreamReadyStatus) => void): () => void {
this.readyListeners.add(listener);
// Deliver cached status to late listeners (handles race where system/init
// arrives before onReady is called).
if (this.cachedReadyStatus) {
listener(this.cachedReadyStatus);
}
return () => {
this.readyListeners.delete(listener);
};
}
onFrame(listener: (frame: Uint8Array) => void): () => void {
this.frameListeners.add(listener);
onTrack(listener: (stream: MediaStream) => void): () => void {
this.trackListeners.add(listener);
if (this.mediaStream) {
listener(this.mediaStream);
}
return () => {
this.frameListeners.delete(listener);
this.trackListeners.delete(listener);
};
}
onConnect(listener: () => void): () => void {
this.connectListeners.add(listener);
return () => {
this.connectListeners.delete(listener);
};
}
onDisconnect(listener: () => void): () => void {
this.disconnectListeners.add(listener);
return () => {
this.disconnectListeners.delete(listener);
};
}
@ -104,97 +202,335 @@ export class DesktopStreamSession {
};
}
/** @deprecated Use onDisconnect instead. */
onClose(listener: () => void): () => void {
this.closeListeners.add(listener);
return () => {
this.closeListeners.delete(listener);
};
return this.onDisconnect(listener);
}
/** @deprecated No longer emits JPEG frames. Use onTrack for WebRTC media. */
onFrame(_listener: (frame: Uint8Array) => void): () => void {
return () => {};
}
getMediaStream(): MediaStream | null {
return this.mediaStream;
}
/** Build a neko data channel message with the 3-byte header (event + length). */
private buildNekoMsg(event: number, payloadSize: number): { buf: ArrayBuffer; view: DataView } {
const totalLen = 3 + payloadSize; // 1 byte event + 2 bytes length + payload
const buf = new ArrayBuffer(totalLen);
const view = new DataView(buf);
view.setUint8(0, event);
view.setUint16(1, payloadSize, false);
return { buf, view };
}
moveMouse(x: number, y: number): void {
this.sendFrame({ type: "moveMouse", x, y });
// Move payload: X(uint16) + Y(uint16) = 4 bytes
const { buf, view } = this.buildNekoMsg(NEKO_OP_MOVE, 4);
view.setUint16(3, x, false);
view.setUint16(5, y, false);
this.sendDataChannel(buf);
}
mouseDown(button?: DesktopMouseButton, x?: number, y?: number): void {
this.sendFrame({ type: "mouseDown", button, x, y });
if (x != null && y != null) {
this.moveMouse(x, y);
}
// Button payload: Key(uint32) = 4 bytes
const { buf, view } = this.buildNekoMsg(NEKO_OP_BTN_DOWN, 4);
view.setUint32(3, mouseButtonToX11(button), false);
this.sendDataChannel(buf);
}
mouseUp(button?: DesktopMouseButton, x?: number, y?: number): void {
this.sendFrame({ type: "mouseUp", button, x, y });
if (x != null && y != null) {
this.moveMouse(x, y);
}
const { buf, view } = this.buildNekoMsg(NEKO_OP_BTN_UP, 4);
view.setUint32(3, mouseButtonToX11(button), false);
this.sendDataChannel(buf);
}
scroll(x: number, y: number, deltaX?: number, deltaY?: number): void {
this.sendFrame({ type: "scroll", x, y, deltaX, deltaY });
this.moveMouse(x, y);
// Scroll payload: DeltaX(int16) + DeltaY(int16) + ControlKey(uint8) = 5 bytes
const { buf, view } = this.buildNekoMsg(NEKO_OP_SCROLL, 5);
view.setInt16(3, deltaX ?? 0, false);
view.setInt16(5, deltaY ?? 0, false);
view.setUint8(7, 0); // controlKey = false
this.sendDataChannel(buf);
}
keyDown(key: string): void {
this.sendFrame({ type: "keyDown", key });
const keysym = keyToX11Keysym(key);
if (keysym === 0) return;
// Key payload: Key(uint32) = 4 bytes
const { buf, view } = this.buildNekoMsg(NEKO_OP_KEY_DOWN, 4);
view.setUint32(3, keysym, false);
this.sendDataChannel(buf);
}
keyUp(key: string): void {
this.sendFrame({ type: "keyUp", key });
const keysym = keyToX11Keysym(key);
if (keysym === 0) return;
const { buf, view } = this.buildNekoMsg(NEKO_OP_KEY_UP, 4);
view.setUint32(3, keysym, false);
this.sendDataChannel(buf);
}
close(): void {
if (this.socket.readyState === WS_READY_STATE_CONNECTING) {
this.socket.addEventListener(
"open",
() => {
this.close();
},
{ once: true },
);
return;
}
if (this.socket.readyState === WS_READY_STATE_OPEN) {
if (!this.closeSignalSent) {
this.closeSignalSent = true;
this.sendFrame({ type: "close" });
}
this.socket.close();
return;
}
this.teardownPeerConnection();
if (this.socket.readyState !== WS_READY_STATE_CLOSED) {
this.socket.close();
}
}
private async handleMessage(data: unknown): Promise<void> {
private handleMessage(data: string): void {
let msg: Record<string, unknown>;
try {
if (typeof data === "string") {
const frame = parseStatusFrame(data);
if (!frame) {
this.emitError(new Error("Received invalid desktop stream control frame."));
msg = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
}
if (frame.type === "ready") {
const event = (msg.event as string) ?? "";
// Neko uses "payload" for message data, not "data".
const payload = (msg.payload ?? msg.data) as Record<string, unknown> | undefined;
switch (event) {
case "system/init": {
const screenData = payload?.screen_size as Record<string, unknown> | undefined;
if (screenData) {
const status: DesktopStreamReadyStatus = {
type: "ready",
width: Number(screenData.width) || 0,
height: Number(screenData.height) || 0,
};
this.cachedReadyStatus = status;
for (const listener of this.readyListeners) {
listener(frame);
listener(status);
}
return;
}
// Request control so this session can send input.
this.sendSignaling("control/request", {});
// Request WebRTC stream from neko. The server will respond with
// signal/provide containing the SDP offer.
this.sendSignaling("signal/request", { video: {}, audio: {} });
break;
}
this.emitError(frame);
return;
case "signal/provide":
case "signal/offer": {
if (payload?.sdp) {
void this.handleNekoOffer(payload);
}
break;
}
const bytes = await decodeBinaryFrame(data);
for (const listener of this.frameListeners) {
listener(bytes);
case "signal/restart": {
// Server-initiated renegotiation (treated as a new offer).
// Ref: https://github.com/demodesk/neko-client/blob/37f93ea/src/component/internal/messages.ts#L190-L192
if (payload?.sdp) {
void this.handleNekoOffer(payload);
}
break;
}
case "signal/candidate": {
if (payload) {
void this.handleNekoCandidate(payload);
}
break;
}
case "signal/close": {
// Server is closing the WebRTC connection.
this.teardownPeerConnection();
break;
}
case "system/disconnect": {
const message = (payload as Record<string, unknown>)?.message as string | undefined;
this.emitError(new Error(message ?? "Server disconnected."));
this.close();
break;
}
default:
break;
}
}
private async handleNekoOffer(data: Record<string, unknown>): Promise<void> {
try {
const iceServers: RTCIceServer[] = [];
const nekoIce = (data.iceservers ?? data.ice) as Array<Record<string, unknown>> | undefined;
if (nekoIce) {
for (const server of nekoIce) {
if (server.urls) {
iceServers.push(server as unknown as RTCIceServer);
}
}
}
if (iceServers.length === 0) {
iceServers.push({ urls: "stun:stun.l.google.com:19302" });
}
const config: RTCConfiguration = { ...this.rtcConfig, iceServers };
const pc = new this.PeerConnection(config);
this.pc = pc;
pc.ontrack = (event) => {
const stream = event.streams[0] ?? new MediaStream([event.track]);
this.mediaStream = stream;
for (const listener of this.trackListeners) {
listener(stream);
}
};
pc.onicecandidate = (event) => {
if (event.candidate) {
this.sendSignaling("signal/candidate", event.candidate.toJSON());
}
};
// Ref: https://github.com/demodesk/neko-client/blob/37f93ea/src/component/internal/webrtc.ts#L123-L173
pc.onconnectionstatechange = () => {
switch (pc.connectionState) {
case "connected":
if (!this.connected) {
this.connected = true;
for (const listener of this.connectListeners) {
listener();
}
}
break;
case "closed":
case "failed":
this.emitError(new Error(`WebRTC connection ${pc.connectionState}.`));
break;
}
};
pc.oniceconnectionstatechange = () => {
switch (pc.iceConnectionState) {
case "connected":
if (!this.connected) {
this.connected = true;
for (const listener of this.connectListeners) {
listener();
}
}
break;
case "closed":
case "failed":
this.emitError(new Error(`WebRTC ICE ${pc.iceConnectionState}.`));
break;
}
};
// Neko v3 creates data channels on the server side.
// Ref: https://github.com/demodesk/neko-client/blob/37f93ea/src/component/internal/webrtc.ts#L477-L486
pc.ondatachannel = (event) => {
this.dataChannel = event.channel;
this.dataChannel.binaryType = "arraybuffer";
this.dataChannel.onerror = () => {
this.emitError(new Error("WebRTC data channel error."));
};
this.dataChannel.onclose = () => {
this.dataChannel = null;
};
};
const sdp = data.sdp as string;
await pc.setRemoteDescription({ type: "offer", sdp });
// Flush any ICE candidates that arrived before the PC was ready.
for (const pending of this.pendingCandidates) {
try {
await pc.addIceCandidate(pending as unknown as RTCIceCandidateInit);
} catch {
// ignore stale candidates
}
}
this.pendingCandidates = [];
const answer = await pc.createAnswer();
// Enable stereo audio for Chromium.
// Ref: https://github.com/demodesk/neko-client/blob/37f93ea/src/component/internal/webrtc.ts#L262
if (answer.sdp) {
answer.sdp = answer.sdp.replace(/(stereo=1;)?useinbandfec=1/, "useinbandfec=1;stereo=1");
}
await pc.setLocalDescription(answer);
this.sendSignaling("signal/answer", { sdp: answer.sdp });
} catch (error) {
this.emitError(error instanceof Error ? error : new Error(String(error)));
}
}
private sendFrame(frame: DesktopStreamClientFrame): void {
if (this.socket.readyState !== WS_READY_STATE_OPEN) {
private async handleNekoCandidate(data: Record<string, unknown>): Promise<void> {
// Buffer candidates that arrive before the peer connection is created.
if (!this.pc) {
this.pendingCandidates.push(data);
return;
}
this.socket.send(JSON.stringify(frame));
try {
const candidate = data as unknown as RTCIceCandidateInit;
await this.pc.addIceCandidate(candidate);
} catch (error) {
this.emitError(error instanceof Error ? error : new Error(String(error)));
}
}
private sendSignaling(event: string, payload: unknown): void {
if (this.socket.readyState !== WS_READY_STATE_OPEN) return;
this.socket.send(JSON.stringify({ event, payload }));
}
private sendDataChannel(buf: ArrayBuffer): void {
if (this.dataChannel && this.dataChannel.readyState === "open") {
this.dataChannel.send(buf);
}
}
/** Tear down the peer connection, nullifying handlers first to prevent stale
* callbacks. Matches the reference disconnect() pattern.
* Ref: https://github.com/demodesk/neko-client/blob/37f93ea/src/component/internal/webrtc.ts#L321-L363 */
private teardownPeerConnection(): void {
if (this.dataChannel) {
this.dataChannel.onerror = null;
this.dataChannel.onmessage = null;
this.dataChannel.onopen = null;
this.dataChannel.onclose = null;
try {
this.dataChannel.close();
} catch {
/* ignore */
}
this.dataChannel = null;
}
if (this.pc) {
this.pc.onicecandidate = null;
this.pc.onicecandidateerror = null;
this.pc.onconnectionstatechange = null;
this.pc.oniceconnectionstatechange = null;
this.pc.onsignalingstatechange = null;
this.pc.onnegotiationneeded = null;
this.pc.ontrack = null;
this.pc.ondatachannel = null;
try {
this.pc.close();
} catch {
/* ignore */
}
this.pc = null;
}
this.mediaStream = null;
this.connected = false;
}
private emitError(error: DesktopStreamErrorStatus | Error): void {
@ -203,34 +539,3 @@ export class DesktopStreamSession {
}
}
}
function parseStatusFrame(payload: string): DesktopStreamStatusMessage | null {
const value = JSON.parse(payload) as Record<string, unknown>;
if (value.type === "ready" && typeof value.width === "number" && typeof value.height === "number") {
return {
type: "ready",
width: value.width,
height: value.height,
};
}
if (value.type === "error" && typeof value.message === "string") {
return {
type: "error",
message: value.message,
};
}
return null;
}
async function decodeBinaryFrame(data: unknown): Promise<Uint8Array> {
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
if (typeof Blob !== "undefined" && data instanceof Blob) {
return new Uint8Array(await data.arrayBuffer());
}
throw new Error("Unsupported desktop stream binary frame type.");
}

42
server/compose.dev.yaml Normal file
View file

@ -0,0 +1,42 @@
name: sandbox-agent-dev
services:
backend:
build:
context: ..
dockerfile: docker/test-agent/Dockerfile
image: sandbox-agent-dev
command: ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"]
environment:
RUST_LOG: "${RUST_LOG:-info}"
ports:
- "2468:3000"
# UDP port range for WebRTC media transport (neko)
- "59050-59070:59050-59070/udp"
frontend:
build:
context: ..
dockerfile: docker/inspector-dev/Dockerfile
working_dir: /app
depends_on:
- backend
environment:
SANDBOX_AGENT_URL: "http://backend:3000"
ports:
- "5173:5173"
volumes:
- "..:/app"
# Keep Linux-native node_modules inside the container.
- "sa_root_node_modules:/app/node_modules"
- "sa_inspector_node_modules:/app/frontend/packages/inspector/node_modules"
- "sa_react_node_modules:/app/sdks/react/node_modules"
- "sa_typescript_node_modules:/app/sdks/typescript/node_modules"
- "sa_pnpm_store:/root/.local/share/pnpm/store"
volumes:
sa_root_node_modules: {}
sa_inspector_node_modules: {}
sa_react_node_modules: {}
sa_typescript_node_modules: {}
sa_pnpm_store: {}

View file

@ -41,6 +41,7 @@ base64.workspace = true
toml_edit.workspace = true
tar.workspace = true
zip.workspace = true
tokio-tungstenite = "0.24"
tempfile = { workspace = true, optional = true }
[target.'cfg(unix)'.dependencies]

View file

@ -172,9 +172,9 @@ impl DesktopRuntime {
let recording_manager =
DesktopRecordingManager::new(process_runtime.clone(), config.state_dir.clone());
Self {
streaming_manager: DesktopStreamingManager::new(process_runtime.clone()),
process_runtime,
recording_manager,
streaming_manager: DesktopStreamingManager::new(),
inner: Arc::new(Mutex::new(DesktopRuntimeStateData {
state: DesktopState::Inactive,
display_num: config.display_num,
@ -197,7 +197,10 @@ impl DesktopRuntime {
pub async fn status(&self) -> DesktopStatusResponse {
let mut state = self.inner.lock().await;
self.refresh_status_locked(&mut state).await;
self.snapshot_locked(&state)
let mut response = self.snapshot_locked(&state);
drop(state);
self.append_neko_process(&mut response).await;
response
}
pub async fn start(
@ -221,7 +224,10 @@ impl DesktopRuntime {
self.refresh_status_locked(&mut state).await;
if state.state == DesktopState::Active {
return Ok(self.snapshot_locked(&state));
let mut response = self.snapshot_locked(&state);
drop(state);
self.append_neko_process(&mut response).await;
return Ok(response);
}
if !state.missing_dependencies.is_empty() {
@ -307,7 +313,10 @@ impl DesktopRuntime {
),
);
Ok(self.snapshot_locked(&state))
let mut response = self.snapshot_locked(&state);
drop(state);
self.append_neko_process(&mut response).await;
Ok(response)
}
pub async fn stop(&self) -> Result<DesktopStatusResponse, DesktopProblem> {
@ -336,7 +345,10 @@ impl DesktopRuntime {
state.install_command = self.install_command_for(&state.missing_dependencies);
state.environment.clear();
Ok(self.snapshot_locked(&state))
let mut response = self.snapshot_locked(&state);
drop(state);
self.append_neko_process(&mut response).await;
Ok(response)
}
pub async fn shutdown(&self) {
@ -630,8 +642,26 @@ impl DesktopRuntime {
self.recording_manager.delete(id).await
}
pub async fn start_streaming(&self) -> DesktopStreamStatusResponse {
self.streaming_manager.start().await
pub async fn start_streaming(&self) -> Result<DesktopStreamStatusResponse, SandboxError> {
let state = self.inner.lock().await;
let display = state
.display
.as_deref()
.ok_or_else(|| SandboxError::Conflict {
message: "desktop runtime is not active".to_string(),
})?;
let resolution = state
.resolution
.clone()
.ok_or_else(|| SandboxError::Conflict {
message: "desktop runtime is not active".to_string(),
})?;
let environment = state.environment.clone();
let display = display.to_string();
drop(state);
self.streaming_manager
.start(&display, resolution, &environment)
.await
}
pub async fn stop_streaming(&self) -> DesktopStreamStatusResponse {
@ -642,6 +672,10 @@ impl DesktopRuntime {
self.streaming_manager.ensure_active().await
}
pub fn streaming_manager(&self) -> &DesktopStreamingManager {
&self.streaming_manager
}
async fn recording_context(&self) -> Result<DesktopRecordingContext, SandboxError> {
let mut state = self.inner.lock().await;
let ready = self
@ -1577,6 +1611,14 @@ impl DesktopRuntime {
processes
}
/// Append neko streaming process info to the response, if a neko process
/// has been started by the streaming manager.
async fn append_neko_process(&self, response: &mut DesktopStatusResponse) {
if let Some(neko_info) = self.streaming_manager.process_info().await {
response.processes.push(neko_info);
}
}
fn record_problem_locked(&self, state: &mut DesktopRuntimeStateData, problem: &DesktopProblem) {
state.last_error = Some(problem.to_error_info());
self.write_runtime_log_locked(

View file

@ -1,37 +1,205 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use sandbox_agent_error::SandboxError;
use crate::desktop_types::DesktopStreamStatusResponse;
use crate::desktop_types::{DesktopProcessInfo, DesktopResolution, DesktopStreamStatusResponse};
use crate::process_runtime::{ProcessOwner, ProcessRuntime, ProcessStartSpec};
/// Internal port where neko listens for HTTP/WS traffic.
const NEKO_INTERNAL_PORT: u16 = 18100;
/// UDP ephemeral port range for WebRTC media.
const NEKO_EPR: &str = "59050-59070";
/// How long to wait for neko to become ready.
const NEKO_READY_TIMEOUT: Duration = Duration::from_secs(15);
/// How long between readiness polls.
const NEKO_READY_POLL: Duration = Duration::from_millis(300);
#[derive(Debug, Clone)]
pub struct DesktopStreamingManager {
inner: Arc<Mutex<DesktopStreamingState>>,
process_runtime: Arc<ProcessRuntime>,
}
#[derive(Debug, Default)]
struct DesktopStreamingState {
active: bool,
process_id: Option<String>,
/// Base URL for neko's internal HTTP server (e.g. "http://127.0.0.1:18100").
neko_base_url: Option<String>,
/// Session cookie obtained from neko login, used for WS auth.
neko_session_cookie: Option<String>,
display: Option<String>,
resolution: Option<DesktopResolution>,
}
impl DesktopStreamingManager {
pub fn new() -> Self {
pub fn new(process_runtime: Arc<ProcessRuntime>) -> Self {
Self {
inner: Arc::new(Mutex::new(DesktopStreamingState::default())),
process_runtime,
}
}
pub async fn start(&self) -> DesktopStreamStatusResponse {
/// Start the neko streaming subprocess targeting the given display.
pub async fn start(
&self,
display: &str,
resolution: DesktopResolution,
environment: &HashMap<String, String>,
) -> Result<DesktopStreamStatusResponse, SandboxError> {
let mut state = self.inner.lock().await;
state.active = true;
DesktopStreamStatusResponse { active: true }
if state.active {
return Ok(DesktopStreamStatusResponse { active: true });
}
// Stop any stale process.
if let Some(ref old_id) = state.process_id {
let _ = self.process_runtime.stop_process(old_id, Some(2000)).await;
state.process_id = None;
state.neko_base_url = None;
state.neko_session_cookie = None;
}
let mut env = environment.clone();
env.insert("DISPLAY".to_string(), display.to_string());
let bind_addr = format!("0.0.0.0:{}", NEKO_INTERNAL_PORT);
let screen = format!("{}x{}@30", resolution.width, resolution.height);
let snapshot = self
.process_runtime
.start_process(ProcessStartSpec {
command: "neko".to_string(),
args: vec![
"serve".to_string(),
"--server.bind".to_string(),
bind_addr,
"--desktop.screen".to_string(),
screen,
"--desktop.display".to_string(),
display.to_string(),
"--capture.video.display".to_string(),
display.to_string(),
"--capture.video.codec".to_string(),
"vp8".to_string(),
"--capture.audio.codec".to_string(),
"opus".to_string(),
"--webrtc.epr".to_string(),
NEKO_EPR.to_string(),
"--webrtc.icelite".to_string(),
"--webrtc.nat1to1".to_string(),
"127.0.0.1".to_string(),
"--member.provider".to_string(),
"noauth".to_string(),
// Disable the custom xf86-input-neko driver (defaults to true
// in neko v3). The driver socket is not available outside
// neko's official Docker images; XTEST is used instead.
"--desktop.input.enabled=false".to_string(),
],
cwd: None,
env,
tty: false,
interactive: false,
owner: ProcessOwner::Desktop,
restart_policy: None,
})
.await
.map_err(|e| SandboxError::Conflict {
message: format!("failed to start neko streaming process: {e}"),
})?;
let neko_base = format!("http://127.0.0.1:{}", NEKO_INTERNAL_PORT);
state.process_id = Some(snapshot.id.clone());
state.neko_base_url = Some(neko_base.clone());
state.display = Some(display.to_string());
state.resolution = Some(resolution);
state.active = true;
// Drop the lock before waiting for readiness.
drop(state);
// Wait for neko to be ready by polling its login endpoint.
let deadline = tokio::time::Instant::now() + NEKO_READY_TIMEOUT;
let login_url = format!("{}/api/login", neko_base);
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let mut session_cookie = None;
loop {
match client
.post(&login_url)
.json(&serde_json::json!({"username": "admin", "password": "admin"}))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
// Extract NEKO_SESSION cookie from Set-Cookie header.
if let Some(set_cookie) = resp.headers().get("set-cookie") {
if let Ok(cookie_str) = set_cookie.to_str() {
// Extract just the cookie value (before the first ';').
if let Some(cookie_part) = cookie_str.split(';').next() {
session_cookie = Some(cookie_part.to_string());
}
}
}
tracing::info!("neko streaming process ready, session obtained");
// Take control so the connected client can send input.
let control_url = format!("{}/api/room/control/take", neko_base);
if let Some(ref cookie) = session_cookie {
let _ = client
.post(&control_url)
.header("Cookie", cookie.as_str())
.send()
.await;
tracing::info!("neko control taken");
}
break;
}
_ => {}
}
if tokio::time::Instant::now() >= deadline {
tracing::warn!("neko did not become ready within timeout, proceeding anyway");
break;
}
tokio::time::sleep(NEKO_READY_POLL).await;
}
// Store the session cookie.
if let Some(ref cookie) = session_cookie {
let mut state = self.inner.lock().await;
state.neko_session_cookie = Some(cookie.clone());
}
Ok(DesktopStreamStatusResponse { active: true })
}
/// Stop streaming and tear down neko subprocess.
pub async fn stop(&self) -> DesktopStreamStatusResponse {
let mut state = self.inner.lock().await;
if let Some(ref process_id) = state.process_id.take() {
let _ = self
.process_runtime
.stop_process(process_id, Some(3000))
.await;
}
state.active = false;
state.neko_base_url = None;
state.neko_session_cookie = None;
state.display = None;
state.resolution = None;
DesktopStreamStatusResponse { active: false }
}
@ -44,4 +212,109 @@ impl DesktopStreamingManager {
})
}
}
/// Get the neko WebSocket URL for signaling proxy, including session cookie.
pub async fn neko_ws_url(&self) -> Option<String> {
self.inner
.lock()
.await
.neko_base_url
.as_ref()
.map(|base| base.replace("http://", "ws://") + "/api/ws")
}
/// Get the neko base HTTP URL (e.g. `http://127.0.0.1:18100`).
pub async fn neko_base_url(&self) -> Option<String> {
self.inner.lock().await.neko_base_url.clone()
}
/// Create a fresh neko login session and return the session cookie.
/// Each WebSocket proxy connection should call this to get its own
/// session, avoiding conflicts when multiple clients connect.
/// Uses a unique username per connection so neko treats them as
/// separate members (noauth provider allows any credentials).
pub async fn create_neko_session(&self) -> Option<String> {
let base_url = self.neko_base_url().await?;
let client = reqwest::Client::new();
let login_url = format!("{}/api/login", base_url);
let username = format!(
"user-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
tracing::debug!(
"creating neko session: username={}, url={}",
username,
login_url
);
let resp = match client
.post(&login_url)
.json(&serde_json::json!({"username": username, "password": "admin"}))
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!("neko login request failed: {e}");
return None;
}
};
if !resp.status().is_success() {
tracing::warn!("neko login returned status {}", resp.status());
return None;
}
let cookie = resp
.headers()
.get("set-cookie")
.and_then(|v| v.to_str().ok())
.map(|v| v.split(';').next().unwrap_or(v).to_string());
let cookie = match cookie {
Some(c) => c,
None => {
tracing::warn!("neko login response missing set-cookie header");
return None;
}
};
tracing::debug!("neko session created: {}", username);
// Take control for this session.
let control_url = format!("{}/api/room/control/take", base_url);
let _ = client
.post(&control_url)
.header("Cookie", &cookie)
.send()
.await;
Some(cookie)
}
/// Get the shared neko session cookie (used during startup).
pub async fn neko_session_cookie(&self) -> Option<String> {
self.inner.lock().await.neko_session_cookie.clone()
}
pub async fn resolution(&self) -> Option<DesktopResolution> {
self.inner.lock().await.resolution.clone()
}
pub async fn is_active(&self) -> bool {
self.inner.lock().await.active
}
/// Return process diagnostics for the neko streaming subprocess, if one
/// has been started. The returned info mirrors the shape used by
/// `DesktopRuntime::processes_locked` for xvfb/openbox/dbus.
pub async fn process_info(&self) -> Option<DesktopProcessInfo> {
let state = self.inner.lock().await;
let process_id = state.process_id.as_ref()?;
let snapshot = self.process_runtime.snapshot(process_id).await.ok()?;
Some(DesktopProcessInfo {
name: "neko".to_string(),
pid: snapshot.pid,
running: snapshot.status == crate::process_runtime::ProcessStatus::Running,
log_path: None,
})
}
}

View file

@ -235,7 +235,7 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
)
.route("/desktop/stream/start", post(post_v1_desktop_stream_start))
.route("/desktop/stream/stop", post(post_v1_desktop_stream_stop))
.route("/desktop/stream/ws", get(get_v1_desktop_stream_ws))
.route("/desktop/stream/signaling", get(get_v1_desktop_stream_ws))
.route("/agents", get(get_v1_agents))
.route("/agents/:agent", get(get_v1_agent))
.route("/agents/:agent/install", post(post_v1_agent_install))
@ -1181,7 +1181,7 @@ async fn delete_v1_desktop_recording(
async fn post_v1_desktop_stream_start(
State(state): State<Arc<AppState>>,
) -> Result<Json<DesktopStreamStatusResponse>, ApiError> {
Ok(Json(state.desktop_runtime().start_streaming().await))
Ok(Json(state.desktop_runtime().start_streaming().await?))
}
/// Stop desktop streaming.
@ -1201,13 +1201,14 @@ async fn post_v1_desktop_stream_stop(
Ok(Json(state.desktop_runtime().stop_streaming().await))
}
/// Open a desktop websocket streaming session.
/// Open a desktop WebRTC signaling session.
///
/// Upgrades the connection to a websocket that streams JPEG desktop frames and
/// accepts mouse and keyboard control frames.
/// Upgrades the connection to a WebSocket used for WebRTC signaling between
/// the browser client and the desktop streaming process. Also accepts mouse
/// and keyboard input frames as a fallback transport.
#[utoipa::path(
get,
path = "/v1/desktop/stream/ws",
path = "/v1/desktop/stream/signaling",
tag = "v1",
params(
("access_token" = Option<String>, Query, description = "Bearer token alternative for WS auth")
@ -2451,46 +2452,6 @@ enum TerminalClientFrame {
Close,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum DesktopStreamClientFrame {
MoveMouse {
x: i32,
y: i32,
},
MouseDown {
#[serde(default)]
x: Option<i32>,
#[serde(default)]
y: Option<i32>,
#[serde(default)]
button: Option<DesktopMouseButton>,
},
MouseUp {
#[serde(default)]
x: Option<i32>,
#[serde(default)]
y: Option<i32>,
#[serde(default)]
button: Option<DesktopMouseButton>,
},
Scroll {
x: i32,
y: i32,
#[serde(default)]
delta_x: Option<i32>,
#[serde(default)]
delta_y: Option<i32>,
},
KeyDown {
key: String,
},
KeyUp {
key: String,
},
Close,
}
async fn process_terminal_ws_session(
mut socket: WebSocket,
runtime: Arc<ProcessRuntime>,
@ -2603,133 +2564,117 @@ async fn process_terminal_ws_session(
}
}
async fn desktop_stream_ws_session(mut socket: WebSocket, desktop_runtime: Arc<DesktopRuntime>) {
let display_info = match desktop_runtime.display_info().await {
Ok(info) => info,
Err(err) => {
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
let _ = socket.close().await;
/// WebRTC signaling proxy session.
///
/// Proxies the WebSocket bidirectionally between the browser client and neko's
/// internal WebSocket endpoint. All neko signaling messages (SDP offers/answers,
/// ICE candidates, system events) are relayed transparently.
async fn desktop_stream_ws_session(mut client_ws: WebSocket, desktop_runtime: Arc<DesktopRuntime>) {
use futures::SinkExt;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
// Get neko's internal WS URL from the streaming manager.
let neko_ws_url = match desktop_runtime.streaming_manager().neko_ws_url().await {
Some(url) => url,
None => {
let _ = send_ws_error(&mut client_ws, "streaming process is not available").await;
let _ = client_ws.close().await;
return;
}
};
if send_ws_json(
&mut socket,
json!({
"type": "ready",
"width": display_info.resolution.width,
"height": display_info.resolution.height,
}),
// Create a fresh neko login session for this connection.
// Each proxy connection gets its own neko session to avoid conflicts
// when multiple clients connect (neko sends signal/close to shared sessions).
let session_cookie = desktop_runtime
.streaming_manager()
.create_neko_session()
.await;
// Build a WS request with the neko session cookie for authentication.
let ws_req = {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let mut req = neko_ws_url
.into_client_request()
.expect("valid neko WS URL");
if let Some(ref cookie) = session_cookie {
req.headers_mut()
.insert("Cookie", cookie.parse().expect("valid cookie header"));
}
req
};
// Connect to neko's internal WebSocket.
let (neko_ws, _) = match tokio_tungstenite::connect_async(ws_req).await {
Ok(conn) => conn,
Err(err) => {
let _ = send_ws_error(
&mut client_ws,
&format!("failed to connect to streaming process: {err}"),
)
.await
.is_err()
{
.await;
let _ = client_ws.close().await;
return;
}
};
let mut frame_tick = tokio::time::interval(Duration::from_millis(100));
let (mut neko_sink, mut neko_stream) = neko_ws.split();
// Relay messages bidirectionally between client and neko.
loop {
tokio::select! {
ws_in = socket.recv() => {
match ws_in {
// Client → Neko (signaling passthrough; input goes via WebRTC data channel)
client_msg = client_ws.recv() => {
match client_msg {
Some(Ok(Message::Text(text))) => {
match serde_json::from_str::<DesktopStreamClientFrame>(&text) {
Ok(DesktopStreamClientFrame::MoveMouse { x, y }) => {
if let Err(err) = desktop_runtime
.move_mouse(DesktopMouseMoveRequest { x, y })
.await
{
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
}
}
Ok(DesktopStreamClientFrame::MouseDown { x, y, button }) => {
if let Err(err) = desktop_runtime
.mouse_down(DesktopMouseDownRequest { x, y, button })
.await
{
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
}
}
Ok(DesktopStreamClientFrame::MouseUp { x, y, button }) => {
if let Err(err) = desktop_runtime
.mouse_up(DesktopMouseUpRequest { x, y, button })
.await
{
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
}
}
Ok(DesktopStreamClientFrame::Scroll { x, y, delta_x, delta_y }) => {
if let Err(err) = desktop_runtime
.scroll_mouse(DesktopMouseScrollRequest {
x,
y,
delta_x,
delta_y,
})
.await
{
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
}
}
Ok(DesktopStreamClientFrame::KeyDown { key }) => {
if let Err(err) = desktop_runtime
.key_down(DesktopKeyboardDownRequest { key })
.await
{
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
}
}
Ok(DesktopStreamClientFrame::KeyUp { key }) => {
if let Err(err) = desktop_runtime
.key_up(DesktopKeyboardUpRequest { key })
.await
{
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
}
}
Ok(DesktopStreamClientFrame::Close) => {
let _ = socket.close().await;
if neko_sink.send(TungsteniteMessage::Text(text.into())).await.is_err() {
break;
}
Err(err) => {
let _ = send_ws_error(&mut socket, &format!("invalid desktop stream frame: {err}")).await;
}
Some(Ok(Message::Binary(data))) => {
if neko_sink.send(TungsteniteMessage::Binary(data.into())).await.is_err() {
break;
}
}
Some(Ok(Message::Ping(payload))) => {
let _ = socket.send(Message::Pong(payload)).await;
let _ = client_ws.send(Message::Pong(payload)).await;
}
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(Message::Binary(_))) | Some(Ok(Message::Pong(_))) => {}
Some(Ok(Message::Pong(_))) => {}
Some(Err(_)) => break,
}
}
_ = frame_tick.tick() => {
let frame = desktop_runtime
.screenshot(DesktopScreenshotQuery {
format: Some(DesktopScreenshotFormat::Jpeg),
quality: Some(60),
scale: Some(1.0),
})
.await;
match frame {
Ok(frame) => {
if socket.send(Message::Binary(frame.bytes.into())).await.is_err() {
// Neko → Client
neko_msg = neko_stream.next() => {
match neko_msg {
Some(Ok(TungsteniteMessage::Text(text))) => {
if client_ws.send(Message::Text(text.into())).await.is_err() {
break;
}
}
Err(err) => {
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
let _ = socket.close().await;
Some(Ok(TungsteniteMessage::Binary(data))) => {
if client_ws.send(Message::Binary(data.into())).await.is_err() {
break;
}
}
Some(Ok(TungsteniteMessage::Ping(payload))) => {
if neko_sink.send(TungsteniteMessage::Pong(payload.clone())).await.is_err() {
break;
}
}
Some(Ok(TungsteniteMessage::Close(_))) | None => break,
Some(Ok(TungsteniteMessage::Pong(_))) => {}
Some(Ok(TungsteniteMessage::Frame(_))) => {}
Some(Err(_)) => break,
}
}
}
}
let _ = neko_sink.close().await;
let _ = client_ws.close().await;
}
async fn send_ws_json(socket: &mut WebSocket, payload: Value) -> Result<(), ()> {
socket
.send(Message::Text(