This repo is a monorepo for Orcabot - a sandboxed, multiplayer AI coding platform.
ALWAYS add a revision marker when modifying code. This eliminates "did you deploy?" debugging.
-
Comment at top of modified file:
// REVISION: <feature>-v<N>-<brief> -
Log on load/startup with runtime timestamp:
// Frontend (TypeScript) const MODULE_REVISION = "feature-v1-desc"; console.log(`[module] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}`);
// Backend (Go) const moduleRevision = "feature-v1-desc" log.Printf("[module] REVISION: %s loaded at %s", moduleRevision, time.Now().Format(time.RFC3339))
-
For API responses: Add a
revisionfield so clients can display what version is running.
This is non-negotiable. Never speculate about deployment issues - add logs that prove it.
- Run Claude Code, Codex, or shell in the browser with zero setup
- Sandboxed VMs for security (isolated execution)
- Built-in Chromium browser for testing
- Secrets broker protects API keys from LLM exfiltration
- Persistent, background intelligent processes
- Multiplayer dashboards (Figma-like collaboration)
frontend— Next.js dashboard UI (includes blog, docs, admin)controlplane— Cloudflare Worker control planesandbox— Go sandbox serverbridge— Node.js messaging bridge service (WhatsApp via Baileys), deployed on Flydesktop— Tauri native desktop app (bundles entire stack locally with VM sandbox); also ships theorcabotCLI (a 2nd binary in the same crate) that runs the stack headlessly and drives it from the terminale2e— Playwright end-to-end tests (external, against running instance)
- Frontend:
frontend/CLAUDE.md - Control plane:
controlplane/CLAUDE.md - Sandbox:
sandbox/CLAUDE.md - Bridge:
bridge/CLAUDE.md - Desktop:
desktop/CLAUDE.md
cd sandbox && make deploy && cd ../controlplane && wrangler deploy -c wrangler.production.toml && cd ../frontend && npm run workers:deploy && cd ..
Sandbox:
docker run --rm -it \
-p 8080:8080 \
--cpus=2 --memory=4g \
-e SANDBOX_INTERNAL_TOKEN=... \
-e CONTROLPLANE_URL=http://localhost:8787 \
-e INTERNAL_API_TOKEN=... \
-e ALLOWED_ORIGINS=http://localhost:8788 \
-v orcabot-sandbox-workspace:/workspace \
orcabot-sandbox
Control plane:
npm run dev
Frontend:
npx wrangler dev
- Frontend never talks directly to sandbox; all traffic goes through the control plane
- Cloudflare control plane uses dev auth via headers/query params when
DEV_AUTH_ENABLED=true - Control plane → sandbox requests are signed per machine (Ed25519,
SANDBOX_AUTH_SIGNING_KEYin the control plane; VMs get onlySANDBOX_AUTH_PUBLIC_KEY). The legacy staticSANDBOX_INTERNAL_TOKENis accepted only while configured — do not reuse Cloudflare API keys. Desktop generates the key pair per boot and passes the public key on the VM's kernel command line; its VM holds no static token and noINTERNAL_API_TOKEN - Personal access tokens (PATs) authenticate the
orcabotCLI / scripts against a remote control plane: prefixorca_pat_, sent asAuthorization: Bearer, only the SHA-256 hash is stored (api_tokenstable). Middleware is prefix-gated (no collision with PTY JWT bearers) and method-gated — a PAT cannot mint/list/revoke other PATs (rejectPatAuth). Seecontrolplane/CLAUDE.md.
Orcabot has a layered defense system to prevent LLMs from exfiltrating API keys:
-
Secrets Broker - API keys are NOT set as env vars. Instead, a session-local broker injects keys server-side. LLMs only see placeholder values. Broker configs and approved domains are session-namespaced — two terminals in the same VM cannot see or overwrite each other's keys.
-
Output Redaction - Any secret values in PTY output are replaced with asterisks before reaching WebSocket clients.
-
Domain Allowlisting - Built-in providers (Anthropic, OpenAI, ElevenLabs, etc.) have hardcoded target domains. Custom secrets require owner approval per-domain, scoped per-session.
-
Localhost Auth - The MCP server and event endpoints on localhost require
X-MCP-Secretproof-of-possession. Each PTY gets a uniqueORCABOT_MCP_SECRETenv var at creation. This prevents rogue processes in the sandbox from calling MCP tools or faking agent events. -
PTY Token Fail-Closed -
INTERNAL_API_TOKENmust be non-empty. If it's missing, PTY token verification rejects all tokens rather than accepting them. -
Network Egress Proxy ("Little Snitch for AI Agents") - An HTTP/HTTPS forward proxy on
localhost:8083intercepts all outbound network requests from PTY processes. Known domains (package registries, git hosting, CDNs, LLM APIs) are allowed automatically. Unknown domains are held (the connection hangs) while a toast + approval dialog is shown to the user. Options: Allow Once, Always Allow, Deny, or Deny Always. 60-second timeout = deny (fail-closed). User-approved "Always Allow" domains persist per-dashboard in D1; "Deny Always" domains (e.g. trackers) persist per-dashboard in D1 too (egress_blocklist) and are then blocked immediately without prompting (deny takes precedence over the allowlist).
Security invariants (non-negotiable):
- Broker configs keyed by
sessionID:provider— never global provider name - Broker URLs include session ID:
/broker/{sessionID}/{provider}/... - Approved domains keyed by
sessionID:secretName— never just secret name - Empty
INTERNAL_API_TOKEN= all PTY tokens rejected (fail-closed) - PTY + dashboard tokens are signed with
PTY_TOKEN_SECRET, a control-plane-only secret — neverINTERNAL_API_TOKEN, which every sandbox VM holds (root can read it from/proc). WithoutPTY_TOKEN_SECRETset the control plane falls back toINTERNAL_API_TOKENand logs a SECURITY WARNING - Sandbox → control plane
/internal/*calls authenticate with the VM's dashboard-scoped token (X-Dashboard-Token,sandbox/internal/cpauth), and every VM-reachable route checks the resource belongs to that dashboard (controlplane/src/auth/sandbox-auth.ts).X-Internal-Tokenis full trust for control-plane-side callers only; withWITHHOLD_INTERNAL_TOKEN_FROM_SANDBOX=truenew VMs never receiveINTERNAL_API_TOKEN. New VM-called routes must useauthorizeSandboxForDashboard, neverrequireInternalAuth - Every control plane → sandbox request goes through
sandboxFetch/sandboxHeaders/SandboxClientso it carriesX-Sandbox-Auth: an Ed25519 signature over target machine (FLY_MACHINE_ID), method and path+query, valid 30s plus the VM's 30s skew allowance (60s replay window at most), plusfly-force-instance-idso no other VM is the first hop. Never call a sandbox with a hand-builtfetch. A bad signature never falls back to the static token - The dashboard browser runs unprivileged, never root: Chromium as
orcabot-browser, Xvfb/x11vnc/websockify asorcabot-vnc, on loopback ports 6100-6299 that pool UIDs andorcabot-browser(page JS) are blocked from, so one dashboard's page can't reach another's VNC/DevTools; the X display requires its auth cookie. Seesandbox/CLAUDE.md(Browser Block) - A dashboard VM only ever receives its owner's secrets (
dashboardSecretsOwner,controlplane/src/secrets/handler.ts): one VM is shared by every member's terminals, so pushing whoever acted last would put their keys behind every terminal's broker URL - Pool UIDs (2000-2099) are egress default-deny at the kernel: only loopback (incl. the 80/443 REDIRECT), established replies, DNS to resolv.conf nameservers, and
EGRESS_EXTRA_TCP_PORTS(opt-in, e.g.22) are allowed - Localhost MCP/event endpoints require valid
X-MCP-Secret(no fail-open) - Egress proxy fail-closed: 60s timeout = deny; proxy crash = connections fail
- Localhost always bypasses egress proxy (NO_PROXY + server-side check)
- Egress decisions come from control plane API (user-authenticated), never from sandbox
Key files:
sandbox/internal/broker/— Broker implementationsandbox/cmd/server/env.go— Env setup with session-scoped broker configcontrolplane/src/secrets/— Secrets API + encryptioncontrolplane/src/auth/pty-token.ts— PTY token creation/verification (fail-closed)frontend/src/components/blocks/TerminalBlock.tsx— Secrets UIsandbox/internal/egress/— Egress proxy + allowlistcontrolplane/src/egress/handler.ts— Egress approval endpoints + audit loggingfrontend/src/components/EgressApprovalDialog.tsx— Approval dialog UIfrontend/src/components/EgressAllowlistPanel.tsx— Allowlist management UI
The egress proxy is gated behind a single env var:
- Global: Set
EGRESS_PROXY_ENABLED=trueon the Fly machine to enable for all sessions - Without this flag, the proxy runs but no PTY processes route through it (iptables rules not installed, UID pool not activated)
- Control plane provides connect + callback endpoints:
/integrations/google/drive/connect+/callback/integrations/google/gmail/connect+/callback/integrations/google/calendar/connect+/callback/integrations/github/connect+/callback/integrations/twitter/connect+/callback
- Required env vars on Cloudflare:
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRETGITHUB_CLIENT_ID,GITHUB_CLIENT_SECRETTWITTER_CLIENT_ID,TWITTER_CLIENT_SECRETOAUTH_REDIRECT_BASE(optional; defaults to request origin)
- D1 tables:
oauth_states,user_integrations,terminal_integrations,integration_policies
Orcabot uses a component integration gate model: on the dashboard canvas, users draw edges from terminal blocks to integration blocks (Gmail, GitHub, Drive, Calendar, Twitter). Only attached integrations are visible as MCP tools to the LLM in that terminal. Each attachment has a policy that controls what actions are allowed and what data the LLM can see.
- No tool without edge — MCP only exposes tools for integrations attached to the terminal
- No policy from request — Policy is loaded from
active_policy_idin DB, never from the sandbox - Boolean enforcement —
enforcePolicy()uses only if/else logic, no LLM judgment - OAuth tokens stay in control plane — Never sent to sandbox; API calls made server-side
- Fail-closed — Missing policy, expired token, or unknown action = deny
- Audit before response — Every request logged before response is returned
- Filtered responses only — LLM never sees raw API data; responses are filtered + formatted
LLM (in sandbox PTY)
→ calls gmail_search via MCP
→ mcp-bridge forwards to sandbox MCP server (HTTP)
→ sandbox calls control plane gateway: POST /internal/gateway/gmail/execute
(sends PTY token for auth, action + args only — no OAuth token)
→ control plane:
1. Verify PTY token (HMAC-SHA256 JWT → terminal_id, dashboard_id, user_id)
2. Load terminal_integration + active_policy from DB
3. Check rate limits
4. enforcePolicy() — boolean logic
5. Get OAuth access token (refresh if expired)
6. Call Gmail/GitHub/Drive/Calendar/Twitter API
7. Filter response based on policy (sender allowlist, repo filter, etc.)
8. Format response for LLM (decode base64, strip HTML, extract headers)
9. Log audit entry
10. Return filtered response
→ sandbox wraps in MCP content format
→ mcp-bridge returns to LLM
controlplane/src/integration-policies/gateway.ts— Gateway execute endpoint + PTY token verificationcontrolplane/src/integration-policies/handler.ts— Attach/detach integrations, policy CRUDcontrolplane/src/integration-policies/response-filter.ts— Policy-based response filteringcontrolplane/src/integration-policies/api-clients/— Gmail, GitHub, Drive, Calendar, Twitter API wrapperscontrolplane/src/auth/pty-token.ts— PTY token creation/verificationsandbox/cmd/mcp-bridge/main.go— stdio-to-HTTP bridge with tool change notificationssandbox/cmd/server/mcp.go— MCP server with integration tool handlingsandbox/internal/mcp/integration_tools.go— Tool definitions for all providerssandbox/internal/mcp/gateway_client.go— Client for control plane gateway calls
When an LLM agent (Claude, Gemini, etc.) finishes a turn, stop hooks notify the
sandbox so it can broadcast agent_stopped WebSocket events to all connected clients.
- Hook scripts:
sandbox/internal/agenthooks/hooks.go - Supported agents: Claude Code, Gemini CLI, OpenCode, Codex, Droid, OpenClaw
- Settings files generated per-agent (
.claude/settings.json,.gemini/settings.json, etc.) - All hooks must include
X-MCP-Secretheader fromORCABOT_MCP_SECRETenv var — unauthenticated hook callbacks are rejected
- Each dashboard gets its own dedicated VM (one sandbox per dashboard)
- Each terminal creates its own session in the control plane (one session per terminal item)
- All terminal sessions in a dashboard share the same sandbox VM; each session maps to a PTY inside that VM
- Sandbox sessions use a shared
/workspaceby default - PTY cwd is set to the session workspace
- Multiple PTYs per sandbox with turn-taking
- The
desktopTauri app bundles the whole stack locally: control plane onworkerd, a SQLite "D1" shim, and a sandbox VM (Apple Virtualization.framework on macOS / QEMU on Linux). - The same crate ships a 2nd binary,
orcabot— a terminal CLI that boots the stack headlessly and drives it (interactive TUI + scriptable subcommands:up/down/status/exec/ls/tail/new/connect/attach, plusexport/import/push/pull). - Owned-session lifecycle: whoever starts the stack tears it down on exit; there is no always-on daemon.
up/downare the explicit persist-mode escape hatch. - Surface switching moves a live session between
cli↔desktop↔web. The GUI's "Switch to CLI" button (Tauriswitch_to_clicommand) hands off to the terminal; the TUI can launch the desktop GUI. Desktop boot, the vsock bridges,/debug/exec, and the full CLI reference live indesktop/CLAUDE.md.
- A dashboard + its workspace can be packaged for transfer. Locally:
orcabot export/importproduce/consume an.orcabotbundle. Remotely:orcabot push/pullsync against a remote control plane, authenticated with a PAT. - File transfer goes through the control plane file proxy (
GET/PUT /sessions/:id/file,POST /sessions/:id/workspace/import), never directly to a sandbox. Pull writes are path-guarded (safe_workspace_dest) so a malicious remote can't escape the workspace.
- Browser block checks embeddability via control plane
/embed-check - If embedding is blocked, UI collapses to a small "open in new tab" panel
- Sandbox-side talkito handles TTS via brokered API calls
- Frontend shows TTS status via WebSocket events
- Supports OpenAI, ElevenLabs, Deepgram, Google providers
- Saved subagents persist per user in control plane
- Catalog lives in
frontend/src/data/claude-subagents.json
- Per-terminal model picker in the terminal settings dropdown (Claude/Codex/OpenCode/Droid/Gemini)
- Default = harness's native API; OpenRouter routes through the local secrets broker. Three paths, by harness:
- OpenCode/Droid: OpenAI-compatible
openrouterprovider viaOPENAI_BASE_URL - Codex: per-invocation
-cCLI flags injected bybuildCodexOpenRouterCommand(model_provider/base_url=broker/wire_api="responses") — the Rust Codex CLI ignoresOPENAI_BASE_URL/OPENAI_MODEL - Claude Code: Anthropic-compatible
openrouter-anthropicprovider viaANTHROPIC_BASE_URL - Gemini: official CLI GATEWAY auth → local translation shim (
sandbox/internal/geminishim/, port 8086) that converts the Gemini wire format to OpenAI Chat Completions → broker → OpenRouter
- OpenCode/Droid: OpenAI-compatible
- Selection lives in
terminalContent.modelSelection(provider/model + catalog-resolvedcontextWindow/maxOutputTokens) and triggers the existing "restart to apply" banner. Limits flow frontend→controlplane→sandbox to set Codex's-c model_context_window/model_max_output_tokens. - Catalog:
frontend/src/data/openrouter-models.json(curated, per-model pricing + context +maxOutputTokens+ compatibleHarnesses) - Drift check:
npm run check-catalogsvalidates pricing, context length, and max output tokens vs upstream (also runs weekly via.github/workflows/check-catalogs.yml) - Upstream provider errors (OpenRouter 429/402/401/5xx) are detected in the broker and surfaced as a dashboard toast via a
model_provider_errorWS event (seeclassifyProviderErrorinsandbox/internal/broker/secrets_broker.go) - Key files:
sandbox/internal/sessions/model_selection.go,sandbox/internal/geminishim/,sandbox/internal/broker/providers.go,frontend/src/components/blocks/TerminalBlock.tsx(Model panel)
- File tree is populated via control plane proxy of sandbox filesystem APIs
- Control plane stores ASR API keys encrypted in
user_secrets(global scope) - Three providers: AssemblyAI (token vending), OpenAI Whisper (HTTP proxy), Deepgram (streaming WS + REST fallback)
ASRStreamProxyDurable Object relays WebSocket audio between browser and Deepgram (API key injected server-side)- Frontend
useASRhook manages provider selection, streaming, and chunked transcription - Key files:
controlplane/src/asr/,frontend/src/hooks/useASR.ts,frontend/src/components/blocks/ASRSettingsDialog.tsx
- Client-side event tracking with batched ingestion (
POST /analytics/events, max 50/batch) - Server-side event logging for agent type detection and session lifecycle
- Admin metrics endpoint (
GET /admin/metrics): DAU/WAU/MAU, signups, agent type breakdown, integration adoption, 7-day retention - D1 table:
analytics_events+users.last_active_atcolumn - Key files:
controlplane/src/analytics/handler.ts,frontend/src/lib/analytics.ts
- Inbound webhook handling for: Slack, Discord, Telegram, WhatsApp, Teams, Matrix, Google Chat
- Per-platform signature verification (HMAC-SHA256, Ed25519, token header)
- Inbound policy enforcement: channel filter, sender filter,
repliesOnlymode - Messages buffered in
inbound_messagesD1 table, broadcast via Durable Object, can trigger VM wake-up - Canvas edges from messaging blocks to terminals enable communication paths
- Key files:
controlplane/src/messaging/webhook-handler.ts
- Separate Node.js service in
bridge/deployed on Fly - Handles WhatsApp connectivity via Baileys (unofficial WhatsApp Web API)
- Session management for persistent WhatsApp connections
- Control plane communicates with bridge via
controlplane/src/bridge/client.ts
- Stripe-based subscription management
- Paywall checks for feature gating
- Key files:
controlplane/src/subscriptions/
- Blog posts served via
controlplane/src/blog/handler.ts - Frontend blog at
frontend/src/app/(blog)/with markdown content infrontend/content/blog/ - In-app documentation at
frontend/src/app/(docs)/with content infrontend/src/docs/content/ - AI chat with help docs:
controlplane/src/chat/handler.ts,controlplane/src/chat/help-docs.ts
- Transactional email via Resend:
controlplane/src/email/resend.ts
External Playwright test suite in e2e/ that runs against a live OrcaBot instance.
cd e2e && npm install && npx playwright install chromium
ORCABOT_URL=https://your-instance.com npm test- Sequential execution (1 worker) — tests share real instance state
- 120s test timeout, 30s expect timeout (sandbox boot can be slow)
- Traces on first retry, screenshots on failure
auth— API-based login (fast) and UI-based logindashboard— Create/goto/delete with auto-cleanupterminal— Add terminal, wait for prompt, type commands, wait for outputapi— Direct control plane API client
01-auth-flow— Login/logout flows02-dashboard-crud— Dashboard create/delete03-terminal-basic— Terminal creation04-terminal-command— Command execution05-integration-blocks— Integration block creation06-integration-wiring— Edge wiring (terminals ↔ integrations)07-policy-editor— Policy UI08-gemini-chess— Full agent scenario (Gemini playing chess)