npm run dev or starts a service inside its container, the operator wants a clickable URL to open it. Port expose creates a capability URL — https://<host>/exposed/<token> — that reverse-proxies to the in-container port. The token is the auth: anyone with the URL gets through, anyone without it gets a 404. Exposes are created by the sidecar on the agent’s behalf, then listed and revoked through the user-facing crew endpoints.
This is the right shape for ephemeral dev URLs: short-lived, easy to share with a teammate via Slack, no JWT plumbing needed. For long-lived or sensitive surfaces, do not use this — set up a proper authenticating proxy.
Implementation: internal/api/port_expose_handler.go. Capability URLs hit Go 1.22+ ServeMux with /exposed/{token} and /exposed/{token}/ (trailing slash and bare-token forms both routed to the same handler).
Endpoints
Capability URL — reverse proxy
Upgrade: websocket is rejected with 426 Upgrade Required (websocket not supported); the exposed-port proxy only handles plain HTTP request/response traffic.
Errors:
There is no rate limit on the capability URL — it forwards as fast as the underlying TCP connection allows.
Container runtime requirements
The capability URL is proxied by crewshipd itself, which dials the crew container on its Docker bridge IP (http://172.19.0.2:3000). No host port is ever published — that is the design intent — so the proxy works only when the process running crewshipd can route to the container network directly.
services: sidecars are unaffected on every runtime — agent↔sidecar traffic never leaves the container network. Only the host-facing /exposed/{token} proxy needs host→container routability.
Sidecar request
How a new capability URL is minted — the agent never calls this directly.Internal only —
X-Internal-Token required. The agent asks the sidecar (e.g. via a tool call), the sidecar asks the server. Agents cannot reach this endpoint directly.
Response:
201 Created
User-facing audit + revocation
List
ACTIVE exposes are returned. Filter with ?status=active|revoked|expired|all.
Response: 200 OK — a bare JSON array (not wrapped in an exposes object).
The
token (and thus the public url) is intentionally not included — only the issuing agent receives the capability URL.
Revoke
MANAGER+ (the create permission). Not idempotent — a second revoke of the same expose returns 409 Conflict because the conditional update only matches ACTIVE/PENDING rows.
Request body (optional):
reason is optional (max 500 chars) and recorded on the audit row.
Response: 200 OK
Tenancy and policy
crewIdis validated viacrewBelongsToWorkspace.- An
AllowAllPolicyruns on every capability-URL request (currently always returns allow). Custom policies can be wired by replacing the policy object — useful for restricting expose to specific IP ranges or to authenticated browser sessions. - The capability URL itself has no workspace context (it’s a public URL). The token-to-port lookup happens server-side and is workspace-isolated by construction.
Security notes
- Tokens are 256 bits of
crypto/rand. Brute-forcing a valid token via the 404 oracle is not feasible. - Tokens land in URL paths, which means they end up in HTTP server logs and browser history. Do not paste a capability URL into a public chat or screenshot it without thinking.
- TLS termination is the operator’s responsibility — Crewship does not currently terminate HTTPS for
/exposed/. Run behind nginx / Caddy / a cloud load balancer that does. - Revocation is fast but not transactional with in-flight requests. A request that is mid-stream when revoke fires completes; subsequent requests fail.
Related
- Devcontainers — Container actuals — pairs with port expose for “this agent installed Node and started a server”.
- Architecture — Sidecar Proxy — the surrounding sidecar surface.