Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ The API is split into two groups:
| **Browser** | Check if Chrome CDP is reachable; stream a live browser screencast via WebSocket at `/api/browser/ws/{project_id}`. |
| **Projects** | Pause/resume/reset projects, update config. |

> **The internal API has no authentication of its own** (as of 2026-09). None of the credential, capability, membership, approval or event-log machinery above applies to it, and that includes the WebSocket surfaces — `/ws`, `/api/terminal/ws/{project_id}` (a shell in the sandbox container), `/api/browser/ws/{project_id}` (CDP relay: screenshots, input, navigation), `/api/desktop/websockify`. The optional `PROXY_AUTH_*` check covers HTTP requests only, not WebSockets (see [Environment Variables](#environment-variables)). Treat the bound port as trusted-network-only: loopback, or a tailnet / authenticating proxy in front. Details: [`docs/security/agent-identity.md`](docs/security/agent-identity.md).
> **The internal API is open unless you set `INTERNAL_API_KEY`.** By default it has no authentication, and none of the credential, capability, membership, approval or event-log machinery above applies to it — including the WebSocket surfaces: `/ws`, `/api/terminal/ws/{project_id}` (a shell in the sandbox container), `/api/browser/ws/{project_id}` (CDP relay: screenshots, input, navigation), `/api/desktop/websockify`, and every workspace file via `/api/workspace/{project}/download`. With `INTERNAL_API_KEY` set, `/api` and the websockets require the `X-Internal-Key` header or a browser session from `POST /api/session/login`. The optional `PROXY_AUTH_*` check covers HTTP requests only, not WebSockets. Without a key, treat the port as trusted-network-only (loopback or a tailnet); **before any public exposure — ngrok, `tailscale funnel`, any tunnel — set the key.** Details: [`docs/security/exposure.md`](docs/security/exposure.md), [`docs/security/agent-identity.md`](docs/security/agent-identity.md).

> See `http://localhost:8000/docs` for the full list of endpoints with request/response schemas.

Expand Down
67 changes: 67 additions & 0 deletions docs/security/exposure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Exposing TeamWork — Tailscale, ngrok, and who can open your files

TeamWork binds to loopback by default. How you reach it from elsewhere decides
who else can reach it, and TeamWork serves more than a chat page: the **file
browser, file downloads, the terminal, the browser screencast and the desktop**
all live behind the same address.

## The short version

| How you expose it | Who can reach it | Required |
|---|---|---|
| `tailscale serve` (recommended) | Devices on your tailnet only | Nothing more; `INTERNAL_API_KEY` still advised |
| `tailscale funnel` | **The whole internet** | `INTERNAL_API_KEY` (or proxy auth) |
| ngrok, Cloudflare Tunnel, any public tunnel | **The whole internet** | `INTERNAL_API_KEY` (or proxy auth) |
| Behind an authenticating proxy (IAP, oauth2-proxy) | Whoever the proxy lets in | `PROXY_AUTH_ENABLED` so TeamWork checks the proxy's assertion |

**Prefer `tailscale serve`.** Only your own devices can connect, and nothing is
published to the internet at all. A public tunnel's URL being random or hard to
guess is obscurity, not access control: URLs leak through browser history,
screenshots, shared links, logs and referrer headers, and tunnel hostnames are
scanned.

## What is open without a login

Out of the box, TeamWork has **no login** (`INTERNAL_API_KEY` is empty and
proxy auth is off). On a tailnet, that means "anyone on my tailnet". Over a
public tunnel, it means **anyone on the internet**, including:

- every file in the agent's workspace, through the file browser and
`GET /api/workspace/{project}/download?path=…` — not just files posted to
chat;
- the chat history and every message;
- the shared terminal and the live browser and desktop, where the agent may be
logged in to your accounts.

## Turn on the login before any public exposure

Set `INTERNAL_API_KEY` in TeamWork's `.env` and restart TeamWork. Every `/api`
request and websocket then needs either the `X-Internal-Key` header (for
agents and scripts) or the browser session cookie you get by logging in once
(`POST /api/session/login`). The cookie is `HttpOnly`, `SameSite=Strict`,
`Secure` over HTTPS.

What that means for file links in chat:

- **Links and embedded images keep working for you.** An agent posts files as
relative links (`/api/workspace/…/download?path=…`), so they resolve against
whatever address you opened TeamWork at — tailnet name, localhost or tunnel
URL — and your browser sends the session cookie with them, so images still
display inline.
- **A copied link does not work for anyone else.** Pasted into another app or
sent to someone, it needs a TeamWork login. That is the point: a link to a
workspace file is not a public share.

To publish something to people without a TeamWork login, use the agent's
explicit share feature, which issues a token for that one file and can revoke
it. Don't open TeamWork itself to the internet for that.

## Checklist for a public tunnel

1. `INTERNAL_API_KEY` set, TeamWork restarted, and a logged-out browser gets
`401` on `/api/workspace/<project>/files`.
2. The tunnel points at TeamWork only — never at the agent's own port or a dev
server. (For Prax, see its
[network-exposure guide](https://github.com/praxagent/prax/blob/main/docs/security/network-exposure.md).)
3. You would be comfortable with the workspace contents leaking if the key did.
If not, use `tailscale serve` instead.
Loading