Skip to content

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.

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
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.

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 node user, without sudo.
  • Bash gets no host environment. It sets the owner's TZ when 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

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.

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.

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 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/.

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.