Web-based cmux — a FastAPI-driven, password-protected terminal multiplexer
that runs in your browser. Tabs, OSC-driven workspace tracking, persistent
reconnect, on-screen keypad for mobile, plus an integrated file
preview/search and public-share layer for the contents under $HOME
(and any extra mount points you whitelist).
Supported platforms: Linux, macOS, Windows 10 1809+. Python ≥ 3.10.
- Multi-tab browser terminal — xterm.js front-end over a Python PTY back-end, per-tab WebSocket with replay buffer + heartbeat keep-alive (§4.18) + exponential-backoff reconnect (§4.9). URLs in terminal output are tappable (web-links addon, §4.20). Scrollbar reserves a stable gutter so column count never jumps under Chromium when scrollback grows (§4.21).
- Workspaces by cwd —
?cwd=<abs path>in the URL keys a workspace; tabs spawned with that param all start there and are listed under the brand drop-down. Closing the browser doesn't kill PTYs (§4.10 / §4.11). - Mobile-first keypad — Sticky Ctrl/Alt that combines with the next
character, hold-to-repeat arrows / PgUp / PgDn, plus the shell symbols
(
]`\) Android/iOS soft keyboards bury two layers deep. Bottom-bar keypad floats above the on-screen keyboard viavisualViewport(§4.12 – §4.17). - Remember this device — toolbar drop-down toggle issues a long-lived
signed token kept in
localStorage; on cookie eviction the page silently exchanges it for a fresh session, no second login (§4.19). - File preview sub-router — toolbar
+-adjacent search box scans up to two directory levels under the configured roots, drops matching files into a list, opens them in a separate window with markdown / code / text / image / jsonl / drawio rendering (§4.22). Multi-root support viaWCMUX_PREVIEW_EXTRA_ROOTSso USB / SD-card / NAS mounts are reachable too. - Public sharing — preview pages have a 🔗 button that mints a
/share/<YYYY-MM-DD>/<slug>-<id12>URL. Recipients view a self-rendered HTML (no JS, strict CSP, server-side pygments highlight, inline CSS). Markdown image references under the preview roots are auto-bundled. Default expiry 1 year, options 1d / 7d / 1mo / 3mo / 1y / 3y / never; revoke from the brand-menu "我的分享" page (§4.23). - Auth, lockout, and password hashing — argon2id (
wcmux hash-password), IP-based 5-fail / 15-min lockout shared across/login,/api/auth/exchange, and/share/*lookups.
# With uv (recommended — installs deps from pyproject.toml)
uv venv .venv --python 3.10
uv pip install -e .
# Or plain pip
python -m venv .venv && . .venv/bin/activate
pip install -e .Runtime deps include fastapi, uvicorn[standard], itsdangerous,
argon2-cffi, bcrypt, markdown, pygments, ptyprocess (Unix) /
pywinpty (Windows).
wcmux --password <your-password>
# or
WCMUX_PASSWORD=<pw> python -m wcmuxBrowse to http://<host>:8022/ and sign in.
All options also read from env vars. CLI args win over env vars.
| CLI | env | default |
|---|---|---|
--port |
WCMUX_PORT |
8022 |
--host |
WCMUX_HOST |
0.0.0.0 |
--base-url |
WCMUX_BASE_URL |
"" (no prefix) |
--password |
WCMUX_PASSWORD |
required (or WCMUX_PASSWORD_HASH) |
--password-hash |
WCMUX_PASSWORD_HASH |
preferred over plaintext for production |
--shell |
WCMUX_SHELL |
$SHELL / /bin/bash (Unix), %COMSPEC% / powershell.exe (Win) |
--secret-key |
WCMUX_SECRET_KEY |
random (warns; sessions / device tokens invalidated on restart) |
--trust-proxy |
WCMUX_TRUST_PROXY=1 |
off |
| — | WCMUX_PREVIEW_ROOT |
$HOME (primary preview root, anchors relative paths) |
| — | WCMUX_PREVIEW_EXTRA_ROOTS |
"" (colon-separated extra roots, e.g. /media/orangepi) |
| — | WCMUX_DEVICES_FILE |
~/.local/share/wcmux/devices.json |
| — | WCMUX_SHARES_FILE |
~/.local/share/wcmux/shares.json |
Run wcmux --help for the authoritative CLI list.
Generate a password hash for production:
wcmux hash-password
# paste the resulting argon2id hash into wcmux.env as WCMUX_PASSWORD_HASH| Keys | Action |
|---|---|
Ctrl+Alt+T |
New tab |
Ctrl+Alt+W |
Close current tab |
Ctrl+Alt+← / Ctrl+Alt+→ |
Prev / next tab (cyclic) |
Ctrl+Alt+1 … Ctrl+Alt+9 |
Jump to tab by index |
Tap Ctrl / Alt on bottom keypad |
Sticky modifier — applies to next character |
wcmux tracks each tab's working directory two ways: it parses OSC 7
(\e]7;file://…\e\\) from the terminal stream as the shell emits it on
prompt redraw, and falls back to polling the shell process's cwd via
psutil. Both observe the shell's cwd — if a wrapper script (e.g. a
project-switcher like ccs) only cds inside a subshell, neither catches
the directory it's actually working on.
The terminal-emulator-friendly fix is to emit the relevant escapes from inside the script. Two short lines, works in any modern terminal that parses OSC sequences (iTerm2, kitty, alacritty, VS Code, wcmux, …):
# After the script decides which dir it is operating on:
target="$1" # or however your script picks the project dir
printf '\033]7;file://%s%s\033\\' "${HOSTNAME:-localhost}" "$target" # OSC 7 — wcmux's cwd_full updates
printf '\033]0;%s\007' "$(basename "$target")" # OSC 0/2 — sets tab title
# If the script returns control to the parent shell, restore so the next
# prompt's OSC 7 isn't out of sync:
trap 'printf "\033]7;file://%s%s\033\\" "${HOSTNAME:-localhost}" "$PWD"' EXITBash / zsh users who want the shell itself to emit OSC 7 on every
directory change (so wcmux tracks cd even outside scripts):
# bash (~/.bashrc):
PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\" "${HOSTNAME:-localhost}" "$PWD"'"${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
# zsh (~/.zshrc):
chpwd_functions+=(_wcmux_emit_cwd)
_wcmux_emit_cwd() { printf '\e]7;file://%s%s\e\\' "${HOST:-localhost}" "$PWD"; }All routes live under <base_url> (default empty).
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | / |
session | Terminal page (the wcmux SPA) |
| GET | /healthz |
none | Liveness probe |
| GET / POST | /login |
none | Login form |
| POST | /logout |
session | Clears the session cookie |
| GET / POST | /api/tabs |
session | List / create tabs |
| DELETE / PATCH | /api/tabs/{id} |
session | Close / rename |
| WS | /ws/{tab_id} |
session | Per-tab terminal stream |
| POST | /api/auth/issue-device-token |
session | Mint a long-lived device token |
| POST | /api/auth/exchange |
none | Swap a device token for a session |
| GET / DELETE | /api/auth/devices[/{id}] |
session | List / revoke devices |
| GET | /api/preview/list |
session | Directory listing under preview roots |
| GET | /api/preview/file |
session | Render-payload for one file |
| GET | /api/preview/search |
session | Filename + dir-name substring search, depth ≤ 2 |
| POST | /api/preview/save |
session | Save (drawio only, MVP) |
| GET | /raw/preview |
session | Raw bytes of a previewable file |
| GET | /static/preview/{index,preview,shares}.html |
session | Preview front-end |
| POST | /api/share |
session | Create a public share for a previewable file |
| GET | /api/share |
session | List shares |
| DELETE | /api/share/{id} |
session | Revoke |
| GET | /share/{date}/{slug}-{id12} |
public | Render the shared file (strict CSP) |
| GET | /share/{date}/{slug}-{id12}/a |
public | Bundled markdown image asset |
| GET | /share/{date}/{slug}-{id12}/raw |
public | Raw source for image-typed shares |
Mount under a subpath like /wcmux/:
location /wcmux/ {
proxy_pass http://127.0.0.1:8022/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 3600s;
}Start wcmux with the matching prefix:
wcmux --password ... --base-url /wcmux --trust-proxyThe preview front-end uses relative URLs throughout, so it works under any prefix transparently.
# ~/.config/systemd/user/wcmux.service
[Unit]
Description=wcmux — web cmux (user service)
After=network-online.target
[Service]
Type=simple
EnvironmentFile=%h/.config/wcmux.env
ExecStart=/bin/sh -c 'exec %h/.local/share/wcmux-venv/bin/wcmux --secret-key "$(cat %h/.config/wcmux.secret)"'
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target~/.config/wcmux.env typical contents:
WCMUX_PORT=22091
WCMUX_HOST=0.0.0.0
WCMUX_PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$...$...'
# Optional: also expose external mounts to preview/share/search
WCMUX_PREVIEW_EXTRA_ROOTS=/media/orangepi:/mnt/nassystemctl --user daemon-reload
systemctl --user enable --now wcmux.service
journalctl --user -u wcmux.service -f(For a system-wide service run as another user, drop %h for absolute
paths and put the unit under /etc/systemd/system/.)
Under docs/:
spec-YYYYMMDD.md— requirementstesting-YYYYMMDD.md— verification checklistplan-YYYYMMDD.md— implementation plan
The latest dated pair is the source of truth; older files are kept for history (do not edit).
Integration tests live in tests/. Each milestone has a dedicated script.
tests/runserver.sh starts a fresh server per script so state (e.g. login
lockouts, device-token registry, share registry) doesn't bleed across tests.
tests/runserver.sh tests/test_m1_auth.py tests/test_m2_single_tab.py \
tests/test_m3_multitab.py tests/test_m4_cwd.py \
tests/test_m5_baseurl.py tests/test_m6_exit_autoclose.py \
tests/test_m8_workspace.py tests/test_m9_osc_live.py \
tests/test_m10_stale_session.py \
tests/test_m11_device_token.py \
tests/test_m12_preview.py \
tests/test_m13_share.py
.venv/bin/python tests/test_m4_shorten.py
.venv/bin/python tests/test_m9_osc_unit.pyMIT.
![Mobile keypad with ] ` \ keys](/cailiang9/wcmux/raw/main/images/wcmux1.jpg)

