Sandbox and browser isolation
This page explains how Otto isolates the commands it runs and the websites it visits, and what each part of the system can and can't reach.
The short version: commands run as an unprivileged user in a workspace container. The browser runs in a separate container. Provider keys and the owner access key stay in the server process.
Trust boundaries
Section titled “Trust boundaries”flowchart LR
server[Otto server<br/>provider keys · Vault]:::accent
subgraph workspace[Workspace container]
sandbox[Sandbox user<br/>runs Pi commands]
cli[otto-connected<br/>one CLI token]
end
browser[Browser container<br/>Chromium · profiles]:::muted
net([Internet])
server -- supervised commands --> sandbox
server -- sealed CLI run --> cli
server -- stdin bridge --> browser
sandbox --> net
cli --> net
browser --> net
What each part can and can't reach
Section titled “What each part can and can't reach”| Server | Sandbox user | Connected CLI user | Browser container | |
|---|---|---|---|---|
| Provider keys | Yes | No | No | No |
| Owner access key | Yes | No | No | No |
| Vault values | Yes | No | No | Only a value the server enters in a page |
| A connected CLI token | Yes | No | Its own token only | No |
| Docker socket | Yes, locally | No | No | No |
| Workspace files | Through a guarded reader | Yes | Files passed as arguments | No shared mounts |
| Browser profiles | Through the bridge | No | No | Yes |
| Internet | Yes | Yes, no egress firewall from source | Yes | Yes. A saved profile reaches only allowed origins. |
The workspace container
Section titled “The workspace container”Pi's native tools (read, bash, edit, write, find and ls) run inside the workspace through WorkspaceSession.exec. Each thread works in /workspace/<thread ID>. Locally, that's the OTTO_DATA_ROOT/workspaces folder the server also uses. /home/node is a separate volume, so tools Pi installs persist.
- Commands run as the unprivileged
nodeuser, without sudo. - Bash gets no host environment. It sets the owner's
TZwhen known. - No workspace file is loaded as instructions.
Both the workspace and browser containers drop all capabilities, set no-new-privileges and have these limits:
| Container | Source install (compose.yaml) |
Desktop app |
|---|---|---|
| Workspace | 8 GB memory, 4 CPUs, 4,096 processes | 2 GB memory, 2 CPUs, 1,024 processes |
| Browser | 8 GB memory, 6 CPUs, 1,024 processes, read-only root | 2 GB memory, 2 CPUs, 768 processes, read-only root |
The command supervisor
Section titled “The command supervisor”Every command goes through a small supervisor inside the container, in src/providers/docker/command.ts.
- It owns the command's session and signals every process in it, even ones that left the process group.
- It enforces the timeout: 10 minutes by default, at most one hour for
bash. - It acknowledges when the command ends. If that acknowledgement is lost, the server fences the workspace: no more commands run until Otto restarts.
Command output
Section titled “Command output”Up to 1 MiB of output reaches Pi. Pi shows the model the tail, and codemode scripts get the whole output and the exit code. Larger output keeps its first and last 512 KiB and names the complete log in the workspace.
Pi writes truncated output, codemode files and images, and extracted web PDFs to the server's temp folder. The runtime moves each file into the workspace's .otto/outputs folder and rewrites its path before the result is recorded. See src/providers/pi/sandbox.ts.
Connected CLIs
Section titled “Connected CLIs”A connected CLI runs as a separate user, otto-connected. Before each run, the supervisor installs the CLI's declared source into /home/otto-connected/cli/<path id> if it's missing. It refuses an install folder or executable that another user can write.
The CLI then runs with an environment built from scratch: a fixed PATH, a private temporary HOME and XDG_CONFIG_HOME, LANG and the one token variable. Nothing comes from the sandbox user's files or environment, so a program the model plants never receives the token. See MCP, APIs and CLIs.
The browser container
Section titled “The browser container”The browser is a separate trusted container. It doesn't share mounts or a network namespace with the workspace. The server drives Vercel Labs agent-browser over a fixed stdin bridge to a private Unix socket.
| Setting | Value |
|---|---|
| Display | Headed Chromium on a private 1920x1080 virtual display per session |
| Viewport | 1440x900, with Noto fonts and software WebGL |
| Time zone | The owner's saved IANA time zone, or UTC when unknown or invalid |
| Language | The owner's formatting locale, such as en-GB, sent as Accept-Language |
| HTTP cache | Under /tmp, per session, outside saved profiles |
Pi reads the page from agent-browser's accessibility snapshot, with @eN references, as plain text after the page URL. Otto runs no page-parsing scripts of its own. While a dialog is open, the observation is the dialog, and every action except open is refused until Pi accepts or dismisses it.
Known secret values are removed from tool output and masked in screenshots. Pi uses saved profiles through browser_session and never reads their files. The code is in src/providers/agent-browser/.
Browser handoff
Section titled “Browser handoff”When Pi needs you in the browser, it asks with an Open browser link. Otto web shows the live tab in chat.
sequenceDiagram participant Pi actor You participant Server participant Browser Pi->>You: Question with an Open browser link Browser-->>You: Live JPEG frames You->>Server: Clicks, typing and scrolling Server->>Browser: Forwarded while the turn waits and holds the browser You->>Server: Done Note over Pi: Next turn takes a fresh snapshot
- Input is forwarded only while the latest conversation turn waits on a question and holds the browser.
- One bridge call carries frames out, newest first, and your input in. Losing the connection disables input until it reconnects.
- Frames pause during credential entry. Passwords you type in the handoff join the known secrets.
- Native WebAuthn stays on, but Otto doesn't supply an authenticator. A passkey needs one of yours.