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