Skip to content

Cloud box: claim with a passkey, a Terminal tab, Render or fly - #121

Open
hhff wants to merge 4 commits into
mainfrom
cloud
Open

hhff wants to merge 4 commits into
mainfrom
cloud

Conversation

@hhff

@hhff hhff commented Nov 6, 2026

Copy link
Copy Markdown
Member

What

An owner can run their whole devteam setup as one container on Render (or fly.io, or any Docker host with a disk). It holds the repos, the tmux server where every supervisor and worker runs, and the hub, reached at a URL of the owner's.

  • cloud/Dockerfile builds the box: Node 22, git, tmux, gh, ttyd 1.7.7 (checksum-pinned), mise and Claude Code. Its header has the Render and fly steps.
  • cloud/gateway.mjs is the box's only public listener (Node, no dependencies).
    • Claiming: the first boot's log prints a claim link. Opening it registers a passkey (1Password saves it), and from then on only a registered passkey gets in. WebAuthn is implemented directly with Node's crypto.
    • Names:
      • box.example.com serves the hub, with a new Terminal tab first (ttyd on the box's tmux server).
      • <port>.box.example.com serves each dashboard, so each keeps an origin of its own, as on a laptop.
      • auth.box.example.com serves sign-in and passkeys, and only the gateway's own pages.
    • The kit is unchanged: every proxied request reaches the kit as a local browser's would, and the kit's loopback origins in its response headers are mapped to the public ones.
  • hub.html: when a gateway answers box.json, instance URLs come from it and the Terminal tab and a Passkeys link appear. On a laptop nothing changes.
  • README: an optional "running it in the cloud" section. cloud/ sits outside docs/devteam/, so adopting projects never copy it.

Why

The owner wants their dev box in the cloud, reachable from any browser, claimed and opened with a passkey, with a terminal for the supervisors and logins.

Security model

  • Sign-in is on a name agents can't touch. Every agent runs as dev. The hub page is read from a checkout, and its port and ttyd's can be rebound by dev, so the box's own name is treated as agent-controlled.
    • Ceremonies and passkey management happen only on auth., which serves only the gateway's bytes.
    • A passkey made on any other origin is refused.
    • Adding a passkey takes a sign-in in the last 5 minutes.
  • The gateway's state is out of agents' reach. The gateway runs as a separate gw user, and its state is in /data/gateway (mode 0700). Agents can't read it or kill the gateway (both checked in the container).
  • Each name accepts writes only from its own pages.
    • Every non-GET request must carry the Origin of the name it's sent to.
    • The terminal's WebSocket needs the box's Origin plus a session.
    • A dashboard's page can't open the terminal or post to the auth name (checked in Chrome).
  • The cookie is SameSite=None; Secure; HttpOnly; Domain=<box>. Sandboxed review pages and artifacts have opaque origins, so a Lax cookie wouldn't go with their loads. Instead, the gateway refuses any cross-site, non-navigation read except what a sandboxed page loads: the kit's two helpers, /api/file images, and artifact files.
  • Reclaim needs a root shell. devteam-reclaim, run as root in a shell on the box (Render Shell, fly ssh console), is how a lost passkey is recovered.

Verification

  • node --test test/gateway.test.mjs: 5 tests covering:
    • CBOR and COSE decoding, and claim, login, add and remove, using a software authenticator.
    • Every refusal: wrong origin, RP or ceremony; missing UV; challenge reuse; forged signature; counter replay; userHandle; a stale session adding a passkey; open-redirect next; Host and port variants; absolute-form request lines; cross-site reads.
    • reclaim, a restart keeping the reclaim link, and corrupt state.
    • A real devteam.mjs dashboard behind the gateway: hub CSP mapping, an owner POST passing the kit's own same-origin check, a review page, header and cookie scrubbing, and the WebSocket.
  • Image: built and booted locally. A dashboard comes back after a restart. A stop takes about 4 s and exits 0.
  • Real Chrome: a virtual passkey, a TLS proxy standing in for Render's, and the container. 14/14 checks pass:
    • claim → hub → typed in the Terminal tab and it ran in the box's tmux;
    • the dashboard on its own subdomain → an owner action → a sandboxed review page loading devteam-ui.js and a committed image with the cookie;
    • from a dashboard's page, the terminal WebSocket, an auth POST and terminal reads are all blocked;
    • from the hub's page, adding a passkey is blocked;
    • signing out on an instance URL goes to sign-in on auth. and back.
  • Reviews: an adversarial review of the spec and one of the code. Every blocker and major finding is fixed, which is where the per-instance subdomains and the auth name come from.

Not in this PR

  • Add-repo and resume-supervisor buttons; the terminal covers both for now.
  • A mobile key bar.
  • An egress allowlist.
  • A published image.

Migration

None. Projects adopting the kit get no new files. The hub change only runs when a gateway answers box.json.

🤖 Generated with Claude Code

hhff and others added 4 commits November 5, 2026 22:53
… or fly

cloud/Dockerfile builds one container that is the owner's dev box: the repos, the tmux server
where every supervisor and worker runs, and the hub. cloud/gateway.mjs (node, no dependencies)
is its only public listener: the box is claimed with a passkey from a link in the first boot's
log, and afterwards only a registered passkey gets in. The hub is the box's home page, with a
Terminal tab first (ttyd on the box's tmux server); each dashboard keeps an origin of its own on
a subdomain (4321.box.example.com), so nothing a dashboard runs can reach the terminal or the
box's passkeys. Requests reach the kit as a local browser's would, and the kit's loopback origins
in its headers are mapped to the public ones, so devteam.mjs is unchanged. The gateway runs as its
own user, so no agent can read or write its state.

hub.html: on a box (box.json answers), instance URLs come from the gateway and the Terminal tab
is first; a 401 reloads the page to sign in. On a laptop nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eview's other findings

The hub and the terminal are bytes the box's user (every agent) can change: the hub page is read
from a checkout, and its port and ttyd's can be taken. So signing in and passkeys move to
auth.<box>, which serves only the gateway's own pages; a passkey made anywhere else is refused, and
adding one takes a sign-in in the last five minutes. With a SameSite=None cookie, a read another
site's page makes is refused, but for what a sandboxed review page or artifact loads.

Also: a response cut off upstream ends the browser's, and a browser that leaves stops the kit's
work; nothing behind the gateway sets a cookie for the box's names, and every session cookie sent
is tried; state that can't be read never reads as a new box; a link reclaim printed survives a
restart; login challenges are pooled apart; the terminal's upgrade forwards its first bytes and
can't leak a ttyd connection; origin-form request lines only; RSA under 2048 bits, duplicate CBOR
keys and trailing attestation bytes refused. Image: gw's uid pinned, its state chowned on boot,
a stop closes the tmux server before the gateway, and the hub links to Passkeys on a box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Fable's review

dev's login shells read files any agent can write, so dev's side started first could take the
public port and stand in for the gateway. The gateway now starts first and dev's side only once
/healthz answers (checked in the container with a listener planted in .profile: it can't bind).

Also: the Render notes name every DNS record a wildcard needs and the real claim link; the
onrender.com address is refused with the name to open; reclaim says it needs root; mise and Claude
Code are pinned; an artifact's cross-site loads are limited to subresource kinds; the README says
a repo's kit must be at this release to serve the box's hub; a test for the https cookie and HSTS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ifact may load its own Worker; pins noted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hhff

hhff commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Review (upstream loop, adversarial): CHANGES. One blocker. The WebAuthn implementation holds up.

Blocker

  1. The terminal's WebSocket opens for any page served on box., and an agent can serve box..
    • The upgrade needs only Origin box. plus a session (gateway.mjs:470). box. proxies to HUB_PORT (:460), and when the hub port is refused it falls back to /term/ (:356).
    • The PR's own model calls box. agent-controlled. A worker that binds 4320 while no hub holds it gets an ungoverned shell in the owner's tmux, through the owner's browser: the page can type and read the stream. A worker can do that on first boot, during a dashboard restart gap, or with PORT=4320 npm run dev, and the kit then never takes 4320 back.
    • Agents are governed by policy, not by Unix user, so "they're already dev" doesn't close it.
    • Repro: a fake server on the hub port served HTML on box./. An upgrade to /term/ws with Origin box. and the owner's cookie returned 101.
    • Fix: give the terminal its own name (term., like auth.), with the gateway serving ttyd's page from its own copy and proxying only the socket. Require Origin term., and drop the /term/ fallback.

Should-fix

  1. Anyone on the internet can lock the owner out of signing in. /auth/login/options is unauthenticated, curl can set any Origin, and the challenge pool holds 100 (:197). 101 calls and the owner's verify fails. Bind each challenge to a cookie set with the options response, or rate-limit per IP.
  2. An agent page can sign the owner out of every name. About 30 KB of Domain=<box> cookies makes Node answer 431 everywhere, auth. included. An upstream Clear-Site-Data: "cookies" also passes through (:337). Strip that header. A per-name __Host- cookie minted via auth. removes the shared jar.
  3. rpId is the box's name, so any agent origin under it can raise a real passkey prompt (a phish) or create() junk credentials. Use auth.<box>.
  4. Any website can load artifact files with the cookie. crossSiteOk passes /artifact/* for any sec-fetch-dest (:372), so a <script src> from any site reaches the kit. Serve artifact subresources under a capability token in the path, or from a cookieless name. Also, same-site requests skip the check entirely, which trusts every subdomain of the registrable domain.
  5. Clickjacking. The mapped frame-ancestors lets an agent page on box. frame 4321.box. and steer the owner's clicks.
  6. IPv6 squat. The gateway listens on 0.0.0.0 only, and dev could bind [::]:8080 (checked in Docker). Listen dual-stack on ::.
  7. Supply chain: curl https://mise.run | sh is unpinned, @anthropic-ai/claude-code is unversioned, and the base image isn't pinned by digest. The ttyd checksums match.
  8. The claim link never expires, and it sits in platform logs, which a wider group can read than can open a shell. Add a TTL, or log only a hint and require DEVTEAM_CLAIM_TOKEN.

Nits

  • dev can write fake "claim link" lines to stdout.
  • The domain = (with a space) scrub is missed.
  • /auth/passkeys/remove doesn't need a fresh sign-in.
  • The RSA exponent isn't checked.
  • store.load() at :391 is outside the try, so a corrupt state file crash-loops the box.
  • A cross-site <img> of /api/file leaks whether a file exists, and its dimensions.

Checked and fine

  • WebAuthn:
    • type, origin and crossOrigin;
    • single-use challenges bound to purpose and session;
    • rpId hash, UP and UV, the signature, and alg/kty/crv tied together;
    • the counter;
    • the first-claim race.
  • Isolation: dev can't signal gw or read its state or environment.
  • Request handling:
    • absolute-form and * request lines refused, and CL+TE refused;
    • forwarding headers stripped;
    • the session cookie never reaches upstreams;
    • Origin is rewritten only when it equals the name's own, and other non-GETs are refused;
    • safeNext is sound;
    • cookie tossing can't fixate a session.
  • On a laptop, box.json is a no-op.

Method: a scratch worktree, node --test test/gateway.test.mjs (5/5), a proof of concept against the gateway with fixture upstreams, and a Docker check on node:22-bookworm-slim.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant