Skip to content

feat(console): browser terminal on this box, gated by moshcode login - #93

Merged
ralyodio merged 1 commit into
mainfrom
feat/console-ttyd-gateway
Jul 30, 2026
Merged

feat(console): browser terminal on this box, gated by moshcode login#93
ralyodio merged 1 commit into
mainfrom
feat/console-ttyd-gateway

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

moshcode console — a real terminal in the browser. Arrow keys, history, full-screen
TUIs, all of it, because the far end is an actual pty and not a log view.

moshcode does not implement the terminal. ttyd
already is one; this is an authenticating reverse proxy in front of it. That's the
whole idea — it replaces hand-rolling xterm.js plus raw keystroke relay into the
session mirror, which would have been much more code for a worse terminal.

Where it runs, and why that matters

The gateway runs on the box you want a shell on, not inside apps/pwa.

The hosted app is on Railway; the shell is on dev. A proxy living in apps/pwa would
have to reach back across the internet to a port on the dev box — i.e. expose it. So
instead the box serves its own gateway and asks app.moshcode.sh exactly one
question: is this token yours?

# on the dev box — ttyd bound to loopback ONLY
ttyd -i 127.0.0.1 -p 7681 -W login
moshcode console serve --port 7682 --ttyd 127.0.0.1:7681

# anywhere you've run `moshcode login`
moshcode console --url https://dev.example.com/

Auth

Everything behind this is a shell, so:

  • ?token= is verified once against /api/me, swapped for a short-lived HMAC cookie,
    and stripped from the URL by the redirect — it doesn't sit in history or ride
    along on every request.
  • The websocket is authenticated too. It carries the terminal; an unauthenticated
    upgrade would hand out a shell no matter how well the page was guarded.
  • Verification fails closed — a network error denies, never admits.
  • The signing secret is per-process, so a restart invalidates every cookie.
  • --bind defaults to 127.0.0.1; binding 0.0.0.0 prints a warning pointing at
    tailscale or a TLS proxy.

Verified end to end

Against a stub upstream:

unauth  : 401
auth    : 302 -> /          (HttpOnly cookie, no token in Location)
proxied : 200 "ttyd saw GET /xterm.js"

Tests

13 new in test/console.test.mjs, deliberately adversarial: forged signature, cookie
with a swapped user, cookie with an extended expiry, malformed input, and an
unauthenticated websocket upgrade. One of them caught a real bug pre-merge — the
cookie split on . and every email contains dots, so the user field is base64url now.

Full suite: 326 tests, 248 pass, 0 fail, 78 pre-existing skips.

Relationship to #92

#92 (pty capture) is still the thing that records output into the session log for
scrollback and async viewing. This is the live, interactive path. They're
complementary, but if you only want one, this is the one that answers "arrow keys".

🤖 Generated with Claude Code

Adds `moshcode console`: a real terminal in the browser — arrow keys, history,
full-screen TUIs — because the far end is an actual pty rather than a log view.

moshcode does not implement the terminal. ttyd already is one, so this is an
authenticating reverse proxy in front of it and nothing more. That replaces the
alternative of hand-rolling xterm.js plus raw keystroke relay into the session
mirror, which would have been a far larger surface for a worse terminal.

The gateway runs on the box you want a shell on, NOT inside apps/pwa. The hosted
app is on Railway and the shell is on the dev box; a proxy in apps/pwa would have
to reach back to a port on that box across the internet, which means exposing it.
Instead the box serves its own gateway and asks app.moshcode.sh only whether a
token is valid.

Auth:
  - ?token=<moshcode token> is verified once against /api/me, then swapped for a
    short-lived HMAC cookie and stripped from the URL by the redirect, so it does
    not linger in history or ride along on every request.
  - The websocket is authenticated too. It carries the terminal, so an
    unauthenticated upgrade would hand out a shell no matter how well the page
    itself was guarded.
  - Verification fails closed: a network error denies rather than admits.
  - The signing secret is per-process, so a restart invalidates every cookie.

Defaults are the safe ones: --bind is 127.0.0.1, ttyd is expected on loopback,
and binding 0.0.0.0 prints a warning pointing at tailscale or a TLS proxy.

Verified end to end against a stub upstream: unauthenticated 401, token exchange
302 with an HttpOnly cookie and a token-free location, then a cookie-bearing
request proxied through to the backend.

13 new tests in test/console.test.mjs, mostly adversarial (forged signature,
swapped user, extended expiry, malformed input, unauthenticated upgrade). Full
suite: 326 tests, 248 pass, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit 67a9dd2 into main Jul 30, 2026
3 checks passed
@ralyodio
ralyodio deleted the feat/console-ttyd-gateway branch August 8, 2026 17:05
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